diff --git a/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md b/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md index 91e1ac5..54d230b 100644 --- a/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md +++ b/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md @@ -18,18 +18,18 @@ by wall clock — the engine takes a deadline as data so it stays fully unit-tes ## Requirements **Functional** -- [ ] `Engine.Submit(playerID, raw)` validates: at least 2 syllables → first syllable matches current → word exists → not already used -- [ ] Distinct rejection reasons, not a boolean -- [ ] Used-word set per game; a word is spent for both players -- [ ] Detects `no legal move remains` for the player to act -- [ ] Turn deadline held as `time.Time`, enforced by the caller; engine exposes `IsExpired(now)` -- [ ] Bot difficulties: Easy (random), Medium (prefer low out-degree), Hard (dead-end win + depth-limited search) -- [ ] Score model: points per accepted word, chain length tracked +- [x] `Engine.Submit(playerID, raw)` validates: at least 2 syllables → first syllable matches current → word exists → not already used +- [x] Distinct rejection reasons, not a boolean +- [x] Used-word set per game; a word is spent for both players +- [x] Detects `no legal move remains` for the player to act +- [x] Turn deadline held as `time.Time`, enforced by the caller; engine exposes `IsExpired(now)` +- [x] Bot difficulties: Easy (random), Medium (prefer low out-degree), Hard (dead-end win + depth-limited search) +- [x] Score model: points per accepted word, chain length tracked **Non-functional** -- [ ] Zero dependency on `wsapi` or generated protobuf types — engine speaks its own Go types -- [ ] Hard bot move decision ≤ 150ms -- [ ] Engine is safe under a single owning goroutine per game (documented; the room owns it) +- [x] Zero dependency on `wsapi` or generated protobuf types — engine speaks its own Go types +- [x] Hard bot move decision ≤ 150ms +- [x] Engine is safe under a single owning goroutine per game (documented; the room owns it) ## Architecture @@ -98,6 +98,46 @@ cap; the branching factor is the out-degree of visited syllables, typically < 50 score reported at game over. The syllable term rewards longer compounds now that they are legal. Client persists a personal best in `localStorage` (phase 6) — no server storage. +## Revised during implementation — measured, not assumed + +| Spec said | Now | Why | +|---|---|---| +| Validation order: turn → length → **link** → dictionary | turn → length → **dictionary** → link → reuse | Phase 2 established that 38% of aliases move the first syllable (`sỹ hai` → `sĩ hai`). The link can only be checked after resolving, or legal moves get rejected. Messages stay accurate either way | +| `dict *dictionary.Store` | `Dictionary` interface in `game` | Lets the engine and bot be tested on hand-built word graphs small enough to reason about, with no SQLite | +| `LegalMoves() ([]string, error)`, `HasLegalMove() (bool, error)` | no error returned | Phase 2's store is in-memory; these cannot fail | +| Medium **never plays a guaranteed kill** | Medium takes a win it can see | Simulation: withholding it made Medium lose to the **random** bot 97% of the time. A strategy that declines to win loses to a coin flip. Difficulty now comes from lookahead depth, not from refusing to play well | +| Eval `-log(1 + opponent move count)` | `+log(1 + moves)` at the node | Negamax evaluates from the perspective of the player *to move*, so the sign inverts. As specified, Hard searched for positions where it was about to be trapped and lost to Medium 71% of the time | +| Bot sleeps a "thinking" pause | `ThinkingDelay()` reported, caller schedules | Sleeping inside `Choose` would make every test wait in real time | +| Strategies take `*game.Engine` | `Board` interface (read-only) | A strategy can read the position but cannot play a move, so it cannot bypass `Submit` validation | + +**Measured on the real 48,216-word corpus** (60 games per pairing, alternating sides): + +| Matchup | Win rate | Mean game length | +|---|---|---| +| Hard vs Easy | 98% | 3.3 moves | +| Medium vs Easy | 87% | — | +| Hard vs Medium | **65%** | 2.9 moves | +| Easy vs Easy | — | 15.3 moves | + +Hard decision latency on the real corpus: p95 12.5ms, **max 18.6ms** against a 150ms budget. + +Sides alternate because moving first is an advantage in its own right, and fixed seating +reports that advantage as skill — on a 7-syllable graph with fixed sides the Hard-vs-Medium +figure was exactly 50%, pure first-move effect. + +**How much lookahead is worth is a property of the graph, not of the bots.** The synthetic +ladder gave 78% on one seed and 49% on another, so the synthetic test now only guards +against a gross regression (>= 45%) and the real-corpus test carries the claim. Earlier +drafts of this document quoted the synthetic 78% and a 6ms max; both were specific to a +single run and are corrected above. + +**Playability signal for phase 6 — the dominant characteristic, not an edge case.** Games +against Hard end in ~3 moves against ~15 for two random bots. The dictionary has 1,814 +dead-end syllables, so an instant kill is available at roughly 42% of opening positions and +Hard simply takes one; the search runs in only about half of its decisions. `hardKillRate` +(0.85) softens this but does not remove it. Needs playtesting before release; the levers are +a lower kill rate and dead-end-aware opening selection, both tunable constants. + ## Related Code Files - Create: `server/internal/game/engine.go` @@ -125,14 +165,14 @@ legal. Client persists a personal best in `localStorage` (phase 6) — no server ## Success Criteria -- [ ] `go test ./internal/game/... ./internal/bot/... -race` green -- [ ] Every `RejectReason` has a test that produces exactly it -- [ ] A 1-syllable submission yields `ReasonTooFewSyllables`; 3- and 4-syllable words are accepted and score the syllable bonus -- [ ] A word played by either player cannot be replayed by the other -- [ ] `HasLegalMove` returns false on a synthetic dead-end board and the engine reports the correct winner -- [ ] Hard win rate vs Easy > 70% over 100 seeded games; Medium strictly between Easy and Hard -- [ ] `BenchmarkHardChoose` ≤ 150ms/op against the real `noitu.db` -- [ ] Engine package imports no transport or protobuf package (verified by an import assertion test) +- [x] `go test ./internal/game/... ./internal/bot/... -race` green +- [x] Every `RejectReason` has a test that produces exactly it +- [x] A 1-syllable submission yields `ReasonTooFewSyllables`; 3- and 4-syllable words are accepted and score the syllable bonus +- [x] A word played by either player cannot be replayed by the other +- [x] `HasLegalMove` returns false on a synthetic dead-end board and the engine reports the correct winner +- [x] Hard win rate vs Easy > 70% over 100 seeded games; Medium strictly between Easy and Hard +- [x] `BenchmarkHardChoose` ≤ 150ms/op against the real `noitu.db` +- [x] Engine package imports no transport or protobuf package (verified by an import assertion test) ## Risk Assessment diff --git a/server/internal/bot/bot.go b/server/internal/bot/bot.go new file mode 100644 index 0000000..0862d0a --- /dev/null +++ b/server/internal/bot/bot.go @@ -0,0 +1,141 @@ +// Package bot chooses moves for the computer opponent. +// +// The bot is a player like any other: it picks a word, and that word goes +// through the same Engine.Submit validation a human's would. Nothing here can +// bend the rules, because nothing here applies them. +// +// Choose is pure and fast. The pause that makes the bot feel human is a +// duration this package reports and the caller schedules — sleeping inside +// Choose would make every test wait in real time. +package bot + +import ( + "errors" + "iter" + "math/rand/v2" + "time" + + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// Difficulty selects a strategy. +type Difficulty int + +const ( + Easy Difficulty = iota + 1 + Medium + Hard +) + +func (d Difficulty) String() string { + switch d { + case Easy: + return "easy" + case Medium: + return "medium" + case Hard: + return "hard" + } + return "unknown" +} + +// ErrNoMove means the bot has nothing legal to play, so it has lost. +var ErrNoMove = errors.New("bot: no legal move") + +// Board is what a strategy may look at. It is deliberately narrower than +// *game.Engine: a strategy can read the position but cannot play a move, so it +// cannot sidestep validation. +// +// Only what the strategies actually use. A dictionary's static out-degree is +// deliberately absent — what matters is how many continuations remain unplayed, +// which the strategies derive from WordsStartingWith and Used. +type Board interface { + LegalMoves() []string + Used(word string) bool + WordsStartingWith(syllable string) iter.Seq[string] + LastSyllable(word string) (string, bool) +} + +// Strategy picks a move for the position. +type Strategy interface { + Choose(b Board) (string, error) + // ThinkingDelay is how long the caller should wait before playing the + // chosen move, so the bot does not answer instantly. + ThinkingDelay() time.Duration + Difficulty() Difficulty +} + +// New returns the strategy for a difficulty, seeded by rng. +// +// The rng is injected rather than global so a test can replay an identical +// game, and so two concurrent rooms never share a source. +func New(d Difficulty, rng *rand.Rand) (Strategy, error) { + if rng == nil { + // Every strategy dereferences this; failing here beats a nil panic on + // the first move. + return nil, errors.New("bot: nil rng") + } + + switch d { + case Easy: + return &easy{rng: rng}, nil + case Medium: + return &medium{rng: rng}, nil + case Hard: + return &hard{rng: rng}, nil + } + return nil, errors.New("bot: unknown difficulty") +} + +// thinkingDelay returns a human-looking pause. Faster as difficulty rises, +// which reads as the bot being more sure of itself. +func thinkingDelay(rng *rand.Rand, min, max time.Duration) time.Duration { + if max <= min { + return min + } + return min + time.Duration(rng.Int64N(int64(max-min))) +} + +// engineBoard adapts an Engine to Board. +type engineBoard struct { + e *game.Engine + dict game.Dictionary +} + +// BoardFor wraps an engine so strategies can inspect it. +// +// The dictionary comes from the engine rather than the caller: handing in a +// different one would let the bot search a graph the engine does not validate +// against, and that failure surfaces as the engine rejecting its own bot's +// move at runtime. +func BoardFor(e *game.Engine) Board { + return &engineBoard{e: e, dict: e.Dict()} +} + +func (b *engineBoard) LegalMoves() []string { return b.e.LegalMoves() } + +func (b *engineBoard) Used(word string) bool { return b.e.Used(word) } + +func (b *engineBoard) WordsStartingWith(syllable string) iter.Seq[string] { + return b.dict.WordsStartingWith(syllable) +} + +func (b *engineBoard) LastSyllable(word string) (string, bool) { + return b.dict.LastSyllable(word) +} + +// remainingOutDegree counts the continuations still available from a syllable, +// ignoring `excluding` — the move being considered, which will itself be spent +// once played. Pass "" to exclude nothing. +// +// This is the number that matters, not the dictionary's static out-degree: a +// syllable with fifty words is still a dead end if all fifty are used. +func remainingOutDegree(b Board, syllable, excluding string) int { + n := 0 + for word := range b.WordsStartingWith(syllable) { + if word != excluding && !b.Used(word) { + n++ + } + } + return n +} diff --git a/server/internal/bot/bot_test.go b/server/internal/bot/bot_test.go new file mode 100644 index 0000000..03f46ca --- /dev/null +++ b/server/internal/bot/bot_test.go @@ -0,0 +1,282 @@ +package bot + +import ( + "iter" + "math/rand/v2" + "slices" + "strings" + "testing" + "time" +) + +// fakeBoard is a hand-built position. Strategies are judged on boards small +// enough to work out the right move by hand. +type fakeBoard struct { + words map[string][2]string // word -> {first, last} + used map[string]bool + current string +} + +func board(current string, words ...string) *fakeBoard { + b := &fakeBoard{words: map[string][2]string{}, used: map[string]bool{}, current: current} + for _, w := range words { + p := strings.Fields(w) + b.words[w] = [2]string{p[0], p[len(p)-1]} + } + return b +} + +func (b *fakeBoard) play(words ...string) *fakeBoard { + for _, w := range words { + b.used[w] = true + } + return b +} + +func (b *fakeBoard) LegalMoves() []string { + var out []string + for w, e := range b.words { + if e[0] == b.current && !b.used[w] { + out = append(out, w) + } + } + slices.Sort(out) + return out +} + +func (b *fakeBoard) Used(word string) bool { return b.used[word] } + +func (b *fakeBoard) WordsStartingWith(syllable string) iter.Seq[string] { + var out []string + for w, e := range b.words { + if e[0] == syllable { + out = append(out, w) + } + } + slices.Sort(out) + return slices.Values(out) +} + +func (b *fakeBoard) LastSyllable(word string) (string, bool) { + e, ok := b.words[word] + return e[1], ok +} + +func seeded() *rand.Rand { return rand.New(rand.NewPCG(1, 2)) } + +func mustStrategy(t *testing.T, d Difficulty) Strategy { + t.Helper() + s, err := New(d, seeded()) + if err != nil { + t.Fatalf("New(%s): %v", d, err) + } + return s +} + +func TestNewRejectsUnknownDifficulty(t *testing.T) { + if _, err := New(Difficulty(99), seeded()); err == nil { + t.Error("New accepted an unknown difficulty") + } +} + +// Every strategy dereferences the rng, so a nil one must fail at construction +// rather than panicking on the first move. +func TestNewRejectsNilRNG(t *testing.T) { + for _, d := range []Difficulty{Easy, Medium, Hard} { + if _, err := New(d, nil); err == nil { + t.Errorf("New(%s, nil) succeeded, want error", d) + } + } +} + +// Every strategy must report no move rather than inventing one. +func TestAllStrategiesReportNoMove(t *testing.T) { + for _, d := range []Difficulty{Easy, Medium, Hard} { + s := mustStrategy(t, d) + b := board("lệ", "ngôn ngữ") // nothing starts with "lệ" + if _, err := s.Choose(b); err != ErrNoMove { + t.Errorf("%s.Choose on a dead end = %v, want ErrNoMove", d, err) + } + } +} + +// Whatever a strategy returns must actually be playable. +func TestAllStrategiesChooseLegalMoves(t *testing.T) { + for _, d := range []Difficulty{Easy, Medium, Hard} { + s := mustStrategy(t, d) + for i := 0; i < 50; i++ { + b := board("ngữ", + "ngữ pháp", "ngữ điệu", "ngữ nghĩa", + "pháp luật", "điệu bộ", "nghĩa vụ", + ).play("ngữ nghĩa") + + move, err := s.Choose(b) + if err != nil { + t.Fatalf("%s.Choose: %v", d, err) + } + if !slices.Contains(b.LegalMoves(), move) { + t.Fatalf("%s chose %q, which is not legal", d, move) + } + } + } +} + +// Hard must take a move that leaves the opponent with nothing. Rate-limited to +// hardKillRate, so this checks it happens most of the time rather than always. +func TestHardTakesTheKill(t *testing.T) { + kills := 0 + const runs = 200 + + for i := 0; i < runs; i++ { + s, err := New(Hard, rand.New(rand.NewPCG(uint64(i), 7))) + if err != nil { + t.Fatal(err) + } + // "ngữ pháp" hands over "pháp", which starts nothing: an instant win. + // "ngữ điệu" hands over "điệu", which still has a reply. + b := board("ngữ", "ngữ pháp", "ngữ điệu", "điệu bộ", "bộ phận") + move, err := s.Choose(b) + if err != nil { + t.Fatal(err) + } + if move == "ngữ pháp" { + kills++ + } + } + + rate := float64(kills) / runs + // Band derived from the constant, so changing hardKillRate cannot make + // this test lie. Seeds are fixed, so the result is deterministic. + if rate < hardKillRate-0.15 || rate > hardKillRate+0.10 { + t.Errorf("Hard took the kill %.0f%% of the time, want near %.0f%%", rate*100, hardKillRate*100) + } +} + +// Medium always takes a win it can see. Withholding it made Medium lose to the +// random bot 97% of the time; difficulty comes from lookahead, not from +// declining to play well. +func TestMediumTakesTheKill(t *testing.T) { + for i := 0; i < 100; i++ { + s, err := New(Medium, rand.New(rand.NewPCG(uint64(i), 11))) + if err != nil { + t.Fatal(err) + } + b := board("ngữ", "ngữ pháp", "ngữ điệu", "điệu bộ", "bộ phận") + move, err := s.Choose(b) + if err != nil { + t.Fatal(err) + } + if move != "ngữ pháp" { + t.Fatalf("Medium passed up the instant win, played %q on run %d", move, i) + } + } +} + +// With only a kill available, Medium must still play it rather than fail. +func TestMediumPlaysKillWhenItIsTheOnlyMove(t *testing.T) { + s := mustStrategy(t, Medium) + b := board("ngữ", "ngữ pháp") // "pháp" starts nothing + + move, err := s.Choose(b) + if err != nil { + t.Fatalf("Choose: %v", err) + } + if move != "ngữ pháp" { + t.Errorf("Choose = %q, want the only legal move", move) + } +} + +// Medium should prefer the tighter reply when the difference is stark. +func TestMediumPrefersTighterReply(t *testing.T) { + tight := 0 + const runs = 100 + + for i := 0; i < runs; i++ { + s, err := New(Medium, rand.New(rand.NewPCG(uint64(i), 3))) + if err != nil { + t.Fatal(err) + } + // "ngữ điệu" -> "điệu" has one reply; "ngữ nghĩa" -> "nghĩa" has four. + b := board("ngữ", + "ngữ điệu", "ngữ nghĩa", + "điệu bộ", + "nghĩa vụ", "nghĩa quân", "nghĩa trang", "nghĩa hiệp", + ) + move, err := s.Choose(b) + if err != nil { + t.Fatal(err) + } + if move == "ngữ điệu" { + tight++ + } + } + + if tight < runs*8/10 { + t.Errorf("Medium chose the tighter reply %d/%d times, want most of them", tight, runs) + } +} + +// remainingOutDegree must count what is actually left, not the dictionary's +// static figure: a syllable whose words are all spent is a dead end. +func TestRemainingOutDegreeCountsOnlyUnusedWords(t *testing.T) { + b := board("ngữ", "ngữ pháp", "pháp luật", "pháp lý") + + if got := remainingOutDegree(b, "pháp", ""); got != 2 { + t.Errorf("remainingOutDegree = %d, want 2", got) + } + + b.play("pháp luật") + if got := remainingOutDegree(b, "pháp", ""); got != 1 { + t.Errorf("after one word used, remainingOutDegree = %d, want 1", got) + } + + // The move being considered counts as spent too. + if got := remainingOutDegree(b, "pháp", "pháp lý"); got != 0 { + t.Errorf("excluding the move itself, remainingOutDegree = %d, want 0", got) + } +} + +func TestThinkingDelayInRange(t *testing.T) { + for _, d := range []Difficulty{Easy, Medium, Hard} { + s := mustStrategy(t, d) + for i := 0; i < 50; i++ { + delay := s.ThinkingDelay() + if delay < 300*time.Millisecond || delay > 2*time.Second { + t.Errorf("%s.ThinkingDelay = %v, outside a human-looking range", d, delay) + } + } + } +} + +func TestDifficultyString(t *testing.T) { + for _, d := range []Difficulty{Easy, Medium, Hard} { + if s := d.String(); s == "" || s == "unknown" { + t.Errorf("Difficulty(%d).String() = %q", d, s) + } + } + if got := Difficulty(99).String(); got != "unknown" { + t.Errorf("unknown difficulty string = %q", got) + } +} + +// A seeded strategy must replay identically, or the simulation below proves +// nothing and a reported bug cannot be reproduced. +func TestChooseIsDeterministicUnderSeed(t *testing.T) { + pick := func() string { + s, err := New(Hard, rand.New(rand.NewPCG(42, 42))) + if err != nil { + t.Fatal(err) + } + b := board("ngữ", "ngữ pháp", "ngữ điệu", "ngữ nghĩa", + "pháp luật", "điệu bộ", "nghĩa vụ", "luật lệ") + move, err := s.Choose(b) + if err != nil { + t.Fatal(err) + } + return move + } + + if a, b := pick(), pick(); a != b { + t.Errorf("same seed produced %q then %q", a, b) + } +} diff --git a/server/internal/bot/realcorpus_test.go b/server/internal/bot/realcorpus_test.go new file mode 100644 index 0000000..0195c1c --- /dev/null +++ b/server/internal/bot/realcorpus_test.go @@ -0,0 +1,232 @@ +package bot + +import ( + "math/rand/v2" + "os" + "sort" + "testing" + "time" + + "github.com/tiennm99dev/noitu/server/internal/dictionary" + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// realDictPath is the derived dictionary. It is a build artifact, not in git, +// so every test here skips when it is absent — CI runs without it. +const realDictPath = "../../../data/noitu.db" + +func realDict(tb testing.TB) *dictionary.Store { + tb.Helper() + + if _, err := os.Stat(realDictPath); err != nil { + tb.Skipf("real dictionary not built (run 'make fetch-dict && make dict'): %v", err) + } + store, err := dictionary.Open(realDictPath) + if err != nil { + tb.Fatalf("open real dictionary: %v", err) + } + return store +} + +// playRealGame runs one bot-vs-bot game on the real corpus and reports the +// winner plus how many moves it took. +func playRealGame(tb testing.TB, dict game.Dictionary, first, second Strategy, seed uint64) (game.PlayerID, int) { + tb.Helper() + + const p1, p2 = game.PlayerID("first"), game.PlayerID("second") + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + store := dict.(*dictionary.Store) + var e *game.Engine + // RandomOpeningWord can still return a word the engine refuses, so retry. + for attempt := 0; attempt < 20 && e == nil; attempt++ { + opening, err := store.RandomOpeningWord(5) + if err != nil { + tb.Fatalf("RandomOpeningWord: %v", err) + } + e, _ = game.New(dict, []game.PlayerID{p1, p2}, opening, time.Minute, now) + } + if e == nil { + tb.Fatal("could not find a playable opening word") + } + + board := BoardFor(e) + strategies := map[game.PlayerID]Strategy{p1: first, p2: second} + + moves := 0 + for turn := 0; turn < 2000 && !e.Over(); turn++ { + p := e.Turn() + move, err := strategies[p].Choose(board) + if err == ErrNoMove { + tb.Fatalf("bot had no move at %q but the engine had not ended the game", e.Current()) + } + if err != nil { + tb.Fatalf("Choose: %v", err) + } + + now = now.Add(time.Second) + if _, reason := e.Submit(p, move, now); reason != game.ReasonNone { + tb.Fatalf("bot %s played %q which the engine rejected: %s", p, move, reason) + } + moves++ + } + + if !e.Over() { + tb.Fatal("game did not finish within the turn cap") + } + return e.Winner(), moves +} + +// The difficulty ladder that actually matters. The synthetic ladder measures +// one hand-made graph, and how much lookahead is worth turns out to be a +// property of the graph; this measures the dictionary players will face. +func TestDifficultyLadderRealCorpus(t *testing.T) { + if testing.Short() { + t.Skip("real-corpus simulation skipped in short mode") + } + dict := realDict(t) + + rate := func(challenger, defender Difficulty, n int) (float64, float64) { + const p1 = game.PlayerID("first") + wins, totalMoves := 0, 0 + for i := 0; i < n; i++ { + c, err := New(challenger, rand.New(rand.NewPCG(uint64(i), 1))) + if err != nil { + t.Fatal(err) + } + d, err := New(defender, rand.New(rand.NewPCG(uint64(i), 2))) + if err != nil { + t.Fatal(err) + } + + // Alternate sides: moving first is an advantage in its own right, + // and fixed seating reports that advantage as skill. + challengerFirst := i%2 == 0 + var winner game.PlayerID + var moves int + if challengerFirst { + winner, moves = playRealGame(t, dict, c, d, uint64(i)) + } else { + winner, moves = playRealGame(t, dict, d, c, uint64(i)) + } + if (winner == p1) == challengerFirst { + wins++ + } + totalMoves += moves + } + return float64(wins) / float64(n), float64(totalMoves) / float64(n) + } + + const games = 60 + + hardVsEasy, hardEasyLen := rate(Hard, Easy, games) + mediumVsEasy, _ := rate(Medium, Easy, games) + hardVsMedium, hardMediumLen := rate(Hard, Medium, games) + _, easyLen := rate(Easy, Easy, games) + + t.Logf("real corpus, %d games each: hard-vs-easy %.0f%% (%.1f moves), medium-vs-easy %.0f%%, hard-vs-medium %.0f%% (%.1f moves), easy-vs-easy %.1f moves", + games, hardVsEasy*100, hardEasyLen, mediumVsEasy*100, hardVsMedium*100, hardMediumLen, easyLen) + + if hardVsEasy <= 0.70 { + t.Errorf("Hard beat Easy %.0f%% of the time, want > 70%%", hardVsEasy*100) + } + if mediumVsEasy <= 0.50 { + t.Errorf("Medium beat Easy %.0f%% of the time, want > 50%%", mediumVsEasy*100) + } + if hardVsMedium <= 0.50 { + t.Errorf("Hard beat Medium %.0f%% of the time, want > 50%%", hardVsMedium*100) + } +} + +// The success criterion is a decision within 150ms on the real dictionary, so +// measure it there rather than on a synthetic graph. +func TestHardChooseLatencyRealCorpus(t *testing.T) { + if testing.Short() { + t.Skip("latency check skipped in short mode") + } + dict := realDict(t) + + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + var durations []time.Duration + + for g := 0; g < 40; g++ { + opening, err := dict.RandomOpeningWord(20) + if err != nil { + t.Fatal(err) + } + e, err := game.New(dict, []game.PlayerID{"p1", "p2"}, opening, time.Minute, now) + if err != nil { + continue // dead-end opening; try another + } + board := BoardFor(e) + hard, _ := New(Hard, rand.New(rand.NewPCG(uint64(g), 1))) + easy, _ := New(Easy, rand.New(rand.NewPCG(uint64(g), 2))) + + for turn := 0; turn < 40 && !e.Over(); turn++ { + s, timed := hard, true + if e.Turn() == "p2" { + s, timed = easy, false + } + start := time.Now() + move, err := s.Choose(board) + elapsed := time.Since(start) + if err != nil { + break + } + if timed { + durations = append(durations, elapsed) + } + now = now.Add(time.Second) + if _, r := e.Submit(e.Turn(), move, now); r != game.ReasonNone { + t.Fatalf("engine rejected bot move %q: %s", move, r) + } + } + } + + if len(durations) == 0 { + t.Fatal("no Hard decisions were measured") + } + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + worst := durations[len(durations)-1] + + t.Logf("Hard decisions on the real corpus: n=%d p50=%v p95=%v max=%v", + len(durations), durations[len(durations)/2], + durations[int(float64(len(durations))*0.95)], worst) + + if worst > 150*time.Millisecond { + t.Errorf("slowest Hard decision %v, want <= 150ms", worst) + } +} + +// BenchmarkHardChooseRealCorpus is the benchmark the success criteria name. +// The synthetic one measures a graph a tenth the size. +func BenchmarkHardChooseRealCorpus(b *testing.B) { + dict := realDict(b) + + s, err := New(Hard, rand.New(rand.NewPCG(1, 1))) + if err != nil { + b.Fatal(err) + } + + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + var e *game.Engine + for attempt := 0; attempt < 20 && e == nil; attempt++ { + opening, err := dict.RandomOpeningWord(50) + if err != nil { + b.Fatal(err) + } + e, _ = game.New(dict, []game.PlayerID{"p1", "p2"}, opening, time.Minute, now) + } + if e == nil { + b.Fatal("could not find a playable opening word") + } + board := BoardFor(e) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := s.Choose(board); err != nil { + b.Fatal(err) + } + } +} diff --git a/server/internal/bot/simulate_test.go b/server/internal/bot/simulate_test.go new file mode 100644 index 0000000..8ced021 --- /dev/null +++ b/server/internal/bot/simulate_test.go @@ -0,0 +1,249 @@ +package bot + +import ( + "iter" + "math/rand/v2" + "slices" + "testing" + "time" + + "github.com/tiennm99dev/noitu/server/internal/game" +) + +// simDict is a word graph large enough that skill matters: syllables differ +// widely in how many continuations they offer, so choosing well is possible +// and choosing badly is punished. +type simDict struct { + words map[string][2]string +} + +// newSimDict builds a seeded pseudo-random word graph. +// +// Size and irregularity both matter. A small, regular graph makes every game a +// race decided by who moves first, and lookahead cannot show an advantage over +// greedy play — measured at exactly 50% on a 7-syllable version. Real +// dictionaries have many syllables with wildly uneven out-degrees, and that is +// where searching ahead starts to pay. +func newSimDict() *simDict { + d := &simDict{words: map[string][2]string{}} + rng := rand.New(rand.NewPCG(20260904, 3)) + + const syllableCount = 26 + syllables := make([]string, syllableCount) + for i := range syllables { + syllables[i] = string(rune('a' + i)) + } + + add := func(first, last string) { + d.words[first+" "+last] = [2]string{first, last} + } + + // Uneven out-degrees: a few hubs, many mid-sized syllables, some dead ends. + for i, first := range syllables { + var outDegree int + switch { + case i < 3: + outDegree = 8 + rng.IntN(4) // hubs + case i < syllableCount-4: + outDegree = 1 + rng.IntN(5) // ordinary + default: + outDegree = rng.IntN(2) // near dead ends + } + for j := 0; j < outDegree; j++ { + add(first, syllables[rng.IntN(syllableCount)]) + } + } + + return d +} + +func (d *simDict) Resolve(word string) (string, bool) { + _, ok := d.words[word] + return word, ok +} + +func (d *simDict) FirstSyllable(word string) (string, bool) { + e, ok := d.words[word] + return e[0], ok +} + +func (d *simDict) LastSyllable(word string) (string, bool) { + e, ok := d.words[word] + return e[1], ok +} + +func (d *simDict) WordsStartingWith(syllable string) iter.Seq[string] { + var out []string + for w, e := range d.words { + if e[0] == syllable { + out = append(out, w) + } + } + // Sorted for reproducibility under a fixed seed. + slices.Sort(out) + return slices.Values(out) +} + +func (d *simDict) OutDegree(syllable string) (int, error) { + n := 0 + for _, e := range d.words { + if e[0] == syllable { + n++ + } + } + return n, nil +} + +// playGame runs one headless bot-vs-bot game and reports the winner. +// Every move goes through Engine.Submit, so the bots are held to the same +// rules a human is. +func playGame(t *testing.T, dict game.Dictionary, first, second Strategy) game.PlayerID { + t.Helper() + + const p1, p2 = game.PlayerID("first"), game.PlayerID("second") + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + e, err := game.New(dict, []game.PlayerID{p1, p2}, "a b", time.Minute, now) + if err != nil { + t.Fatalf("New: %v", err) + } + board := BoardFor(e) + + strategies := map[game.PlayerID]Strategy{p1: first, p2: second} + + // Bounded so a pathological loop cannot hang the suite. + for turn := 0; turn < 500 && !e.Over(); turn++ { + p := e.Turn() + move, err := strategies[p].Choose(board) + if err == ErrNoMove { + // The engine ends a game on a dead end before the bot is asked, so + // this should be unreachable. If it ever fires, the player to move + // has lost, so the winner is the opponent -- returning the player + // to move would silently invert every ladder number. + t.Fatalf("bot had no move at %q but the engine had not ended the game", e.Current()) + } + if err != nil { + t.Fatalf("Choose: %v", err) + } + + now = now.Add(time.Second) + if _, reason := e.Submit(p, move, now); reason != game.ReasonNone { + t.Fatalf("bot %s played %q which the engine rejected: %s", p, move, reason) + } + } + + if !e.Over() { + t.Fatal("game did not finish within the turn cap") + } + return e.Winner() +} + +// winRate plays n games and reports how often the challenger wins. +// +// Sides alternate every game. On a small graph moving first is an advantage in +// its own right, and a fixed seating order would report that advantage as +// skill. +func winRate(t *testing.T, challenger, defender Difficulty, n int) float64 { + t.Helper() + + const p1 = game.PlayerID("first") + dict := newSimDict() + wins := 0 + + for i := 0; i < n; i++ { + // Fresh seeded strategies per game: reproducible, but not identical + // play every game. + c, err := New(challenger, rand.New(rand.NewPCG(uint64(i), 1))) + if err != nil { + t.Fatal(err) + } + d, err := New(defender, rand.New(rand.NewPCG(uint64(i), 2))) + if err != nil { + t.Fatal(err) + } + + challengerMovesFirst := i%2 == 0 + var winner game.PlayerID + if challengerMovesFirst { + winner = playGame(t, dict, c, d) + } else { + winner = playGame(t, dict, d, c) + } + + if (winner == p1) == challengerMovesFirst { + wins++ + } + } + return float64(wins) / float64(n) +} + +// The whole point of three difficulties is that they are actually different. +// If Hard does not beat Easy, the ladder is decoration. +func TestDifficultyLadder(t *testing.T) { + if testing.Short() { + t.Skip("simulation skipped in short mode") + } + + const games = 100 + + hardVsEasy := winRate(t, Hard, Easy, games) + mediumVsEasy := winRate(t, Medium, Easy, games) + hardVsMedium := winRate(t, Hard, Medium, games) + + t.Logf("win rates over %d games: hard-vs-easy %.0f%%, medium-vs-easy %.0f%%, hard-vs-medium %.0f%%", + games, hardVsEasy*100, mediumVsEasy*100, hardVsMedium*100) + + if hardVsEasy <= 0.70 { + t.Errorf("Hard beat Easy only %.0f%% of the time, want > 70%%", hardVsEasy*100) + } + if mediumVsEasy <= 0.50 { + t.Errorf("Medium beat Easy only %.0f%% of the time, want > 50%%", mediumVsEasy*100) + } + // Hard vs Medium head to head, not their scores against Easy: both beat a + // random opponent nearly always, so those numbers saturate and cannot order + // the two. + // + // The threshold is deliberately loose. This is one synthetic graph, and how + // much lookahead is worth is a property of the graph: regenerating it with + // a different seed moved this figure between 49% and 78%. The real corpus + // is the load-bearing measurement -- see TestDifficultyLadderRealCorpus -- + // and this test only guards against a gross regression. + if hardVsMedium < 0.45 { + t.Errorf("Hard beat Medium only %.0f%% of the time, want no worse than parity", hardVsMedium*100) + } +} + +// Bots must never hand the engine an illegal move; playGame fails the test if +// Submit rejects anything, so this is really a rules-conformance check. +func TestSimulatedGamesAlwaysTerminate(t *testing.T) { + dict := newSimDict() + for i := 0; i < 20; i++ { + a, _ := New(Hard, rand.New(rand.NewPCG(uint64(i), 5))) + b, _ := New(Hard, rand.New(rand.NewPCG(uint64(i), 6))) + if got := playGame(t, dict, a, b); got == "" { + t.Fatalf("game %d ended with no winner", i) + } + } +} + +func BenchmarkHardChoose(b *testing.B) { + dict := newSimDict() + s, err := New(Hard, rand.New(rand.NewPCG(1, 1))) + if err != nil { + b.Fatal(err) + } + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + e, err := game.New(dict, []game.PlayerID{"p1", "p2"}, "a b", time.Minute, now) + if err != nil { + b.Fatal(err) + } + board := BoardFor(e) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := s.Choose(board); err != nil { + b.Fatal(err) + } + } +} diff --git a/server/internal/bot/strategy_easy.go b/server/internal/bot/strategy_easy.go new file mode 100644 index 0000000..1a31d0e --- /dev/null +++ b/server/internal/bot/strategy_easy.go @@ -0,0 +1,28 @@ +package bot + +import ( + "math/rand/v2" + "time" +) + +// easy plays a uniformly random legal move. +// +// It does not look at what the move hands the opponent, so it will happily +// give away a winning position — which is the point. +type easy struct { + rng *rand.Rand +} + +func (s *easy) Difficulty() Difficulty { return Easy } + +func (s *easy) ThinkingDelay() time.Duration { + return thinkingDelay(s.rng, 400*time.Millisecond, 900*time.Millisecond) +} + +func (s *easy) Choose(b Board) (string, error) { + moves := b.LegalMoves() + if len(moves) == 0 { + return "", ErrNoMove + } + return moves[s.rng.IntN(len(moves))], nil +} diff --git a/server/internal/bot/strategy_hard.go b/server/internal/bot/strategy_hard.go new file mode 100644 index 0000000..3938913 --- /dev/null +++ b/server/internal/bot/strategy_hard.go @@ -0,0 +1,215 @@ +package bot + +import ( + "math" + "math/rand/v2" + "slices" + "time" +) + +// Tuning. These are constants rather than structure precisely so the bot can be +// made easier or harder without touching the search. +const ( + // hardKillRate is how often Hard takes an available instant win. A bot that + // always wins is not a game, so it sometimes plays on instead. + hardKillRate = 0.85 + // searchDepth is plies of lookahead. Directed Edge Geography is + // PSPACE-complete, so this is a heuristic cutoff, not a solver. + searchDepth = 4 + // nodeCap bounds the search on hub syllables with a large branching + // factor, keeping Choose inside its latency budget. + nodeCap = 20000 + // branchCap limits how many candidates are explored per node. Moves are + // ordered tightest-first, so the pruned tail is the least interesting. + branchCap = 12 + + // A win is the negation of a child's loseScore, so only the losing + // terminal needs a constant. + loseScore = -1000.0 +) + +// hard looks ahead. +// +// First it checks for an immediate kill: a move that leaves the opponent with +// nothing. Failing that it runs a depth-limited negamax over the remaining +// edges, valuing a position by how few replies the opponent has. If the search +// finds nothing useful it falls back to Medium's heuristic. +type hard struct { + rng *rand.Rand +} + +func (s *hard) Difficulty() Difficulty { return Hard } + +func (s *hard) ThinkingDelay() time.Duration { + return thinkingDelay(s.rng, 600*time.Millisecond, 1500*time.Millisecond) +} + +func (s *hard) Choose(b Board) (string, error) { + moves := b.LegalMoves() + if len(moves) == 0 { + return "", ErrNoMove + } + + scored := scoreMoves(b, moves) + if len(scored) == 0 { + return moves[s.rng.IntN(len(moves))], nil + } + + // An instant win: the opponent has no reply at all. + var kills []string + for _, m := range scored { + if m.handsOver == 0 { + kills = append(kills, m.word) + } + } + if len(kills) > 0 { + if s.rng.Float64() < hardKillRate { + return kills[s.rng.IntN(len(kills))], nil + } + // Declined. The killing moves must be taken off the table, not just + // skipped here: the search below would otherwise rediscover the same + // win and play it anyway, making hardKillRate do nothing at all. + var spared []scoredMove + for _, m := range scored { + if m.handsOver > 0 { + spared = append(spared, m) + } + } + if len(spared) > 0 { + scored = spared + } + } + + // Order tightest-first so alpha-beta prunes early. + slices.SortStableFunc(scored, func(a, b scoredMove) int { return a.handsOver - b.handsOver }) + + st := &search{board: b, used: map[string]bool{}, budget: nodeCap} + + best := "" + alpha := math.Inf(-1) + for i, m := range scored { + if i >= branchCap || st.budget <= 0 { + // Out of budget: keep the best move found so far rather than + // scoring the rest against an exhausted search. + break + } + last, ok := b.LastSyllable(m.word) + if !ok { + continue + } + + st.used[m.word] = true + // Negamax: the opponent's best outcome, negated, is ours. Alpha carries + // across children so later subtrees can be pruned. + score := -st.negamax(last, searchDepth-1, math.Inf(-1), -alpha) + delete(st.used, m.word) + + if score > alpha { + alpha, best = score, m.word + } + } + + if best == "" { + // Nothing scored; fall back to the tightest-first heuristic, which is + // Medium's judgement. + return scored[0].word, nil + } + return best, nil +} + +// search carries the mutable state of one lookahead. Moves are made and unmade +// on `used` rather than copying the position at every node. +type search struct { + board Board + used map[string]bool + budget int +} + +// spent reports whether a word is unavailable: already played in the real game, +// or played earlier in this search line. +func (s *search) spent(word string) bool { + return s.used[word] || s.board.Used(word) +} + +// negamax scores the position for the player to move at `current`. +// +// Returns a value from that player's point of view, so the caller negates it. +func (s *search) negamax(current string, depth int, alpha, beta float64) float64 { + if s.budget <= 0 { + // Fail soft: return the bound already established rather than a neutral + // 0, which reads as "this line is fine" and can push the root toward a + // losing move when the budget bites. + return alpha + } + s.budget-- + + var candidates []string + for word := range s.board.WordsStartingWith(current) { + if !s.spent(word) { + candidates = append(candidates, word) + } + } + + // No reply: the player to move has lost. + if len(candidates) == 0 { + return loseScore + } + if depth <= 0 { + // Negamax evaluates from the perspective of the player to move, so + // mobility is a positive: more replies available is better for them. + // The caller negates this, which is what turns it into "leave the + // opponent with as little as possible". Getting this sign backwards + // makes the search prefer positions where it is about to be trapped — + // measured at a 29% win rate against the one-ply bot. + // + // Log rather than the raw count so one hub syllable cannot dominate. + return math.Log(1 + float64(len(candidates))) + } + + // Order tightest-first: explore the moves most likely to be strong. + type cand struct { + word string + last string + handsOver int + } + ordered := make([]cand, 0, len(candidates)) + for _, w := range candidates { + last, ok := s.board.LastSyllable(w) + if !ok { + continue + } + n := 0 + for reply := range s.board.WordsStartingWith(last) { + if reply != w && !s.spent(reply) { + n++ + } + } + ordered = append(ordered, cand{word: w, last: last, handsOver: n}) + } + slices.SortStableFunc(ordered, func(a, b cand) int { return a.handsOver - b.handsOver }) + + // Starts at the losing score: if every candidate is pruned or unresolvable, + // this position is no better than lost, which is the honest floor. + best := loseScore + for i, c := range ordered { + if i >= branchCap { + break + } + + s.used[c.word] = true + score := -s.negamax(c.last, depth-1, -beta, -alpha) + delete(s.used, c.word) + + if score > best { + best = score + } + if best > alpha { + alpha = best + } + if alpha >= beta { + break // the opponent would avoid this line + } + } + + return best +} diff --git a/server/internal/bot/strategy_medium.go b/server/internal/bot/strategy_medium.go new file mode 100644 index 0000000..8166323 --- /dev/null +++ b/server/internal/bot/strategy_medium.go @@ -0,0 +1,78 @@ +package bot + +import ( + "math/rand/v2" + "slices" + "time" +) + +// medium plays greedily one move deep. +// +// It scores each legal move by how many continuations remain from the syllable +// it hands over, takes an immediate win when it sees one, and otherwise picks +// at random from the tightest quartile. Random rather than strictly best, so it +// does not play the same game every time. +// +// What separates it from hard is lookahead, not ruthlessness: medium sees only +// the move in front of it, while hard searches several plies. An earlier +// version withheld the winning move to feel gentler, and simulation showed it +// losing to the random bot 97% of the time — a strategy that declines to win +// loses to a coin flip. Difficulty has to come from how well a bot plays, not +// from it refusing to. +type medium struct { + rng *rand.Rand +} + +func (s *medium) Difficulty() Difficulty { return Medium } + +func (s *medium) ThinkingDelay() time.Duration { + return thinkingDelay(s.rng, 500*time.Millisecond, 1200*time.Millisecond) +} + +type scoredMove struct { + word string + handsOver int // continuations left for the opponent +} + +func (s *medium) Choose(b Board) (string, error) { + moves := b.LegalMoves() + if len(moves) == 0 { + return "", ErrNoMove + } + + scored := scoreMoves(b, moves) + if len(scored) == 0 { + return moves[s.rng.IntN(len(moves))], nil + } + + slices.SortStableFunc(scored, func(a, b scoredMove) int { + return a.handsOver - b.handsOver + }) + + // A move that leaves the opponent nothing wins immediately. Sorting puts + // those first, so this is simply taking the best available move. + if scored[0].handsOver == 0 { + return scored[0].word, nil + } + + // Otherwise pick at random from the tightest quartile. + cut := max(len(scored)/4, 1) + return scored[s.rng.IntN(cut)].word, nil +} + +// scoreMoves annotates each candidate with how many replies it leaves. The +// move itself is counted as spent, since the opponent cannot replay it. +func scoreMoves(b Board, moves []string) []scoredMove { + scored := make([]scoredMove, 0, len(moves)) + for _, word := range moves { + last, ok := b.LastSyllable(word) + if !ok { + continue + } + scored = append(scored, scoredMove{ + word: word, + handsOver: remainingOutDegree(b, last, word), + }) + } + return scored +} diff --git a/server/internal/game/engine.go b/server/internal/game/engine.go new file mode 100644 index 0000000..c068c6b --- /dev/null +++ b/server/internal/game/engine.go @@ -0,0 +1,301 @@ +// Package game implements the nối từ rules: what counts as a legal move, whose +// turn it is, and when a game is over. +// +// It is transport-free by design. No WebSocket, no protobuf, no wall clock: the +// turn deadline is data the caller supplies and reads back, so every rule can +// be tested without a timer or a network. The room in the wsapi layer owns an +// Engine and is the only goroutine that touches it. +package game + +import ( + "errors" + "fmt" + "time" + + "github.com/tiennm99dev/noitu/server/internal/vietnamese" +) + +// Scoring. Longer words are worth more, which gives players a reason to reach +// for three- and four-syllable compounds rather than always playing the +// shortest legal word. +const ( + basePoints = 10 + chainBonus = 2 // per word already played + syllableBonus = 5 // per syllable beyond the minimum + maxPointsPerWord = 100 +) + +// Engine holds one game. +// +// Not safe for concurrent use. Exactly one goroutine owns an Engine — in the +// server that is the room goroutine, which serializes every input through a +// single channel. +type Engine struct { + dict Dictionary + players []PlayerID + used map[string]struct{} + current string + turnIndex int + turnLimit time.Duration + deadline time.Time + history []Move + scores map[PlayerID]int + + over bool + winner PlayerID + endReason EndReason +} + +// New starts a game from an opening word. +// +// The opening word counts as played: it seeds the used set and fixes the +// syllable the first player must link from. +func New(dict Dictionary, players []PlayerID, opening string, turnLimit time.Duration, now time.Time) (*Engine, error) { + if dict == nil { + return nil, errors.New("game: nil dictionary") + } + if len(players) < 2 { + return nil, fmt.Errorf("game: need at least 2 players, got %d", len(players)) + } + if turnLimit <= 0 { + return nil, fmt.Errorf("game: turn limit must be positive, got %v", turnLimit) + } + seen := make(map[PlayerID]struct{}, len(players)) + for _, p := range players { + if _, dup := seen[p]; dup { + return nil, fmt.Errorf("game: duplicate player %q", p) + } + seen[p] = struct{}{} + } + + canonical, ok := dict.Resolve(opening) + if !ok { + return nil, fmt.Errorf("game: opening word %q is not in the dictionary", opening) + } + last, ok := dict.LastSyllable(canonical) + if !ok { + return nil, fmt.Errorf("game: opening word %q has no last syllable", canonical) + } + + e := &Engine{ + dict: dict, + players: append([]PlayerID{}, players...), + used: map[string]struct{}{canonical: {}}, + current: last, + turnLimit: turnLimit, + deadline: now.Add(turnLimit), + scores: make(map[PlayerID]int, len(players)), + } + for _, p := range players { + e.scores[p] = 0 + } + + // An opening whose last syllable starts nothing hands the first player a + // game they have already lost, with no move to make and no reason given — + // it would resolve only when the turn timer expired, reported as a timeout. + // Refuse it here so the caller picks another opening. + if !e.HasLegalMove() { + return nil, fmt.Errorf("game: opening word %q ends on %q, which starts no other word", canonical, last) + } + + return e, nil +} + +// Dict returns the dictionary this game is played against, so a bot searches +// the same word graph that Submit validates against. +func (e *Engine) Dict() Dictionary { return e.dict } + +// Turn reports whose move it is. +func (e *Engine) Turn() PlayerID { return e.players[e.turnIndex] } + +// Current reports the syllable the next word must start with. +func (e *Engine) Current() string { return e.current } + +// Deadline reports when the current turn expires. +func (e *Engine) Deadline() time.Time { return e.deadline } + +// Over reports whether the game has finished. +func (e *Engine) Over() bool { return e.over } + +// Winner reports the winner. Meaningless while the game is in play. +func (e *Engine) Winner() PlayerID { return e.winner } + +// ChainLength reports how many words have been played, opening word included. +func (e *Engine) ChainLength() int { return len(e.history) + 1 } + +// Submit validates a player's word and, if legal, plays it. +// +// The returned Move carries the canonical spelling; on rejection the reason +// says which rule failed. +// +// Validation order is turn, then length, then dictionary, then link, then +// reuse. Resolving before checking the link is not optional: canonicalization +// can move the first syllable ("sỹ hai" resolves to "sĩ hai"), so a link check +// against what the player typed would reject legal moves. +// +// raw is untrusted input and is normalized before use, but its length is not +// bounded here: the transport layer caps message size before a word reaches +// this point. +func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReason) { + if e.over { + return Move{}, ReasonGameOver + } + if p != e.Turn() { + return Move{}, ReasonNotYourTurn + } + if e.IsExpired(now) { + e.finish(e.opponentOf(p), EndTimeout) + return Move{}, ReasonTimeout + } + + normalized, syllables, err := vietnamese.Normalize(raw) + if err != nil || !vietnamese.HasEnoughSyllables(syllables) { + return Move{}, ReasonTooFewSyllables + } + + canonical, ok := e.dict.Resolve(normalized) + if !ok { + return Move{}, ReasonNotInDictionary + } + + first, ok := e.dict.FirstSyllable(canonical) + if !ok { + return Move{}, ReasonNotInDictionary + } + if first != e.current { + return Move{}, ReasonWrongLink + } + + if _, played := e.used[canonical]; played { + return Move{}, ReasonAlreadyUsed + } + + last, ok := e.dict.LastSyllable(canonical) + if !ok { + return Move{}, ReasonNotInDictionary + } + + move := Move{ + Player: p, + Word: canonical, + Typed: raw, + First: first, + Last: last, + Syllables: len(syllables), + Points: e.pointsFor(len(syllables)), + At: now, + } + + e.used[canonical] = struct{}{} + e.history = append(e.history, move) + e.scores[p] += move.Points + e.current = last + e.turnIndex = (e.turnIndex + 1) % len(e.players) + e.deadline = now.Add(e.turnLimit) + + // The player who now has to move may have nothing to play. + if !e.HasLegalMove() { + e.finish(p, EndNoLegalMove) + } + + return move, ReasonNone +} + +// pointsFor scores a word about to be played. The chain term counts the words +// already down, opening word included, which is what ChainLength reports. +func (e *Engine) pointsFor(syllables int) int { + points := basePoints + chainBonus*e.ChainLength() + syllableBonus*(syllables-vietnamese.MinSyllables) + return min(points, maxPointsPerWord) +} + +// LegalMoves lists every word the player to act may play. +// +// Allocates, so the bot's search uses HasLegalMove and iterates directly rather +// than calling this per node. +func (e *Engine) LegalMoves() []string { + var moves []string + for word := range e.dict.WordsStartingWith(e.current) { + if _, played := e.used[word]; !played { + moves = append(moves, word) + } + } + return moves +} + +// HasLegalMove reports whether the player to act has anything to play. It stops +// at the first unused candidate instead of building the whole list. +func (e *Engine) HasLegalMove() bool { + for word := range e.dict.WordsStartingWith(e.current) { + if _, played := e.used[word]; !played { + return true + } + } + return false +} + +// IsExpired reports whether the current turn's deadline has passed. +func (e *Engine) IsExpired(now time.Time) bool { + return now.After(e.deadline) +} + +// Timeout ends the game against the player whose turn expired. The caller +// drives this from its own timer; the engine never reads the clock itself. +func (e *Engine) Timeout(now time.Time) bool { + if e.over || !e.IsExpired(now) { + return false + } + e.finish(e.opponentOf(e.Turn()), EndTimeout) + return true +} + +// Resign ends the game against the player who gave up. +func (e *Engine) Resign(p PlayerID) bool { + if e.over { + return false + } + e.finish(e.opponentOf(p), EndResigned) + return true +} + +func (e *Engine) finish(winner PlayerID, reason EndReason) { + e.over = true + e.winner = winner + e.endReason = reason +} + +// opponentOf returns the other player. With more than two seats it returns the +// next one, which keeps the two-player case exact and the rest sane. +func (e *Engine) opponentOf(p PlayerID) PlayerID { + for i, candidate := range e.players { + if candidate == p { + return e.players[(i+1)%len(e.players)] + } + } + return p +} + +// Used reports whether a canonical word has already been played. +func (e *Engine) Used(word string) bool { + _, played := e.used[word] + return played +} + +// Snapshot copies the observable state for the transport layer. +func (e *Engine) Snapshot() State { + scores := make(map[PlayerID]int, len(e.scores)) + for p, s := range e.scores { + scores[p] = s + } + + return State{ + Current: e.current, + Turn: e.Turn(), + Deadline: e.deadline, + History: append([]Move{}, e.history...), + Scores: scores, + ChainLength: e.ChainLength(), + Over: e.over, + Winner: e.winner, + EndReason: e.endReason, + } +} diff --git a/server/internal/game/engine_test.go b/server/internal/game/engine_test.go new file mode 100644 index 0000000..9aaea87 --- /dev/null +++ b/server/internal/game/engine_test.go @@ -0,0 +1,496 @@ +package game + +import ( + "iter" + "slices" + "strings" + "testing" + "time" +) + +// fakeDict is a hand-built word graph. Tests state the exact edges they need, +// so a rule can be exercised on a board small enough to reason about — no +// SQLite, no 48k-word corpus. +type fakeDict struct { + words map[string][2]string // canonical -> {first, last} + aliases map[string]string +} + +func newDict(words ...string) *fakeDict { + d := &fakeDict{words: map[string][2]string{}, aliases: map[string]string{}} + for _, w := range words { + parts := strings.Fields(w) + d.words[w] = [2]string{parts[0], parts[len(parts)-1]} + } + return d +} + +func (d *fakeDict) alias(variant, canonical string) *fakeDict { + d.aliases[variant] = canonical + return d +} + +func (d *fakeDict) Resolve(word string) (string, bool) { + if _, ok := d.words[word]; ok { + return word, true + } + c, ok := d.aliases[word] + return c, ok +} + +func (d *fakeDict) FirstSyllable(word string) (string, bool) { + e, ok := d.words[word] + return e[0], ok +} + +func (d *fakeDict) LastSyllable(word string) (string, bool) { + e, ok := d.words[word] + return e[1], ok +} + +func (d *fakeDict) WordsStartingWith(syllable string) iter.Seq[string] { + var out []string + for w, e := range d.words { + if e[0] == syllable { + out = append(out, w) + } + } + slices.Sort(out) + return slices.Values(out) +} + +func (d *fakeDict) OutDegree(syllable string) (int, error) { + n := 0 + for _, e := range d.words { + if e[0] == syllable { + n++ + } + } + return n, nil +} + +const ( + alice = PlayerID("alice") + bob = PlayerID("bob") +) + +var t0 = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + +// standardDict gives both players room to move for several turns. +func standardDict() *fakeDict { + return newDict( + "ngôn ngữ", // ngôn -> ngữ + "ngữ pháp", // ngữ -> pháp + "ngữ điệu", // ngữ -> điệu + "pháp luật", // pháp -> luật + "pháp lý", // pháp -> lý + "luật lệ", // luật -> lệ + "lý do", // lý -> do + "vô tuyến điện", + ) +} + +func newGame(t *testing.T, d Dictionary, opening string) *Engine { + t.Helper() + e, err := New(d, []PlayerID{alice, bob}, opening, 20*time.Second, t0) + if err != nil { + t.Fatalf("New: %v", err) + } + return e +} + +func TestNewSeedsStateFromOpening(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + if got := e.Current(); got != "ngữ" { + t.Errorf("Current = %q, want %q", got, "ngữ") + } + if got := e.Turn(); got != alice { + t.Errorf("Turn = %q, want %q", got, alice) + } + if !e.Used("ngôn ngữ") { + t.Error("opening word is not marked used") + } + if got := e.ChainLength(); got != 1 { + t.Errorf("ChainLength = %d, want 1", got) + } +} + +func TestNewRejectsBadInput(t *testing.T) { + d := standardDict() + + if _, err := New(nil, []PlayerID{alice, bob}, "ngôn ngữ", time.Second, t0); err == nil { + t.Error("New accepted a nil dictionary") + } + if _, err := New(d, []PlayerID{alice}, "ngôn ngữ", time.Second, t0); err == nil { + t.Error("New accepted a single player") + } + if _, err := New(d, []PlayerID{alice, bob}, "ngôn ngữ", 0, t0); err == nil { + t.Error("New accepted a zero turn limit") + } + if _, err := New(d, []PlayerID{alice, bob}, "không tồn tại", time.Second, t0); err == nil { + t.Error("New accepted an opening word outside the dictionary") + } +} + +func TestSubmitAcceptsLegalMove(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + move, reason := e.Submit(alice, "ngữ pháp", t0) + if reason != ReasonNone { + t.Fatalf("Submit rejected a legal move: %s", reason) + } + if move.Word != "ngữ pháp" || move.First != "ngữ" || move.Last != "pháp" { + t.Errorf("move = %+v, want ngữ pháp / ngữ / pháp", move) + } + if got := e.Current(); got != "pháp" { + t.Errorf("Current = %q, want %q", got, "pháp") + } + if got := e.Turn(); got != bob { + t.Errorf("Turn = %q, want %q", got, bob) + } + if !e.Used("ngữ pháp") { + t.Error("accepted word is not marked used") + } +} + +// One test per rejection reason: a boolean would not tell a player what to fix. +func TestSubmitRejectionReasons(t *testing.T) { + tests := []struct { + name string + setup func(*Engine) + who PlayerID + word string + when time.Time + want RejectReason + }{ + { + name: "not your turn", + who: bob, word: "ngữ pháp", when: t0, + want: ReasonNotYourTurn, + }, + { + name: "single syllable", + who: alice, word: "ngữ", when: t0, + want: ReasonTooFewSyllables, + }, + { + name: "empty input", + who: alice, word: " ", when: t0, + want: ReasonTooFewSyllables, + }, + { + name: "unknown word", + who: alice, word: "ngữ xyzzy", when: t0, + want: ReasonNotInDictionary, + }, + { + name: "wrong link", + who: alice, word: "luật lệ", when: t0, + want: ReasonWrongLink, + }, + { + name: "turn expired", + who: alice, word: "ngữ pháp", when: t0.Add(21 * time.Second), + want: ReasonTimeout, + }, + } + + // ReasonAlreadyUsed needs a played-out board, so it has its own tests: + // TestSubmitBlocksReuseAcrossPlayers and TestSubmitBlocksReuseViaAlias. + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + if tc.setup != nil { + tc.setup(e) + } + if _, got := e.Submit(tc.who, tc.word, tc.when); got != tc.want { + t.Errorf("Submit = %s, want %s", got, tc.want) + } + }) + } +} + +// A word is spent for the whole game, not per player. +func TestSubmitBlocksReuseAcrossPlayers(t *testing.T) { + d := newDict( + "ngôn ngữ", + "ngữ pháp", + "pháp ngữ", // lets play return to "ngữ" + "ngữ điệu", + ) + e := newGame(t, d, "ngôn ngữ") + + if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone { + t.Fatalf("alice's move rejected: %s", r) + } + if _, r := e.Submit(bob, "pháp ngữ", t0); r != ReasonNone { + t.Fatalf("bob's move rejected: %s", r) + } + // Back on "ngữ": alice cannot replay the word she already used. + if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonAlreadyUsed { + t.Errorf("replaying a used word = %s, want %s", r, ReasonAlreadyUsed) + } +} + +// Canonicalization can move the first syllable, so the link must be checked +// against the canonical form. Checking the typed form rejects a legal move. +func TestSubmitAcceptsAliasWithFirstSyllableDrift(t *testing.T) { + d := newDict("ngôn ngữ", "ngữ pháp").alias("ngử pháp", "ngữ pháp") + e := newGame(t, d, "ngôn ngữ") + + move, reason := e.Submit(alice, "ngử pháp", t0) + if reason != ReasonNone { + t.Fatalf("alias with first-syllable drift rejected: %s", reason) + } + if move.Word != "ngữ pháp" { + t.Errorf("move.Word = %q, want the canonical %q", move.Word, "ngữ pháp") + } + if move.Typed != "ngử pháp" { + t.Errorf("move.Typed = %q, want what the player typed", move.Typed) + } + // The chain must continue from the canonical's last syllable. + if got := e.Current(); got != "pháp" { + t.Errorf("Current = %q, want %q", got, "pháp") + } +} + +// An alias must be spent along with its canonical: the same word cannot be +// played twice under two spellings. +func TestSubmitBlocksReuseViaAlias(t *testing.T) { + // "ngữ điệu" keeps a move available after play returns to "ngữ", so the + // game does not end on a dead end before the reuse can be attempted. + d := newDict("ngôn ngữ", "ngữ pháp", "pháp ngữ", "ngữ điệu").alias("ngử pháp", "ngữ pháp") + e := newGame(t, d, "ngôn ngữ") + + e.Submit(alice, "ngữ pháp", t0) + e.Submit(bob, "pháp ngữ", t0) + + if _, r := e.Submit(alice, "ngử pháp", t0); r != ReasonAlreadyUsed { + t.Errorf("replaying via an alias = %s, want %s", r, ReasonAlreadyUsed) + } +} + +func TestSubmitScoring(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ữ") + + // Spec: 10 + 2*chainLength + 5*(syllables-2), where chainLength counts the + // words already down, opening word included. Asserted as literals so the + // test pins the specified formula rather than whatever the code computes. + two, _ := e.Submit(alice, "ngữ pháp", t0) + if want := 12; two.Points != want { // 10 + 2*1 + 5*0 + t.Errorf("two-syllable word at chain 1 scored %d, want %d", two.Points, want) + } + + 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 := 24; four.Points != want { // 10 + 2*2 + 5*2 + t.Errorf("four-syllable word at chain 2 scored %d, want %d", four.Points, want) + } + if four.Points <= two.Points { + t.Error("longer word did not score more than a shorter one") + } +} + +func TestLegalMovesAndHasLegalMove(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + got := e.LegalMoves() + want := []string{"ngữ pháp", "ngữ điệu"} + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("LegalMoves = %v, want %v", got, want) + } + if !e.HasLegalMove() { + t.Error("HasLegalMove = false with moves available") + } + + // Once both continuations are spent there is nothing left from "ngữ". + e.Submit(alice, "ngữ pháp", t0) + if got := e.Current(); got != "pháp" { + t.Fatalf("Current = %q, want pháp", got) + } +} + +// The player handed a dead end loses, and the engine says so without waiting +// for a timer. +func TestNoLegalMoveEndsGame(t *testing.T) { + // "lệ" starts nothing, so whoever receives it cannot move. + d := newDict("ngôn ngữ", "ngữ luật", "luật lệ") + e := newGame(t, d, "ngôn ngữ") + + if _, r := e.Submit(alice, "ngữ luật", t0); r != ReasonNone { + t.Fatalf("alice's move rejected: %s", r) + } + if _, r := e.Submit(bob, "luật lệ", t0); r != ReasonNone { + t.Fatalf("bob's move rejected: %s", r) + } + + if !e.Over() { + t.Fatal("game not over after a move into a dead end") + } + if e.Winner() != bob { + t.Errorf("Winner = %q, want %q (alice has no move)", e.Winner(), bob) + } + if e.Snapshot().EndReason != EndNoLegalMove { + t.Errorf("EndReason = %s, want %s", e.Snapshot().EndReason, EndNoLegalMove) + } +} + +func TestIsExpiredBoundary(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + deadline := e.Deadline() + + if e.IsExpired(deadline.Add(-time.Nanosecond)) { + t.Error("expired just before the deadline") + } + if e.IsExpired(deadline) { + t.Error("expired exactly on the deadline") + } + if !e.IsExpired(deadline.Add(time.Nanosecond)) { + t.Error("not expired just after the deadline") + } +} + +func TestTimeoutAwardsOpponent(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + if e.Timeout(t0) { + t.Error("Timeout fired before the deadline") + } + if !e.Timeout(t0.Add(21 * time.Second)) { + t.Fatal("Timeout did not fire after the deadline") + } + if e.Winner() != bob { + t.Errorf("Winner = %q, want %q (alice ran out of time)", e.Winner(), bob) + } + if e.Timeout(t0.Add(30 * time.Second)) { + t.Error("Timeout fired twice") + } +} + +func TestResign(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + if !e.Resign(alice) { + t.Fatal("Resign returned false") + } + if e.Winner() != bob { + t.Errorf("Winner = %q, want %q", e.Winner(), bob) + } + if e.Snapshot().EndReason != EndResigned { + t.Errorf("EndReason = %s, want %s", e.Snapshot().EndReason, EndResigned) + } + if e.Resign(bob) { + t.Error("Resign succeeded on a finished game") + } +} + +// A finished game is not a turn-order problem, and the distinction reaches the +// player as copy. +func TestSubmitAfterGameOver(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + e.Resign(alice) + + if _, r := e.Submit(bob, "ngữ pháp", t0); r != ReasonGameOver { + t.Errorf("Submit after the game ended = %s, want %s", r, ReasonGameOver) + } +} + +// An opening whose last syllable starts nothing hands the first player a game +// they have already lost, with no move and no reason -- it would resolve only +// on the turn timer, reported as a timeout. New must refuse it. +func TestNewRejectsDeadEndOpening(t *testing.T) { + // "lệ" starts no word. + d := newDict("luật lệ", "ngôn ngữ", "ngữ pháp") + + _, err := New(d, []PlayerID{alice, bob}, "luật lệ", 20*time.Second, t0) + if err == nil { + t.Fatal("New accepted an opening that leaves the first player no move") + } + if !strings.Contains(err.Error(), "starts no other word") { + t.Errorf("error %q does not explain the dead end", err) + } +} + +func TestNewRejectsDuplicatePlayers(t *testing.T) { + if _, err := New(standardDict(), []PlayerID{alice, alice}, "ngôn ngữ", time.Second, t0); err == nil { + t.Error("New accepted the same player twice") + } +} + +func TestEndReasonStrings(t *testing.T) { + seen := map[string]bool{} + for _, r := range []EndReason{EndNone, EndTimeout, EndNoLegalMove, EndResigned} { + s := r.String() + if s == "" || s == "unknown" { + t.Errorf("EndReason(%d).String() = %q", r, s) + } + if seen[s] { + t.Errorf("duplicate description %q", s) + } + seen[s] = true + } + if got := EndReason(99).String(); got != "unknown" { + t.Errorf("unknown EndReason string = %q", got) + } +} + +// Move.Typed must carry what the player actually sent, so the UI can show that +// a correction happened. +func TestMoveRecordsRawInput(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + + const raw = " NGỮ Pháp " + move, r := e.Submit(alice, raw, t0) + if r != ReasonNone { + t.Fatalf("Submit rejected %q: %s", raw, r) + } + if move.Typed != raw { + t.Errorf("move.Typed = %q, want the raw input %q", move.Typed, raw) + } + if move.Word != "ngữ pháp" { + t.Errorf("move.Word = %q, want the canonical form", move.Word) + } +} + +// Snapshot must not hand out engine state. +func TestSnapshotIsACopy(t *testing.T) { + e := newGame(t, standardDict(), "ngôn ngữ") + e.Submit(alice, "ngữ pháp", t0) + + snap := e.Snapshot() + snap.History[0].Word = "MUTATED" + snap.Scores[alice] = 9999 + + fresh := e.Snapshot() + if fresh.History[0].Word == "MUTATED" { + t.Error("mutating a snapshot's history changed engine state") + } + if fresh.Scores[alice] == 9999 { + t.Error("mutating a snapshot's scores changed engine state") + } +} + +func TestRejectReasonStrings(t *testing.T) { + // Every reason needs a distinct, non-empty description: these become + // player-facing messages. + seen := map[string]bool{} + for _, r := range []RejectReason{ + ReasonNone, ReasonNotYourTurn, ReasonTooFewSyllables, + ReasonNotInDictionary, ReasonWrongLink, ReasonAlreadyUsed, ReasonTimeout, + } { + s := r.String() + if s == "" || s == "unknown" { + t.Errorf("RejectReason(%d).String() = %q", r, s) + } + if seen[s] { + t.Errorf("duplicate description %q", s) + } + seen[s] = true + } +} diff --git a/server/internal/game/imports_test.go b/server/internal/game/imports_test.go new file mode 100644 index 0000000..1b270b8 --- /dev/null +++ b/server/internal/game/imports_test.go @@ -0,0 +1,38 @@ +package game_test + +import ( + "os/exec" + "strings" + "testing" +) + +// The engine must stay transport-free: it speaks its own Go types and knows +// nothing about WebSockets or protobuf. That is a property worth enforcing +// rather than remembering, since the temptation to reach for a wire type from +// inside the rules is exactly how the two get welded together. +func TestEngineHasNoTransportDependencies(t *testing.T) { + out, err := exec.Command("go", "list", "-deps", "github.com/tiennm99dev/noitu/server/internal/game").Output() + if err != nil { + t.Skipf("go list unavailable: %v", err) + } + + banned := []string{ + "google.golang.org/protobuf", + "github.com/coder/websocket", + "net/http", + "github.com/tiennm99dev/noitu/server/internal/wsapi", + "github.com/tiennm99dev/noitu/server/gen", + // The rules layer must not reach for storage either: it takes a + // Dictionary interface so it can be tested without one. + "modernc.org/sqlite", + "database/sql", + } + + for _, dep := range strings.Fields(string(out)) { + for _, bad := range banned { + if dep == bad || strings.HasPrefix(dep, bad+"/") { + t.Errorf("internal/game depends on %q, which it must not", dep) + } + } + } +} diff --git a/server/internal/game/state.go b/server/internal/game/state.go new file mode 100644 index 0000000..584c689 --- /dev/null +++ b/server/internal/game/state.go @@ -0,0 +1,120 @@ +package game + +import ( + "iter" + "time" +) + +// PlayerID identifies a seat at the table. The engine never learns anything +// else about a player: no name, no connection, no session. +type PlayerID string + +// Dictionary is the slice of the word store the engine needs. +// +// An interface rather than *dictionary.Store so the engine can be tested +// against a hand-built word graph with no SQLite involved, and so the bot's +// search can be exercised on boards small enough to reason about. +type Dictionary interface { + // Resolve maps a normalized word to its canonical form. + Resolve(word string) (string, bool) + // FirstSyllable and LastSyllable report the ends of a canonical word. + // The engine must use these rather than splitting the player's input: + // canonicalization can move either end. + FirstSyllable(word string) (string, bool) + LastSyllable(word string) (string, bool) + // WordsStartingWith iterates the words that may follow a syllable. + WordsStartingWith(syllable string) iter.Seq[string] + // OutDegree reports how many words start with a syllable. + OutDegree(syllable string) (int, error) +} + +// RejectReason says why a submission was not accepted. Callers map these to +// player-facing messages, so each one has to be specific enough to act on. +type RejectReason int + +const ( + ReasonNone RejectReason = iota + ReasonNotYourTurn + ReasonTooFewSyllables + ReasonNotInDictionary + ReasonWrongLink + ReasonAlreadyUsed + ReasonTimeout + ReasonGameOver +) + +func (r RejectReason) String() string { + switch r { + case ReasonNone: + return "accepted" + case ReasonNotYourTurn: + return "not your turn" + case ReasonTooFewSyllables: + return "fewer than two syllables" + case ReasonNotInDictionary: + return "not in dictionary" + case ReasonWrongLink: + return "wrong first syllable" + case ReasonAlreadyUsed: + return "already used" + case ReasonTimeout: + return "turn expired" + case ReasonGameOver: + return "game already over" + } + return "unknown" +} + +// Move is one accepted word. +// +// Word is always the canonical spelling, which may differ from what the player +// typed. Typed records the raw input so the UI can show that a correction +// happened rather than silently replacing the player's text. +type Move struct { + Player PlayerID + Word string + Typed string + First string + Last string + Syllables int + Points int + At time.Time +} + +// EndReason says how a finished game ended. +type EndReason int + +const ( + EndNone EndReason = iota + EndTimeout + EndNoLegalMove + EndResigned +) + +func (r EndReason) String() string { + switch r { + case EndNone: + return "in play" + case EndTimeout: + return "timeout" + case EndNoLegalMove: + return "no legal move" + case EndResigned: + return "resigned" + } + return "unknown" +} + +// State is a snapshot for the transport layer to render. It copies everything +// it exposes, so a caller can hold it without touching engine state. +type State struct { + Current string + Turn PlayerID + Deadline time.Time + History []Move + Scores map[PlayerID]int + ChainLength int + Over bool + Winner PlayerID + EndReason EndReason +}