Files
noitu/plans/260904-1125-noi-tu-web-game/phase-03-go-game-engine-and-bot-ai.md
T
tiennm99 48b3b3c8ac feat(proto): add protobuf wire contract and cross-language codegen
proto/noitu/v1/game.proto is the single source of truth for every WebSocket
message. buf generates Go types into server/gen and JavaScript types into
web/src/lib/proto; both trees are committed so building needs no codegen
toolchain.

The Go suite emits binary fixtures into proto/testdata and the JavaScript suite
decodes the same bytes, so the two generated clients are checked against one
artifact rather than against each other's assumptions. CI lints the schema,
rejects breaking changes against main, and fails when the committed generated
trees drift from the schema.

game.NumRejectReasons and game.NumEndReasons let the mapping tests prove every
engine reason has a wire value without guessing where the enum ends.
2026-09-05 12:06:58 +07:00

185 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Phase 3: Go Game Engine and Bot AI"
status: done
phase: 3
priority: P1
effort: "4d"
dependencies: [2]
---
# Phase 3: Go Game Engine and Bot AI
## Overview
Pure, transport-free game logic: rule validation, per-game state, win/loss resolution, and
the bot's move selection at three difficulties. No WebSocket, no protobuf, no timers driven
by wall clock — the engine takes a deadline as data so it stays fully unit-testable.
## Requirements
**Functional**
- [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**
- [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
The game is a **directed graph**: nodes = syllables, edges = words (`pháp ──"pháp luật"──► luật`).
A move traverses an unused edge from the current node. Formally this is *Directed Edge
Geography*, which is PSPACE-complete — so no perfect solver is attempted; the Hard bot uses
an instant-win check plus bounded search. Words of 3+ syllables are edges like any other —
they simply span extra syllables between `first` and `last`, so nothing in the graph model
changes.
```go
type RejectReason int
const (
ReasonNone RejectReason = iota
ReasonTooFewSyllables // fewer than vietnamese.MinSyllables (2)
ReasonWrongLink
ReasonNotInDictionary
ReasonAlreadyUsed
ReasonNotYourTurn
ReasonTimeout
)
type Engine struct {
dict *dictionary.Store
used map[string]struct{}
current string // syllable the next word must start with
turn PlayerID
deadline time.Time
history []Move
scores map[PlayerID]int
}
func New(dict *dictionary.Store, players []PlayerID, opening string, turnLimit time.Duration) (*Engine, error)
func (e *Engine) Submit(p PlayerID, raw string) (Move, RejectReason)
func (e *Engine) LegalMoves() ([]string, error) // for the player to act
func (e *Engine) HasLegalMove() (bool, error)
func (e *Engine) IsExpired(now time.Time) bool
func (e *Engine) Snapshot() State
```
**Validation order matters** — check cheapest and most-informative first so the player gets
the most useful message: turn → syllable count (≥2) → link → dictionary → reuse.
**Scoring and word length:** longer words are worth more, so allowing 3+ syllables adds a
reason to reach for them rather than being merely permissive. See the scoring formula below.
**Bot strategies** (`internal/bot`), all implementing one interface:
```go
type Strategy interface{ Choose(e *game.Engine) (string, error) }
```
| Difficulty | Behaviour |
|---|---|
| **Easy** | Uniform random legal move. Adds a 400-900ms simulated "thinking" pause so it feels human |
| **Medium** | Score each legal move by the out-degree of the syllable it hands the opponent; pick randomly among the lowest quartile. Never plays a guaranteed kill |
| **Hard** | 1) if any legal move lands on a syllable with 0 remaining continuations → play it (instant win). 2) else negamax with alpha-beta, depth 4, over the remaining edge set; eval = `-log(1 + opponent legal move count)`. 3) else fall back to Medium |
Search cost is bounded by move ordering (ascending opponent out-degree first) and a node
cap; the branching factor is the out-degree of visited syllables, typically < 50.
**Anti-frustration:** Hard plays the instant-win move only `hardKillRate` of the time
(a tunable constant, default 0.85). A bot that always wins is not a game.
**Scoring:** `10 + 2 × chainLength + 5 × (syllables − 2)` per accepted word, capped; final
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`
- Create: `server/internal/game/state.go` — `Move`, `State`, `PlayerID`, `RejectReason`
- Create: `server/internal/game/engine_test.go`
- Create: `server/internal/bot/bot.go` — `Strategy` interface, difficulty registry, thinking delay
- Create: `server/internal/bot/strategy_easy.go`
- Create: `server/internal/bot/strategy_medium.go`
- Create: `server/internal/bot/strategy_hard.go`
- Create: `server/internal/bot/bot_test.go`
- Create: `server/internal/bot/simulate_test.go` — 100-game difficulty ladder assertion
## Implementation Steps
1. `state.go`: `PlayerID`, `Move{Player, Word, First, Last, Syllables, Points, At}`, `RejectReason` with `String()`, `State` snapshot struct.
2. `engine.New`: seed `used` with the opening word, set `current` to its last syllable, assign first turn.
3. `Submit`: normalize via `vietnamese.Normalize`, then run the validation order above; on success record the move, add to `used`, advance `current` and `turn`, reset deadline, add points.
4. `LegalMoves`: `dict.WordsStartingWith(current)` minus `used`. `HasLegalMove` short-circuits on the first hit rather than materializing the slice.
5. Engine unit tests, one per rejection reason, plus: reuse blocked across both players, chain advances correctly, no-legal-move detection, `IsExpired` boundary.
6. `bot.Strategy` interface + registry keyed by difficulty; shared randomized thinking delay helper.
7. Easy: uniform pick. Medium: out-degree-of-result scoring, lowest-quartile random pick, explicit guard that skips a 0-out-degree kill.
8. Hard: instant-win scan gated by `hardKillRate`; negamax + alpha-beta depth 4 with move ordering and node cap; Medium fallback.
9. `simulate_test.go`: 100 headless bot-vs-bot games per pairing; assert Hard beats Easy well above chance and Medium sits between. Deterministic via a seeded RNG.
10. Benchmark Hard's `Choose` to confirm ≤150ms on the real DB.
## Success Criteria
- [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
| Risk | Signal | Response |
|---|---|---|
| Depth-4 search too slow on hub syllables (high out-degree) | Benchmark exceeds 150ms | Node cap already present; reduce to depth 3 for syllables with out-degree > 200 — the depth is a tunable constant |
| Hard bot feels unbeatable | Playtest win rate ≈ 0% | Lower `hardKillRate`; it exists precisely for this dial |
| Bot picks obscure words that feel unfair | Playtest complaints | Out of scope for v1 (no frequency data in the derived DB); note as a post-v1 item requiring a frequency column |
| Engine mutated from two goroutines | `-race` failures once rooms land in phase 5 | Ownership rule documented here and enforced in phase 5: exactly one goroutine per room owns its engine |