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.
This commit is contained in:
tiennm99 committed 2026-09-21 01:54:58 +07:00
1 parent 865222d2cd
commit 16fd37978a
12 files changed
+905 -13

No files matched your search

+72
View File
@@ -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) {
+89
View File
@@ -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)
+54 -8
View File
@@ -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.
+103
View File
@@ -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ữ")
+49 -1
View File
@@ -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.
+12 -2
View File
@@ -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},
}}
}
+35
View File
@@ -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),
}
}
+79
View File
@@ -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
+9
View File
@@ -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"),
+100 -2
View File
@@ -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() {
+77
View File
@@ -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() {
+226
View File
@@ -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)
}
}