refactor(server): share env parsing, shutdown and score capping helpers

Collapse the three env parsers into one generic helper, the duplicated
graceful-shutdown block into shutdownServer, and the score cap into
pointsFor. Behaviour is unchanged.
This commit is contained in:
tiennm99 committed 2026-09-28 15:19:59 +07:00
1 parent 97a352f9f5
commit 5848751c6b
6 files changed
+56 -100

No files matched your search

+2 -6
View File
@@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"iter"
"math/rand/v2"
"net/url"
"os"
"slices"
@@ -27,8 +28,6 @@ import (
"strings"
"unicode"
"math/rand/v2"
"golang.org/x/text/unicode/norm"
_ "modernc.org/sqlite"
)
@@ -98,7 +97,7 @@ func Open(path string) (*Store, error) {
return nil, fmt.Errorf("dictionary not found at %s — run 'make fetch-dict && make dict' first: %w", path, err)
}
db, err := sql.Open("sqlite", dsn(path))
db, err := sql.Open("sqlite", DSN(path, true))
if err != nil {
return nil, fmt.Errorf("open dictionary: %w", err)
}
@@ -148,9 +147,6 @@ func Open(path string) (*Store, error) {
return s, nil
}
// dsn is the read-only URI the store opens with.
func dsn(path string) string { return DSN(path, true) }
// DSN builds a SQLite URI for path. The path must be escaped: SQLite reads
// '#' as a URI fragment delimiter, so a bare path containing one silently
// opens a different (usually nonexistent) file and reports a confusing schema
+5 -21
View File
@@ -269,20 +269,13 @@ func TestResolveUnknown(t *testing.T) {
}
}
// nearMissFixtureAt builds a tiny dictionary purpose-built for NearMiss: one
// nearMissFixture builds a tiny dictionary purpose-built for NearMiss: one
// word with a unique stripped form, one ambiguous pair that shares a stripped
// form with each other, and nothing else that could coincidentally collide.
func nearMissFixtureAt(tb testing.TB, dir string) string {
func nearMissFixture(tb testing.TB) *Store {
tb.Helper()
path := filepath.Join(dir, "nearmiss.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
tb.Fatal(err)
}
defer func() { _ = db.Close() }()
data := fixtureSchema + `
path := writeDB(tb, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','4'),('meaning_count','0');
INSERT INTO words VALUES
('bình yên','bình','yên',2),
@@ -292,17 +285,8 @@ INSERT INTO words VALUES
('ngữ nghĩa','ngữ','nghĩa',2),
('ngừ nghĩa','ngừ','nghĩa',2);
INSERT INTO syllables VALUES ('bình',1),('an',1),('ngữ',1),('ngừ',1);
`
if _, err := db.Exec(data); err != nil {
tb.Fatal(err)
}
return path
}
func nearMissFixture(tb testing.TB) *Store {
tb.Helper()
store, err := Open(nearMissFixtureAt(tb, tb.TempDir()))
`)
store, err := Open(path)
if err != nil {
tb.Fatalf("Open: %v", err)
}
+16 -30
View File
@@ -298,44 +298,30 @@ func (e *Engine) pointsFor(syllables int, link string, now time.Time) (int, []Po
{Kind: PointKindSpeed, Value: e.speedPoints(now)},
{Kind: PointKindRarity, Value: e.rarityPoints(link)},
}
parts = capParts(parts)
total := 0
for _, p := range parts {
total += p.Value
// Anything over maxPointsPerWord is trimmed from the end: rarity first,
// then speed, then syllables. Base and the chain term never need touching
// to make room — the chain term is itself capped at chainBonusWords words
// (10 base + 2*15 chain = 40 at most), well under the cap — so the loop
// always finds enough in the later terms and stops before reaching them.
for i, overflow := len(parts)-1, sumParts(parts)-maxPointsPerWord; i >= 0 && overflow > 0; i-- {
cut := min(parts[i].Value, overflow)
parts[i].Value -= cut
overflow -= cut
}
return total, parts
// A PointPart exists only for a term that actually contributed.
parts = slices.DeleteFunc(parts, func(p PointPart) bool { return p.Value <= 0 })
return sumParts(parts), parts
}
// capParts trims a word's score down to maxPointsPerWord when the terms
// pointsFor computed add up to more, and drops whatever term that leaves at
// zero — a PointPart exists only for a term that actually contributed.
//
// Trimmed from the end: rarity first, then speed, then syllables, then chain.
// Base and the chain term never need touching to make room — the chain term
// is itself capped at chainBonusWords words (10 base + 2*15 chain = 40 at
// most), well under the cap — so the loop always finds enough in the later
// terms and stops before reaching them.
func capParts(parts []PointPart) []PointPart {
// sumParts is the total a word's score breakdown adds up to.
func sumParts(parts []PointPart) int {
total := 0
for _, p := range parts {
total += p.Value
}
if overflow := total - maxPointsPerWord; overflow > 0 {
for i := len(parts) - 1; i >= 0 && overflow > 0; i-- {
cut := min(parts[i].Value, overflow)
parts[i].Value -= cut
overflow -= cut
}
}
kept := parts[:0]
for _, p := range parts {
if p.Value > 0 {
kept = append(kept, p)
}
}
return kept
return total
}
// speedPoints pays for the share of the turn the player left on the clock.
-8
View File
@@ -477,14 +477,6 @@ func TestSubmitScoringPartsAreTrimmedAtTheCap(t *testing.T) {
}
}
func sumParts(parts []PointPart) int {
sum := 0
for _, p := range parts {
sum += p.Value
}
return sum
}
func TestPointKindStrings(t *testing.T) {
seen := map[string]bool{}
for k := PointKind(0); k < NumPointKinds; k++ {
+2 -2
View File
@@ -198,7 +198,7 @@ type State struct {
Over bool
Winner PlayerID
EndReason EndReason
// Standings is the final table, best first. Meaningless while the game is
// in play, for the same reason Winner is.
// Standings is the final table, best first. Nil while the game is in
// play, when it would be meaningless for the same reason Winner is.
Standings []Standing
}