feat(game): add noi tu rules engine and bot opponent

The engine is transport-free: no sockets, no protobuf, and no clock of its
own. Callers pass the current time in and read the deadline back, so every
rule is testable without a timer. One goroutine owns a game.

Validation resolves the word before checking the chain link. Roughly a
third of dictionary aliases move the first syllable, so "sy hai" resolves
to "si hai"; checking the link against what the player typed would reject
legal moves. The used-word set is keyed on the canonical form, which also
stops the same word being played twice under two spellings.

An opening whose last syllable starts nothing is refused. It would hand
the first player a game already lost, with no move and no reason, that
resolves only when the turn timer expires and then reports a timeout.

Three bot difficulties, separated by how far they look ahead rather than
by how willing they are to win: random, one-ply greedy, and depth-limited
negamax with alpha-beta. Strategies see a read-only view of the board, so
a bot cannot bypass the same validation a human's move goes through.

Simulation on the real corpus, alternating sides across 60 games per
pairing: hard beats easy 98%, medium beats easy 87%, hard beats medium
65%. Decisions take at most 19ms against a 150ms budget.

Three defects surfaced only under simulation. Withholding the winning
move from the middle bot, as first designed, made it lose to the random
bot 97% of the time. The search evaluated leaves with an inverted sign,
so it hunted for positions where it was about to be trapped. And the
rate limit on taking an instant win did nothing, because declining the
shortcut let the search rediscover the same move.

Games against the hard bot end after about three moves versus fifteen
for two random bots: with 1,814 dead-end syllables an instant win is
usually available. Tunable, and flagged for playtesting.
This commit is contained in:
tiennm99 committed 2026-09-04 17:29:47 +07:00
1 parent e8b76cc643
commit ca01145d06
12 files changed
+2238 -18

No files matched your search

@@ -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
+141
View File
@@ -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
}
+282
View File
@@ -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)
}
}
+232
View File
@@ -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)
}
}
}
+249
View File
@@ -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)
}
}
}
+28
View File
@@ -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
}
+215
View File
@@ -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
}
+78
View File
@@ -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
}
+301
View File
@@ -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,
}
}
+496
View File
@@ -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
}
}
+38
View File
@@ -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)
}
}
}
}
+120
View File
@@ -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
}