From 16fd37978a2bc46b51e1e65bea3e057f4a3a39cd Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 01:54:58 +0700 Subject: [PATCH] feat(game): a score a player can see the cause of, and a dead end they can claim pointsFor now returns the named terms behind a word's total alongside the total itself, trimmed from the trailing term when the maxPointsPerWord cap bites, so PlayedWord.parts always sums exactly to points. ClaimDeadEnd lets the player to act say a syllable has no answer instead of waiting out the clock; the room checks HasLegalMove and settles it exactly as a timeout would, or refuses with the clock untouched. dictionary.Store gains a diacritic-stripped index built at Open, so MoveRejected.suggestion can offer the one real word a typo differs from by tone marks alone. ReportWord is validated and rate-limited on the session, logged with the room's own context, and answered with WordReported. --- server/internal/dictionary/store.go | 72 ++++++++ server/internal/dictionary/store_test.go | 89 +++++++++ server/internal/game/engine.go | 62 ++++++- server/internal/game/engine_test.go | 103 +++++++++++ server/internal/game/state.go | 50 ++++- server/internal/wsapi/codec.go | 14 +- server/internal/wsapi/convert.go | 35 ++++ server/internal/wsapi/convert_test.go | 79 ++++++++ server/internal/wsapi/metrics.go | 9 + server/internal/wsapi/room.go | 102 +++++++++- server/internal/wsapi/session.go | 77 ++++++++ server/internal/wsapi/wsapi_test.go | 226 +++++++++++++++++++++++ 12 files changed, 905 insertions(+), 13 deletions(-) diff --git a/server/internal/dictionary/store.go b/server/internal/dictionary/store.go index f5e6220..32ac0cc 100644 --- a/server/internal/dictionary/store.go +++ b/server/internal/dictionary/store.go @@ -24,9 +24,12 @@ import ( "slices" "sort" "strconv" + "strings" + "unicode" "math/rand/v2" + "golang.org/x/text/unicode/norm" _ "modernc.org/sqlite" ) @@ -73,6 +76,11 @@ type Store struct { meanings map[string][]Sense meaningCount int + // stripped maps a word's diacritic-stripped form to every canonical word + // that reduces to it, for NearMiss. Built once at Open from the same words + // map, never mutated afterwards. + stripped map[string][]string + license string } @@ -131,6 +139,7 @@ func Open(path string) (*Store, error) { if err := s.validate(declaredWords, declaredMeanings); err != nil { return nil, err } + s.buildStrippedIndex() sort.SliceStable(s.openers, func(i, j int) bool { return s.openers[i].lastOutDegree > s.openers[j].lastOutDegree @@ -360,6 +369,69 @@ func (s *Store) Resolve(word string) (string, bool) { return "", false } +// buildStrippedIndex populates the near-miss lookup from the words already +// loaded. Run once at Open, after loadWords: every canonical word maps to the +// bucket of every other canonical word that shares its diacritic-stripped +// form. +func (s *Store) buildStrippedIndex() { + s.stripped = make(map[string][]string, len(s.words)) + for word := range s.words { + key := stripDiacritics(word) + s.stripped[key] = append(s.stripped[key], word) + } +} + +// stripDiacritics reduces a word to the letters a Vietnamese input method +// actually types, so two spellings that differ only in tone or vowel marks +// compare equal. NFD decomposition drops every combining mark (Mn), and đ/Đ +// are folded onto d/D by hand because they are single codepoints with no +// canonical decomposition — a separate letter in the alphabet, but the same +// key on a Telex or VNI keyboard as d. +// +// This is purely a typing-distance measure, never a vocabulary one: it is +// used only to find a real dictionary word near a mistyped one, not to decide +// whether a word is playable. +func stripDiacritics(word string) string { + var b strings.Builder + b.Grow(len(word)) + for _, r := range norm.NFD.String(word) { + switch { + case unicode.Is(unicode.Mn, r): + continue + case r == 'đ': + r = 'd' + case r == 'Đ': + r = 'D' + } + b.WriteRune(r) + } + return strings.ToLower(b.String()) +} + +// NearMiss finds the one real word a normalized submission is a diacritic +// typo away from. +// +// It reports a match only when the stripped form has exactly one canonical +// word behind it, other than normalized itself: an ambiguous stem ("ngu" for +// both "ngữ" and "ngủ") corrects to nothing, since guessing which one the +// player meant would be handing out a word they may not know, and normalized +// is excluded so a word already in the dictionary is never "corrected" to +// itself. Callers are expected to call this only after Resolve has already +// failed. +func (s *Store) NearMiss(normalized string) (string, bool) { + match, count := "", 0 + for _, w := range s.stripped[stripDiacritics(normalized)] { + if w == normalized { + continue + } + match, count = w, count+1 + } + if count != 1 { + return "", false + } + return match, true +} + // FirstSyllable returns the syllable a canonical word begins with: the syllable // it must link from. Reports false for an alias, which is not a playable entry. func (s *Store) FirstSyllable(word string) (string, bool) { diff --git a/server/internal/dictionary/store_test.go b/server/internal/dictionary/store_test.go index 0a695b5..fc91346 100644 --- a/server/internal/dictionary/store_test.go +++ b/server/internal/dictionary/store_test.go @@ -269,6 +269,95 @@ func TestResolveUnknown(t *testing.T) { } } +// nearMissFixtureAt 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 { + 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 + ` +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), + ('an nhàn','an','nhàn',2), + -- "ngữ" and "ngừ" both strip to "ngu": an ambiguous stem with two real + -- candidates behind it. + ('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())) + if err != nil { + tb.Fatalf("Open: %v", err) + } + return store +} + +// stripDiacritics is the typing-distance normalization NearMiss is built on: +// NFD plus dropping combining marks handles tones and vowel horns, but đ has +// no canonical decomposition, so it needs its own fold onto d. +func TestStripDiacriticsFoldsDBreve(t *testing.T) { + if got := stripDiacritics("đông đảo"); got != "dong dao" { + t.Errorf("stripDiacritics(%q) = %q, want %q", "đông đảo", got, "dong dao") + } +} + +func TestNearMissFindsAUniqueDiacriticTypo(t *testing.T) { + s := nearMissFixture(t) + + got, ok := s.NearMiss("binh yen") + if !ok || got != "bình yên" { + t.Errorf(`NearMiss("binh yen") = (%q, %v), want ("bình yên", true)`, got, ok) + } +} + +// Two real words differing only by diacritics from each other must not +// resolve to either: correcting to the wrong one would hand out a word the +// player did not know, which is the one thing NearMiss must never do. +func TestNearMissRefusesAnAmbiguousStem(t *testing.T) { + s := nearMissFixture(t) + + if got, ok := s.NearMiss("ngu nghia"); ok { + t.Errorf(`NearMiss("ngu nghia") = (%q, true), want no match for an ambiguous stem`, got) + } +} + +// A word already spelled correctly must never be offered as its own +// suggestion — Resolve would already have accepted it, so reaching NearMiss +// with it at all means something upstream skipped that check, and this is the +// last line of defence against showing "ý bạn là bình yên?" under "bình yên". +func TestNearMissNeverSuggestsTheWordItself(t *testing.T) { + s := nearMissFixture(t) + + if got, ok := s.NearMiss("bình yên"); ok { + t.Errorf(`NearMiss("bình yên") = (%q, true), want no suggestion for an exact dictionary word`, got) + } +} + +func TestNearMissRefusesAnUnrelatedWord(t *testing.T) { + s := nearMissFixture(t) + + if got, ok := s.NearMiss("hoàn toàn"); ok { + t.Errorf(`NearMiss("hoàn toàn") = (%q, true), want no match`, got) + } +} + func TestFirstAndLastSyllable(t *testing.T) { s := fixture(t) diff --git a/server/internal/game/engine.go b/server/internal/game/engine.go index 869314e..36b11b0 100644 --- a/server/internal/game/engine.go +++ b/server/internal/game/engine.go @@ -231,6 +231,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas return Move{}, ReasonNotInDictionary } + points, parts := e.pointsFor(len(syllables), first, now) move := Move{ Player: p, Word: canonical, @@ -238,7 +239,8 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas First: first, Last: last, Syllables: len(syllables), - Points: e.pointsFor(len(syllables), first, now), + Points: points, + Parts: parts, At: now, } @@ -262,13 +264,57 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas // already down, opening word included, which is what ChainLength reports; link // is the syllable the word answers, and now is when it was played, so both the // speed and the rarity term have to be read before the move is applied. -func (e *Engine) pointsFor(syllables int, link string, now time.Time) int { - points := basePoints + - chainBonus*min(e.ChainLength(), chainBonusWords) + - syllableBonus*(syllables-vietnamese.MinSyllables) + - e.speedPoints(now) + - e.rarityPoints(link) - return min(points, maxPointsPerWord) +// +// Returns the total alongside the named terms it is made of, capped together: +// a client with no wordlist of its own cannot re-derive why a word scored what +// it did, so the breakdown travels with the total rather than being dropped +// once it agrees with it. +func (e *Engine) pointsFor(syllables int, link string, now time.Time) (int, []PointPart) { + parts := []PointPart{ + {Kind: PointKindBase, Value: basePoints}, + {Kind: PointKindChain, Value: chainBonus * min(e.ChainLength(), chainBonusWords)}, + {Kind: PointKindSyllables, Value: syllableBonus * (syllables - vietnamese.MinSyllables)}, + {Kind: PointKindSpeed, Value: e.speedPoints(now)}, + {Kind: PointKindRarity, Value: e.rarityPoints(link)}, + } + parts = capParts(parts) + + total := 0 + for _, p := range parts { + total += p.Value + } + return total, 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 { + 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 } // speedPoints pays for the share of the turn the player left on the clock. diff --git a/server/internal/game/engine_test.go b/server/internal/game/engine_test.go index 726013b..74b98c0 100644 --- a/server/internal/game/engine_test.go +++ b/server/internal/game/engine_test.go @@ -399,6 +399,109 @@ func TestSubmitScoringStopsPayingForChainLength(t *testing.T) { } } +// Parts must name every term the total is made of, with nothing left over and +// nothing zero: a term that did not contribute has nothing to show a player. +func TestSubmitScoringPartsMatchTheTotal(t *testing.T) { + d := newDict("ngôn ngữ", "ngữ pháp", "pháp vô tuyến điện") + e := newGame(t, d, "ngôn ngữ") + + two, _ := e.Submit(alice, "ngữ pháp", t0) + if want := []PointPart{ + {Kind: PointKindBase, Value: 10}, + {Kind: PointKindChain, Value: 2}, + {Kind: PointKindSpeed, Value: 10}, + {Kind: PointKindRarity, Value: 15}, + }; !slices.Equal(two.Parts, want) { + t.Errorf("two-syllable word parts = %+v, want %+v", two.Parts, want) + } + if sum := sumParts(two.Parts); sum != two.Points { + t.Errorf("parts sum to %d, want Points %d", sum, two.Points) + } + + // Four syllables is two past the minimum, so this word's syllable term is + // the one difference from the case above and must appear rather than be + // silently folded into the total. + four, r := e.Submit(bob, "pháp vô tuyến điện", t0) + if r != ReasonNone { + t.Fatalf("four-syllable word rejected: %s", r) + } + if want := []PointPart{ + {Kind: PointKindBase, Value: 10}, + {Kind: PointKindChain, Value: 4}, + {Kind: PointKindSyllables, Value: 10}, + {Kind: PointKindSpeed, Value: 10}, + {Kind: PointKindRarity, Value: 15}, + }; !slices.Equal(four.Parts, want) { + t.Errorf("four-syllable word parts = %+v, want %+v", four.Parts, want) + } + if sum := sumParts(four.Parts); sum != four.Points { + t.Errorf("parts sum to %d, want Points %d", sum, four.Points) + } +} + +// When the naive sum overruns maxPointsPerWord, the parts must still sum to +// the capped total exactly — trimmed from the end (rarity, then speed, then +// syllables) rather than the total being clamped while the breakdown still +// claims the uncapped numbers. +func TestSubmitScoringPartsAreTrimmedAtTheCap(t *testing.T) { + // A 20-syllable word answering "ngữ", the only continuation the dictionary + // offers there, so rarity pays its maximum: base 10 + chain 2 + syllables + // 5*18=90 + speed 10 + rarity 15 = 127 raw, 27 over the cap. + tokens := []string{"ngữ"} + for i := range 18 { + tokens = append(tokens, fmt.Sprintf("s%d", i)) + } + tokens = append(tokens, "z") + long := strings.Join(tokens, " ") + + e := newGame(t, newDict("ngôn ngữ", long), "ngôn ngữ") + move, r := e.Submit(alice, long, t0) + if r != ReasonNone { + t.Fatalf("long word rejected: %s", r) + } + if move.Points != maxPointsPerWord { + t.Fatalf("Points = %d, want the cap %d", move.Points, maxPointsPerWord) + } + if sum := sumParts(move.Parts); sum != maxPointsPerWord { + t.Errorf("parts sum to %d, want the cap %d", sum, maxPointsPerWord) + } + // Rarity and speed are trimmed away entirely (15 + 10 = 25 of the 27 + // overflow) before syllables gives up the remaining 2, so base and chain + // are untouched and syllables lands at 88 rather than its raw 90. + if want := []PointPart{ + {Kind: PointKindBase, Value: 10}, + {Kind: PointKindChain, Value: 2}, + {Kind: PointKindSyllables, Value: 88}, + }; !slices.Equal(move.Parts, want) { + t.Errorf("trimmed parts = %+v, want %+v", move.Parts, want) + } +} + +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++ { + s := k.String() + if s == "" || s == "unknown" { + t.Errorf("PointKind(%d).String() = %q", k, s) + } + if seen[s] { + t.Errorf("duplicate description %q", s) + } + seen[s] = true + } + if got := PointKind(99).String(); got != "unknown" { + t.Errorf("unknown PointKind string = %q", got) + } +} + func TestLegalMovesAndHasLegalMove(t *testing.T) { e := newGame(t, standardDict(), "ngôn ngữ") diff --git a/server/internal/game/state.go b/server/internal/game/state.go index 891fd84..9c7f637 100644 --- a/server/internal/game/state.go +++ b/server/internal/game/state.go @@ -86,7 +86,55 @@ type Move struct { Last string Syllables int Points int - At time.Time + // Parts is how Points was arrived at: one entry per non-zero term, summing + // exactly to Points even when the maxPointsPerWord cap trimmed them. See + // pointsFor. + Parts []PointPart + At time.Time +} + +// PointKind names one term of a word's score. PointKindNone is the zero value +// and never appears in a PointPart — it exists only so the wire mapping in +// wsapi/convert.go has somewhere unreachable to send an unmapped value, the +// same shape RejectReason and EndReason already use. +type PointKind int + +const ( + PointKindNone PointKind = iota + PointKindBase + PointKindChain + PointKindSyllables + PointKindSpeed + PointKindRarity + + // NumPointKinds is one past the last defined kind. See NumRejectReasons + // for why the count is a constant rather than a walk. + NumPointKinds +) + +func (k PointKind) String() string { + switch k { + case PointKindNone: + return "none" + case PointKindBase: + return "base" + case PointKindChain: + return "chain" + case PointKindSyllables: + return "syllables" + case PointKindSpeed: + return "speed" + case PointKindRarity: + return "rarity" + } + return "unknown" +} + +// PointPart is one named term of a word's score: how many points it +// contributed, and which of pointsFor's terms it was. +type PointPart struct { + Kind PointKind + Value int } // EndReason says how a finished game ended. diff --git a/server/internal/wsapi/codec.go b/server/internal/wsapi/codec.go index 282d5e7..beec6bd 100644 --- a/server/internal/wsapi/codec.go +++ b/server/internal/wsapi/codec.go @@ -59,9 +59,19 @@ func welcomeMsg(sessionID, resumeToken, nickname string) *noituv1.ServerMessage }}} } -func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *noituv1.ServerMessage { +// suggestion is REJECT_REASON_NOT_IN_DICTIONARY only: the one real word the +// submission differs from by diacritics alone, or empty when none applies. +func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32, suggestion string) *noituv1.ServerMessage { return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_MoveRejected{ - MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq}, + MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq, Suggestion: suggestion}, + }} +} + +// wordReportedMsg acknowledges a ReportWord, echoing the word as the server +// recorded it so the player sees that it was heard. +func wordReportedMsg(word string) *noituv1.ServerMessage { + return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_WordReported{ + WordReported: &noituv1.WordReported{Word: word}, }} } diff --git a/server/internal/wsapi/convert.go b/server/internal/wsapi/convert.go index b2dfa5a..65396e9 100644 --- a/server/internal/wsapi/convert.go +++ b/server/internal/wsapi/convert.go @@ -76,6 +76,40 @@ func EndReason(r game.EndReason) noituv1.GameEndReason { return noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED } +// PointKind maps an engine score term onto the wire enum. +// +// game.PointKindNone has no wire counterpart: a PointPart exists only for a +// term that actually contributed, so no move ever carries one and this arm is +// reached only by the exhaustiveness test. +func PointKind(k game.PointKind) noituv1.PointKind { + switch k { + case game.PointKindNone: + return noituv1.PointKind_POINT_KIND_UNSPECIFIED + case game.PointKindBase: + return noituv1.PointKind_POINT_KIND_BASE + case game.PointKindChain: + return noituv1.PointKind_POINT_KIND_CHAIN + case game.PointKindSyllables: + return noituv1.PointKind_POINT_KIND_SYLLABLES + case game.PointKindSpeed: + return noituv1.PointKind_POINT_KIND_SPEED + case game.PointKindRarity: + return noituv1.PointKind_POINT_KIND_RARITY + } + log.Printf("wsapi: no wire mapping for game.PointKind(%d) %q", int(k), k) + return noituv1.PointKind_POINT_KIND_UNSPECIFIED +} + +// PointParts renders a move's score breakdown for the wire, in the order the +// engine produced them. +func PointParts(parts []game.PointPart) []*noituv1.PointPart { + out := make([]*noituv1.PointPart, 0, len(parts)) + for _, p := range parts { + out = append(out, &noituv1.PointPart{Kind: PointKind(p.Kind), Value: uint32(p.Value)}) + } + return out +} + // Difficulty maps a wire difficulty onto a bot strategy selector. // // This one runs client to server, so an unrecognized value is untrusted input @@ -113,6 +147,7 @@ func PlayedWord(m game.Move, byMe bool, meanings []dictionary.Sense) *noituv1.Pl Points: uint32(m.Points), Syllables: uint32(m.Syllables), Meanings: Senses(meanings), + Parts: PointParts(m.Parts), } } diff --git a/server/internal/wsapi/convert_test.go b/server/internal/wsapi/convert_test.go index a3c4e7d..306cfa7 100644 --- a/server/internal/wsapi/convert_test.go +++ b/server/internal/wsapi/convert_test.go @@ -127,6 +127,85 @@ func TestEndReasonMappingIsExhaustive(t *testing.T) { } } +func enginePointKinds() []game.PointKind { + out := make([]game.PointKind, 0, game.NumPointKinds) + for i := game.PointKind(0); i < game.NumPointKinds; i++ { + out = append(out, i) + } + return out +} + +// TestPointKindMappingIsExhaustive mirrors the reject-reason walk. Only +// PointKindNone is allowed to land on UNSPECIFIED: a PointPart exists only for +// a term that contributed, so no real move ever carries that value. +func TestPointKindMappingIsExhaustive(t *testing.T) { + kinds := enginePointKinds() + if len(kinds) < 2 { + t.Fatalf("enumerated %d engine point kinds, expected the full set", len(kinds)) + } + + seen := make(map[noituv1.PointKind]game.PointKind, len(kinds)) + for _, k := range kinds { + got := PointKind(k) + if k == game.PointKindNone { + if got != noituv1.PointKind_POINT_KIND_UNSPECIFIED { + t.Errorf("PointKindNone should map to UNSPECIFIED, got %v", got) + } + continue + } + if got == noituv1.PointKind_POINT_KIND_UNSPECIFIED { + t.Errorf("game.PointKind(%d) %q has no wire mapping", int(k), k) + continue + } + if prev, dup := seen[got]; dup { + t.Errorf("%v is produced by both %q and %q", got, prev, k) + } + seen[got] = k + } + + for _, v := range enumValues(noituv1.PointKind_POINT_KIND_UNSPECIFIED.Descriptor()) { + w := noituv1.PointKind(v) + if w == noituv1.PointKind_POINT_KIND_UNSPECIFIED { + continue + } + if _, ok := seen[w]; !ok { + t.Errorf("wire value %v is unreachable: no engine point kind maps to it", w) + } + } +} + +// TestPlayedWordCarriesItsScoreBreakdown guards the invariant the client +// relies on: the parts it draws beside the total must sum to it exactly. +func TestPlayedWordCarriesItsScoreBreakdown(t *testing.T) { + m := game.Move{ + Player: "p1", + Word: "bình yên", + Points: 25, + Parts: []game.PointPart{ + {Kind: game.PointKindBase, Value: 10}, + {Kind: game.PointKindSpeed, Value: 15}, + }, + } + + rendered := PlayedWord(m, true, nil) + if len(rendered.GetParts()) != 2 { + t.Fatalf("rendered %d parts, want 2", len(rendered.GetParts())) + } + sum := uint32(0) + for _, p := range rendered.GetParts() { + sum += p.GetValue() + } + if sum != rendered.GetPoints() { + t.Errorf("parts sum to %d, want points %d", sum, rendered.GetPoints()) + } + if rendered.GetParts()[0].GetKind() != noituv1.PointKind_POINT_KIND_BASE { + t.Errorf("first part kind = %v, want BASE", rendered.GetParts()[0].GetKind()) + } + if rendered.GetParts()[1].GetKind() != noituv1.PointKind_POINT_KIND_SPEED { + t.Errorf("second part kind = %v, want SPEED", rendered.GetParts()[1].GetKind()) + } +} + // TestDifficultyMapping checks each level and, more importantly, that an // unknown wire difficulty is refused rather than defaulted. Difficulty arrives // from the client, so silently treating garbage as Easy would let a malformed diff --git a/server/internal/wsapi/metrics.go b/server/internal/wsapi/metrics.go index 54065a0..d284b8f 100644 --- a/server/internal/wsapi/metrics.go +++ b/server/internal/wsapi/metrics.go @@ -42,6 +42,13 @@ type metricSet struct { chatLines *expvar.Int + // deadEndClaims is keyed "true" or "false": whether the claimed position + // actually had no legal move. + deadEndClaims *expvar.Map + // wordsReported counts accepted ReportWord messages — never the refused + // ones, which cost the sender an error and nothing else. + wordsReported *expvar.Int + // joinsRefused is keyed by the same UI error code the client is sent — // "too_many_attempts", "room_not_found" or "room_full" — so a reader // checking this counter against the frontend copy is checking against the @@ -74,6 +81,8 @@ func newMetricSet() *metricSet { wordsRejected: expvar.NewMap("noitu_words_rejected"), eliminations: expvar.NewMap("noitu_eliminations"), chatLines: expvar.NewInt("noitu_chat_lines"), + deadEndClaims: expvar.NewMap("noitu_dead_end_claims"), + wordsReported: expvar.NewInt("noitu_words_reported"), joinsRefused: expvar.NewMap("noitu_joins_refused"), botMoves: expvar.NewMap("noitu_bot_moves"), resumesAttempted: expvar.NewInt("noitu_resumes_attempted"), diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go index 1e46573..2c929dc 100644 --- a/server/internal/wsapi/room.go +++ b/server/internal/wsapi/room.go @@ -156,6 +156,22 @@ type resignInput struct { player game.PlayerID } +// claimDeadEndInput is the player to act saying the syllable has no answer +// left. Carries the connection, not just the claimed seat, for the same +// reason resignInput does. +type claimDeadEndInput struct { + sess *session + player game.PlayerID +} + +// reportWordInput is a word the session has already validated as reportable — +// long enough, and within its own per-session cap — waiting only on the room +// for the context a report is logged with: the syllable in play, if any. +type reportWordInput struct { + sess *session + word string +} + type disconnectInput struct { player game.PlayerID // sess identifies which connection dropped. A player who already @@ -303,6 +319,9 @@ type Dictionary interface { RandomOpeningWord(minOutDegree int) (string, error) // Meanings returns a canonical word's senses in order, nil for none. Meanings(word string) []dictionary.Sense + // NearMiss finds the one real word a normalized submission differs from by + // diacritics alone, reported only when exactly one such word exists. + NearMiss(normalized string) (string, bool) } func newRoom(h *hub, code string, turnLimit, graceFor, idleFor time.Duration, mode string) *room { @@ -464,6 +483,11 @@ func (r *room) run() { idleActivity = false case resignInput: r.handleResign(m) + case claimDeadEndInput: + r.handleClaimDeadEnd(m) + case reportWordInput: + r.handleReportWord(m) + idleActivity = false case disconnectInput: // A dropped connection is not a player leaving: the seat is // held for the reconnect window whether a game is running or @@ -573,6 +597,43 @@ func (r *room) handleResign(m resignInput) { } } +// handleClaimDeadEnd is the player to act saying the syllable in play has no +// answer left, checked rather than trusted. +// +// A true claim takes them out at once with EndNoLegalMove — exactly what the +// clock would eventually rule, so the game's own outcome is unchanged and +// only the wait is gone. A false claim changes nothing at all: the clock +// keeps running and the claimant is simply told a word exists, which is hint +// enough to be the whole cost of asking wrongly. +func (r *room) handleClaimDeadEnd(m claimDeadEndInput) { + if !r.occupies(m.sess, m.player) { + m.sess.send(errorMsg("not_your_seat")) + return + } + if r.engine == nil { + m.sess.send(errorMsg("game_not_started")) + return + } + if r.engine.Over() { + return + } + if r.engine.Turn() != m.player { + m.sess.send(errorMsg("not_your_turn")) + return + } + if r.engine.HasLegalMove() { + metrics.deadEndClaims.Add("false", 1) + m.sess.send(errorMsg("not_a_dead_end")) + return + } + + metrics.deadEndClaims.Add("true", 1) + before := r.mark() + if r.engine.NoMove(time.Now()) { + r.applyEliminations(before) + } +} + // handleStartBot seats a bot opposite the player and begins immediately. func (r *room) handleStartBot(m startBotInput) { strategy, err := bot.New(m.difficulty, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))) @@ -860,7 +921,7 @@ func (r *room) handleSubmit(m submitInput) { metrics.wordsSubmitted.Add(1) if m.turnSeq != r.turnSeq { - r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq)) + r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq, "")) r.recordRejection(game.ReasonNotYourTurn, m.word) return } @@ -874,7 +935,7 @@ func (r *room) handleSubmit(m submitInput) { before := r.mark() move, reason := r.engine.Submit(m.player, word, time.Now()) if reason != game.ReasonNone { - r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq)) + r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq, r.nearMissFor(reason, word))) r.recordRejection(reason, word) // A rejection for an expired turn also took this player out of the // game, and everybody has to be told which. @@ -890,6 +951,25 @@ func (r *room) handleSubmit(m submitInput) { r.maybeScheduleBot() } +// nearMissFor finds a diacritic-typo suggestion for a word the dictionary +// refused. Only for REJECT_REASON_NOT_IN_DICTIONARY: every other rejection +// means the word IS in the dictionary and was refused for some other reason, +// where a spelling suggestion would be misleading rather than helpful. +func (r *room) nearMissFor(reason game.RejectReason, raw string) string { + if reason != game.ReasonNotInDictionary { + return "" + } + normalized, _, err := vietnamese.Normalize(raw) + if err != nil { + return "" + } + suggestion, ok := r.dict.NearMiss(normalized) + if !ok { + return "" + } + return suggestion +} + // recordRejection counts one rejected submission and logs it at Info. // // This is the corpus feedback loop the improvement report calls the input to @@ -922,6 +1002,24 @@ func (r *room) recordRejection(reason game.RejectReason, raw string) { ) } +// handleReportWord logs one report with this room's context. +// +// The session has already checked the word is long enough and within its own +// per-session cap before routing it here — this is only about what to log, +// and the syllable in play, this room's mode and its code are all room +// goroutine state that only the room may read. Never the reporting player's +// seat or name: recordRejection keeps the same information out of the corpus +// feedback loop for the same reason. +func (r *room) handleReportWord(m reportWordInput) { + link := "" + if r.engine != nil { + link = r.engine.Current() + } + metrics.wordsReported.Add(1) + slog.Info("word_reported", "word", m.word, "link", link, "mode", r.mode, "room", r.code) + m.sess.send(wordReportedMsg(m.word)) +} + // handleBotMove applies what the worker chose. func (r *room) handleBotMove(m botMoveInput) { if r.engine == nil || r.engine.Over() { diff --git a/server/internal/wsapi/session.go b/server/internal/wsapi/session.go index 38502c6..e99fbac 100644 --- a/server/internal/wsapi/session.go +++ b/server/internal/wsapi/session.go @@ -12,6 +12,7 @@ import ( "github.com/coder/websocket" noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" "github.com/tiennm99dev/noitu/server/internal/game" + "github.com/tiennm99dev/noitu/server/internal/vietnamese" ) const ( @@ -49,6 +50,13 @@ const ( joinsPerSecond = 1 joinBurst = 5 + // maxWordReportsPerSession bounds how many distinct words one session may + // file with ReportWord. A duplicate report of a word already filed does + // not count against it — it costs nothing new to acknowledge again — but + // an unbounded stream of distinct ones would turn the corpus feedback loop + // into a log-filling vector. + maxWordReportsPerSession = 20 + // Room creation is far more expensive than a join: each one is a // goroutine, an engine and a registry entry held until the game ends. roomsPerSecond = 0.2 @@ -121,6 +129,13 @@ type session struct { chatLimiter *bucket frameLimiter *bucket + // reportedWords is every distinct word this session has filed with + // ReportWord, capped at maxWordReportsPerSession. Touched only from + // dispatch, which is the sole reader of this connection's frames, so it + // needs no lock of its own — unlike nick/room/playerID above, nothing else + // ever reads or writes it. + reportedWords map[string]struct{} + // greeted marks the handshake done. It is a one-shot transition: a second // Hello would re-register the session and rewrite its nickname mid-game. greeted bool @@ -147,6 +162,7 @@ func newSession(ctx context.Context, conn *websocket.Conn, h *hub, remoteIP stri roomLimiter: newBucket(roomsPerSecond, roomBurst, time.Now()), chatLimiter: newBucket(chatsPerSecond, chatBurst, time.Now()), frameLimiter: newBucket(framesPerSecond, frameBurst, time.Now()), + reportedWords: make(map[string]struct{}), } } @@ -476,6 +492,24 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error { s.send(errorMsg("not_in_a_game")) } + case *noituv1.ClientMessage_ClaimDeadEnd: + // Rate-limited on the same budget as a submission: a claim is the + // alternative to playing a word, not a second action alongside it. + if !s.submitLimiter.allow(time.Now()) { + s.send(errorMsg("too_fast")) + return nil + } + if r, id := s.currentRoom(); r != nil { + if !r.send(claimDeadEndInput{sess: s, player: id}) { + s.send(errorMsg("busy")) + } + } else { + s.send(errorMsg("not_in_a_game")) + } + + case *noituv1.ClientMessage_ReportWord: + s.handleReportWord(p.ReportWord) + case *noituv1.ClientMessage_SetReady: s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()}) @@ -623,6 +657,49 @@ func (s *session) handleSubmit(w *noituv1.SubmitWord) { } } +// handleReportWord validates a word report and, once it is worth logging, +// hands it to the current room for the context only the room goroutine may +// read — the syllable in play, and the room's own mode and code. +// +// Validation happens here rather than in the room because it is entirely +// about this connection: its own rate budget, and its own running count of +// distinct words already filed. Neither needs the room at all, and a session +// playing no game — smoke-testing the wire directly, per the README — can +// still file a report, acknowledged with mode "none" and no link. +func (s *session) handleReportWord(m *noituv1.ReportWord) { + if !s.chatLimiter.allow(time.Now()) { + s.send(errorMsg("too_fast")) + return + } + + word, syllables, err := vietnamese.Normalize(sanitizeText(m.GetWord(), maxWordRunes, maxNicknameMarks)) + if err != nil || !vietnamese.HasEnoughSyllables(syllables) { + s.send(errorMsg("word_report_refused")) + return + } + + if _, already := s.reportedWords[word]; !already { + if len(s.reportedWords) >= maxWordReportsPerSession { + s.send(errorMsg("word_report_limit")) + return + } + s.reportedWords[word] = struct{}{} + } + + // currentRoom's player id is not needed here: the log line is about the + // word and the room's context, never about who filed it. + if r, _ := s.currentRoom(); r != nil { + if !r.send(reportWordInput{sess: s, word: word}) { + s.send(errorMsg("busy")) + } + return + } + + metrics.wordsReported.Add(1) + slog.Info("word_reported", "word", word, "link", "", "mode", "none", "room", "") + s.send(wordReportedMsg(word)) +} + // leaveRoom tells the room this connection is gone, so the seat enters its // grace window rather than the game simply stalling. func (s *session) leaveRoom() { diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go index dd61b77..868456d 100644 --- a/server/internal/wsapi/wsapi_test.go +++ b/server/internal/wsapi/wsapi_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" "time" + "unicode" "unicode/utf8" "github.com/coder/websocket" @@ -19,6 +20,7 @@ import ( "github.com/tiennm99dev/noitu/server/internal/bot" "github.com/tiennm99dev/noitu/server/internal/dictionary" "github.com/tiennm99dev/noitu/server/internal/game" + "golang.org/x/text/unicode/norm" "google.golang.org/protobuf/proto" ) @@ -90,6 +92,41 @@ func (d *testDict) OutDegree(syllable string) (int, error) { func (d *testDict) RandomOpeningWord(int) (string, error) { return d.opening, nil } +// NearMiss strips tone marks and the đ/d distinction by hand — the same +// typing-distance fold dictionary.stripDiacritics performs — since this test +// graph is built by hand rather than through the real Store. +func (d *testDict) NearMiss(normalized string) (string, bool) { + strip := func(s string) string { + s = norm.NFD.String(s) + var b strings.Builder + for _, r := range s { + switch { + case unicode.Is(unicode.Mn, r): + continue + case r == 'đ': + r = 'd' + } + b.WriteRune(r) + } + return b.String() + } + + key := strip(normalized) + match, count := "", 0 + for w := range d.words { + if w == normalized { + continue + } + if strip(w) == key { + match, count = w, count+1 + } + } + if count != 1 { + return "", false + } + return match, true +} + func maps(m map[string][2]string) iter.Seq[string] { return func(yield func(string) bool) { for k := range m { @@ -277,6 +314,8 @@ func payloadCase(m *noituv1.ServerMessage) string { return "chat_message" case *noituv1.ServerMessage_ChatHistory: return "chat_history" + case *noituv1.ServerMessage_WordReported: + return "word_reported" } // Named rather than empty: a missing arm here makes every await for that // message time out with nothing to say about why. @@ -1156,6 +1195,20 @@ func (c *testClient) resign() { c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}}) } +func (c *testClient) claimDeadEnd() { + c.t.Helper() + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_ClaimDeadEnd{ + ClaimDeadEnd: &noituv1.ClaimDeadEnd{}, + }}) +} + +func (c *testClient) reportWord(word string) { + c.t.Helper() + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_ReportWord{ + ReportWord: &noituv1.ReportWord{Word: word}, + }}) +} + // resignFrom has one player of a two-player game give up, whichever of them // drew the first turn. // @@ -2076,6 +2129,15 @@ func offlineSession(t *testing.T, capacity int) *session { cancel: cancel, out: make(chan []byte, capacity), flushed: make(chan struct{}), + // Generous rather than zero: most callers exercise a handler directly + // and never touch a limiter, but one that does — handleReportWord — must + // not panic on a nil bucket, and a test about something else has no + // reason to also be a test of rate limiting. + submitLimiter: newBucket(1000, 1000, time.Now()), + roomLimiter: newBucket(1000, 1000, time.Now()), + chatLimiter: newBucket(1000, 1000, time.Now()), + frameLimiter: newBucket(1000, 1000, time.Now()), + reportedWords: make(map[string]struct{}), } } @@ -2292,3 +2354,167 @@ func TestLobbyActionsAreRateLimited(t *testing.T) { } t.Error("a burst of lobby actions was never refused") } + +// --- dead-end claims --------------------------------------------------- + +// TestClaimDeadEndEliminatesImmediately walks a player into a real dead end +// and has them claim it rather than wait out the clock. The outcome must be +// exactly what a timeout would have produced: NO_LEGAL_MOVE, no answerable +// suggestions. +func TestClaimDeadEndEliminatesImmediately(t *testing.T) { + // "b" starts nothing after "b c" is played, so whoever inherits "c" has no + // move at all. + _, url := newTestServer(t, newTestDict("a b", "b c"), Config{}) + host, guest, start := pvpRoom(t, url) + lead, stuck := host, guest + if !start.GetMyTurn() { + lead, stuck = guest, host + } + + lead.submit("b c", start.GetTurnSeq()) + lead.await("turn_update") + turn := stuck.await("turn_update").GetTurnUpdate() + if turn.GetCurrentSyllable() != "c" { + t.Fatalf("current syllable = %q, want %q", turn.GetCurrentSyllable(), "c") + } + if !turn.GetMyTurn() { + t.Fatal("the player left with the dead end should be on turn") + } + + stuck.claimDeadEnd() + + out := stuck.await("player_eliminated").GetPlayerEliminated() + if !out.GetIsMe() { + t.Error("the claimant should be the one eliminated") + } + if out.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE { + t.Errorf("reason = %v, want NO_LEGAL_MOVE", out.GetReason()) + } + if len(out.GetSuggestions()) != 0 { + t.Errorf("suggestions = %v, want none for a genuine dead end", out.GetSuggestions()) + } + + stuck.await("game_over") + lead.await("game_over") +} + +// TestClaimDeadEndRefusedWhenAMoveExists checks the false claim costs nothing +// but the answer: the clock is untouched, proven by the original turn_seq +// still being accepted afterwards. +func TestClaimDeadEndRefusedWhenAMoveExists(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, start := pvpRoom(t, url) + lead := host + if !start.GetMyTurn() { + lead = guest + } + + lead.claimDeadEnd() + if code := lead.await("error").GetError().GetCode(); code != "not_a_dead_end" { + t.Errorf("code = %q, want not_a_dead_end", code) + } + + // The turn_seq the claim was answered on is still the current one: a + // submission stamped with it is still accepted rather than refused as + // stale. + lead.submit("b c", start.GetTurnSeq()) + lead.await("turn_update") +} + +// TestClaimDeadEndOutOfTurnRefused: only the player to act may spend a claim, +// exactly as only they may spend a resignation. +func TestClaimDeadEndOutOfTurnRefused(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + host, guest, start := pvpRoom(t, url) + waits := guest + if !start.GetMyTurn() { + waits = host + } + + waits.claimDeadEnd() + if code := waits.await("error").GetError().GetCode(); code != "not_your_turn" { + t.Errorf("code = %q, want not_your_turn", code) + } +} + +// --- near-miss suggestions ---------------------------------------------- + +// TestNearMissSuggestionOnWire is the end-to-end proof that a diacritic typo +// carries a suggestion: the dictionary layer is unit-tested on its own, but +// only this shows the room actually wires MoveRejected.suggestion up. +func TestNearMissSuggestionOnWire(t *testing.T) { + _, url := newTestServer(t, newTestDict("ngôn ngữ", "ngữ pháp"), Config{}) + c := dial(t, url) + c.hello("Người chơi") + c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{ + StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY}, + }}) + started := c.await("game_started").GetGameStarted() + + // Typed with no diacritics at all, which the dictionary does not know as a + // word but which strips to exactly one real one. + c.submit("ngu phap", started.GetTurnSeq()) + + rejected := c.await("move_rejected").GetMoveRejected() + if rejected.GetReason() != noituv1.RejectReason_REJECT_REASON_NOT_IN_DICTIONARY { + t.Fatalf("reason = %v, want NOT_IN_DICTIONARY", rejected.GetReason()) + } + if rejected.GetSuggestion() != "ngữ pháp" { + t.Errorf("suggestion = %q, want %q", rejected.GetSuggestion(), "ngữ pháp") + } +} + +// --- word reports --------------------------------------------------------- + +// TestReportWordAcceptsAndEchoes needs no room at all: filing a report is a +// session-level fact, which is also what a direct wire client per the README +// would exercise. +func TestReportWordAcceptsAndEchoes(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người chơi") + + c.reportWord("bình tâm") + got := c.await("word_reported").GetWordReported() + if got.GetWord() != "bình tâm" { + t.Errorf("echoed word = %q, want %q", got.GetWord(), "bình tâm") + } +} + +func TestReportWordRefusesOneSyllable(t *testing.T) { + _, url := newTestServer(t, chainDict(), Config{}) + c := dial(t, url) + c.hello("Người chơi") + + c.reportWord("một") + if code := c.await("error").GetError().GetCode(); code != "word_report_refused" { + t.Errorf("code = %q, want word_report_refused", code) + } +} + +// TestReportWordEnforcesPerSessionCap drives the cap directly against the +// session rather than through a real socket: 21 reports through the chat rate +// limiter (chatBurst=5) would be a test of two budgets fighting each other +// rather than of the cap itself. +func TestReportWordEnforcesPerSessionCap(t *testing.T) { + s := offlineSession(t, maxWordReportsPerSession+8) + + for i := range maxWordReportsPerSession { + s.handleReportWord(&noituv1.ReportWord{Word: fmt.Sprintf("từ số %d", i)}) + } + accepted := queued(t, s) + if len(accepted) != maxWordReportsPerSession { + t.Fatalf("got %d replies for %d distinct reports, want one each", len(accepted), maxWordReportsPerSession) + } + for _, m := range accepted { + if m.GetWordReported() == nil { + t.Errorf("a report inside the cap was refused: %+v", m) + } + } + + s.handleReportWord(&noituv1.ReportWord{Word: "một từ khác nữa"}) + overflow := queued(t, s) + if len(overflow) != 1 || overflow[0].GetError().GetCode() != "word_report_limit" { + t.Fatalf("the report past the cap = %+v, want a single word_report_limit error", overflow) + } +}