diff --git a/.gitea/workflows/go-unit.yaml b/.gitea/workflows/go-unit.yaml index 739a093..6d8833d 100644 --- a/.gitea/workflows/go-unit.yaml +++ b/.gitea/workflows/go-unit.yaml @@ -31,6 +31,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Fetch scrabble-solver (sibling) + # The engine package consumes scrabble-solver in-process; go.work points + # its bare module path at this sibling checkout. The repository is public, + # so the clone needs no credentials. It tracks master HEAD (see PLAN.md + # TODO-1 for the move to a published, versioned module). + run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver" + - name: Set up Go uses: actions/setup-go@v5 with: @@ -52,5 +59,8 @@ jobs: - name: test # -count=1 disables the test cache so a green run never depends on a - # previous runner's cached state. + # previous runner's cached state. BACKEND_DICT_DIR points the engine + # tests at the committed DAWGs in the sibling checkout. + env: + BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg run: go test -count=1 ./backend/... diff --git a/.gitea/workflows/integration.yaml b/.gitea/workflows/integration.yaml index dde47b6..9b9274f 100644 --- a/.gitea/workflows/integration.yaml +++ b/.gitea/workflows/integration.yaml @@ -37,6 +37,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Fetch scrabble-solver (sibling) + # The backend now imports the engine package, which consumes + # scrabble-solver in-process; go.work points its bare module path at this + # sibling checkout. The repository is public, so the clone needs no + # credentials. It tracks master HEAD (see PLAN.md TODO-1). + run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver" + - name: Set up Go uses: actions/setup-go@v5 with: @@ -46,5 +53,8 @@ jobs: - name: Integration tests # -count=1 disables the test cache; -p=1 -parallel=1 keeps the # container-backed tests serial; the 15-minute timeout bounds a stuck - # container pull. + # container pull. The engine package's (untagged) tests also compile and + # run here, so BACKEND_DICT_DIR points them at the committed DAWGs. + env: + BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/... diff --git a/PLAN.md b/PLAN.md index fe0abeb..778ccdb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -35,7 +35,7 @@ independent (see ARCHITECTURE §9.1). |---|-------|--------| | 0 | Scaffolding (go.work, backend skeleton, docs, CI) | **done** | | 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | **done** | -| 2 | Engine package over scrabble-solver | todo | +| 2 | Engine package over scrabble-solver | **done** | | 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | todo | | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | todo | | 5 | Robot opponent | todo | @@ -173,3 +173,59 @@ Open details: deployment target/host; dashboards; load expectations. at boot** (migrations at startup) — a deliberate contract change from Stage 0, documented in both READMEs. All code stays in the existing `backend` module under `internal/` (+ `cmd/jetgen`); `go.work` untouched. +- **Stage 2** (interview + implementation): + - Scope: `internal/engine` is a self-contained **library** (registry, bag, + `Game` state machine, decode/replay). No `config`/`main`/`server` wiring this + stage — there is no consumer yet; wiring lands in **Stage 3**, mirroring + Stage 1's deferred handlers. + - **Pure rules engine** (interview): the engine owns the in-memory `Game`, + pure transitions (play/pass/exchange/resign + draw) **and end-condition + detection**, including the standard **end-game rack-adjustment scoring** — a + deliberate slice of Stage 3's "scoring/end-conditions" that the pure-engine + boundary implies. Stage 3 keeps scheduling, the 24h timeout, persistence and + GCG. + - **Solver wiring**: `replace scrabble-solver => ../scrabble-solver` in + `go.work`; `backend/go.mod` requires `scrabble-solver` (placeholder version, + redirected by the replace) and `github.com/iliadenisov/dafsa` directly (for + `dawg.Load`). CI clones the **public** solver repo at **master HEAD** + anonymously into `../scrabble-solver` (no token); both Go workflows gained + the step (the engine's untagged tests run under the integration workflow too) + and set `BACKEND_DICT_DIR`. + - **Dictionaries**: registry loads the committed DAWGs from a directory + parameter; `dict_version` is an explicit string label; the latest version + per variant is tracked. Smoke tests validate a known word per variant + (English/Russian/Эрудит). **Эрудит is handled uniformly** — every real + difference is already in `rules.Erudit()`; the move.go "single orientation + per turn" note needs no special code (any single play is one-directional). + - **Bag/blanks/exchange**: own deterministic `Bag` (Draw + Return) because + `selfplay.Bag` cannot return tiles; exchange is legal only when the bag holds + at least a rack and draws replacements before returning the swapped tiles. A + blank is `Placement{Blank:true}` carrying its designated letter; the history + keeps the concrete letter plus a blank flag (decoded via `Alphabet.Character` + / `Decode`). `ReplayBoard` reuses `scrabble.Apply`, so no `internal/encoding` + dependency. + - **Deviation from the approved plan**: `docs/FUNCTIONAL.md` (+`_ru`) was left + unchanged. Stage 2 adds no user-visible behaviour; the variant, per-game + dictionary and dictionary-independent-history user stories already live in + Stages 3–4, so a "light touch" here would have duplicated or pre-empted them. + +## Deferred TODOs (cross-stage) + +- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, + give it a real module URL and switch `backend` to a versioned dependency, + dropping the `go.work` replace and the CI clone. Removes the floating + `master` dependency accepted for now (Stage 2 interview). +- **TODO-2 — split the solver into engine vs dictionary generator + versioned + dictionary artifacts.** Owner's idea, with the caveats agreed at the Stage 2 + interview: the split is sound (build-time wordlist→DAWG vs runtime load have + different lifecycles and shrink the runtime dependency surface), **but** the + generator must pin the **same** `dafsa`/`alphabet` versions and alphabet + definitions as the runtime engine or the on-disk format / letter indexing + drifts and silently corrupts validation. For delivery prefer **Git LFS or an + artifact store** (Gitea releases / OCI artifact / object storage) over a raw + git submodule (the ~0.5–0.7 MB DAWGs are regenerated wholesale and bloat git + history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull + is a **deploy-time** way to populate the directory, **not** the runtime + dynamic-reload mechanism (Stage 9) — keep the `BACKEND_DICT_DIR` directory as + the runtime contract: a new `.dawg` appears in it and is loaded with + `dawg.Load`. diff --git a/backend/README.md b/backend/README.md index fcd8053..3ff4ab7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,6 +12,12 @@ and the durable accounts / identities / sessions data model. The session and account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the store/service layer they will call. +Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver` +library: a versioned dictionary registry, a deterministic tile bag, and a pure +rules `Game` (legal plays, passes, exchanges, resignations and end-condition +detection) that emits dictionary-independent move records. It is a library only; +the game domain wires it into the server in Stage 3. + ## Package layout ``` @@ -25,6 +31,7 @@ internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations internal/account/ # durable accounts + platform/email identities (store) internal/session/ # opaque tokens, sessions store, write-through cache, service internal/server/ # gin engine, route groups, X-User-ID middleware, probes +internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay ``` ## Configuration (environment) @@ -64,6 +71,22 @@ regenerate the committed go-jet code (needs Docker): go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp container ``` +## Engine & dictionaries + +`internal/engine` consumes the sibling `scrabble-solver` module in-process. Its +bare module path (`scrabble-solver`, not a URL) cannot be fetched via VCS, so the +workspace `go.work` carries `replace scrabble-solver => ../scrabble-solver` and +the build must run from the repository root (the workspace), not from this module +in isolation. `github.com/iliadenisov/dafsa` (the DAWG loader) is a direct +dependency. CI clones the public solver repository into `../scrabble-solver` +before building (see `.gitea/workflows/`); locally, check it out next to this +repository. Committed dictionaries (`en_sowpods.dawg`, `ru_scrabble.dawg`, +`ru_erudit.dawg`) live in the solver's `dawg/` directory; the engine loads them +by `(variant, dict_version)` from a directory path. A configurable +`BACKEND_DICT_DIR` is wired when the first consumer needs it (Stage 3); the +future versioned-artifact direction is recorded in [`../PLAN.md`](../PLAN.md) +TODO-2. + ## Tests ```sh @@ -73,4 +96,6 @@ go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker) Integration tests are guarded by the `integration` build tag and run against a throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent -rather than skipping. +rather than skipping. The `internal/engine` tests load the committed DAWGs from +`BACKEND_DICT_DIR` (defaulting to the sibling `../scrabble-solver/dawg`) and fail +loudly when that directory is absent. diff --git a/backend/go.mod b/backend/go.mod index 143fa55..d92f02b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/go-jet/jet/v2 v2.14.1 github.com/google/uuid v1.6.0 + github.com/iliadenisov/dafsa v1.1.0 github.com/jackc/pgx/v5 v5.9.2 github.com/pressly/goose/v3 v3.27.1 github.com/testcontainers/testcontainers-go v0.42.0 @@ -19,6 +20,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/zap v1.27.1 + scrabble-solver v0.0.0-00010101000000-000000000000 ) require ( diff --git a/backend/internal/engine/bag.go b/backend/internal/engine/bag.go new file mode 100644 index 0000000..e4743c3 --- /dev/null +++ b/backend/internal/engine/bag.go @@ -0,0 +1,68 @@ +package engine + +import ( + "math/rand" + + "scrabble-solver/rules" +) + +// blankTile marks a blank tile in a hand or in the bag, matching the +// scrabble-solver convention (selfplay) so a hand of these bytes interoperates +// with the solver's rack helpers. +const blankTile byte = 0xff + +// Bag is the shuffled draw pile for one game. Unlike the solver's self-play bag +// it supports returning tiles, which an exchange needs. It is seeded once, so a +// game's draws are reproducible from its seed and the sequence of operations. +// Bag is not safe for concurrent use; the owning Game serialises access. +type Bag struct { + tiles []byte + rng *rand.Rand +} + +// NewBag fills a bag from the ruleset's tile counts and blanks and shuffles it +// with seed. Letters are stored as alphabet-index bytes and blanks as blankTile. +func NewBag(rs *rules.Ruleset, seed int64) *Bag { + var tiles []byte + for i, n := range rs.Counts { + for range n { + tiles = append(tiles, byte(i)) + } + } + for range rs.Blanks { + tiles = append(tiles, blankTile) + } + b := &Bag{tiles: tiles, rng: rand.New(rand.NewSource(seed))} + b.shuffle() + return b +} + +// Len returns the number of tiles left in the bag. +func (b *Bag) Len() int { return len(b.tiles) } + +// Draw removes up to n tiles from the bag and returns them in a fresh slice. +// Drawing more than remain returns all of them; drawing from an empty bag +// returns an empty slice. +func (b *Bag) Draw(n int) []byte { + if n > len(b.tiles) { + n = len(b.tiles) + } + out := make([]byte, n) + copy(out, b.tiles[len(b.tiles)-n:]) + b.tiles = b.tiles[:len(b.tiles)-n] + return out +} + +// Return puts tiles back into the bag and reshuffles, as when a player exchanges +// tiles. The tiles must use the same encoding as Draw (alphabet indices and +// blankTile). +func (b *Bag) Return(tiles []byte) { + b.tiles = append(b.tiles, tiles...) + b.shuffle() +} + +// shuffle randomises the remaining tiles with the bag's own RNG, keeping draws +// deterministic for a given seed and sequence of operations. +func (b *Bag) shuffle() { + b.rng.Shuffle(len(b.tiles), func(i, j int) { b.tiles[i], b.tiles[j] = b.tiles[j], b.tiles[i] }) +} diff --git a/backend/internal/engine/bag_test.go b/backend/internal/engine/bag_test.go new file mode 100644 index 0000000..418670f --- /dev/null +++ b/backend/internal/engine/bag_test.go @@ -0,0 +1,78 @@ +package engine + +import ( + "maps" + "slices" + "testing" + + "scrabble-solver/rules" +) + +// allTiles returns the full multiset of tiles a bag is filled from, in ruleset +// order (letters then blanks). +func allTiles(rs *rules.Ruleset) []byte { + var ts []byte + for i, n := range rs.Counts { + for range n { + ts = append(ts, byte(i)) + } + } + for range rs.Blanks { + ts = append(ts, blankTile) + } + return ts +} + +// TestBagDeterministic checks that two bags with the same seed draw identically. +func TestBagDeterministic(t *testing.T) { + rs := rules.English() + a, b := NewBag(rs, 42), NewBag(rs, 42) + if a.Len() != b.Len() { + t.Fatalf("len mismatch: %d vs %d", a.Len(), b.Len()) + } + for a.Len() > 0 { + if da, db := a.Draw(3), b.Draw(3); !slices.Equal(da, db) { + t.Fatalf("same seed drew differently: %v vs %v", da, db) + } + } +} + +// TestBagReturnConservesMultiset checks Len accounting and that Return puts the +// exact tiles back, leaving the bag's multiset unchanged. +func TestBagReturnConservesMultiset(t *testing.T) { + rs := rules.English() + want := tileCounts(allTiles(rs)) + total := len(allTiles(rs)) + + b := NewBag(rs, 7) + if b.Len() != total { + t.Fatalf("new bag len = %d, want %d", b.Len(), total) + } + drawn := b.Draw(rs.RackSize) + if b.Len() != total-rs.RackSize { + t.Fatalf("after draw len = %d, want %d", b.Len(), total-rs.RackSize) + } + b.Return(drawn) + if b.Len() != total { + t.Fatalf("after return len = %d, want %d", b.Len(), total) + } + if got := tileCounts(b.Draw(b.Len())); !maps.Equal(got, want) { + t.Fatalf("multiset changed across draw/return") + } +} + +// TestBagDrawAll returns everything once the bag is exhausted and never panics. +func TestBagDrawAll(t *testing.T) { + rs := rules.English() + b := NewBag(rs, 1) + all := b.Draw(b.Len() + 10) // asking for more than present returns all + if len(all) != len(allTiles(rs)) { + t.Fatalf("drew %d, want %d", len(all), len(allTiles(rs))) + } + if b.Len() != 0 { + t.Fatalf("bag len = %d, want 0", b.Len()) + } + if got := b.Draw(1); len(got) != 0 { + t.Fatalf("draw from empty bag returned %d tiles", len(got)) + } +} diff --git a/backend/internal/engine/decode.go b/backend/internal/engine/decode.go new file mode 100644 index 0000000..c2dadc0 --- /dev/null +++ b/backend/internal/engine/decode.go @@ -0,0 +1,134 @@ +package engine + +import ( + "fmt" + + "scrabble-solver/board" + "scrabble-solver/rules" + "scrabble-solver/scrabble" +) + +// ActionKind classifies a turn in the move log. +type ActionKind uint8 + +const ( + // ActionPlay is a tile placement forming one or more words. + ActionPlay ActionKind = iota + // ActionPass is a forfeited turn. + ActionPass + // ActionExchange swaps tiles with the bag. + ActionExchange + // ActionResign abandons the game. + ActionResign + // ActionTimeout is the auto-resignation a missed turn becomes; recorded by + // the game domain in a later stage, never produced by the engine itself. + ActionTimeout +) + +// String renders the action kind for logs and GCG export. +func (a ActionKind) String() string { + switch a { + case ActionPlay: + return "play" + case ActionPass: + return "pass" + case ActionExchange: + return "exchange" + case ActionResign: + return "resign" + case ActionTimeout: + return "timeout" + } + return "unknown" +} + +// TileRecord is a single placed tile decoded to a concrete letter, so the move +// log is independent of any dictionary and of the solver's internal encoding. +type TileRecord struct { + Row, Col int + // Letter is the concrete character placed; for a blank, the letter it stands + // for. + Letter string + // Blank reports whether the tile was placed from a blank (and so scored 0). + Blank bool +} + +// MoveRecord is one turn in the dictionary-independent history. A play carries +// the placed tiles, the words it formed and its score; a pass or resignation +// carries only the action; an exchange carries the number of tiles swapped. The +// game domain adds timestamps and persistence around these values. +type MoveRecord struct { + Player int + Action ActionKind + Tiles []TileRecord // ActionPlay only + Words []string // ActionPlay only: the main word first, then cross words + Count int // ActionExchange only: number of tiles swapped + Score int // points scored this turn (0 for non-plays) + Total int // the player's running total after this turn +} + +// recordPlay decodes a scored move into a dictionary-independent MoveRecord for +// the given player. +func (g *Game) recordPlay(player int, m scrabble.Move) MoveRecord { + tiles := make([]TileRecord, len(m.Tiles)) + for i, p := range m.Tiles { + tiles[i] = TileRecord{Row: p.Row, Col: p.Col, Letter: g.letter(p.Letter), Blank: p.Blank} + } + words := make([]string, 0, 1+len(m.Cross)) + words = append(words, g.word(m.Main)) + for _, cw := range m.Cross { + words = append(words, g.word(cw)) + } + return MoveRecord{ + Player: player, + Action: ActionPlay, + Tiles: tiles, + Words: words, + Score: m.Score, + Total: g.scores[player], + } +} + +// letter decodes one alphabet index to its concrete character via the ruleset's +// alphabet. A malformed index yields the empty string. +func (g *Game) letter(idx byte) string { + s, err := g.rules.Alphabet.Character(idx) + if err != nil { + return "" + } + return s +} + +// word decodes a solver word's letters to a concrete string via the ruleset's +// alphabet. A malformed word yields the empty string. +func (g *Game) word(w scrabble.Word) string { + s, err := g.rules.Alphabet.Decode(w.Letters) + if err != nil { + return "" + } + return s +} + +// ReplayBoard reconstructs the board the play records produce, on an empty board +// for ruleset rs, using only the alphabet and never a dictionary: each recorded +// letter is re-indexed and the placements are applied. Non-play records are +// ignored. It realises the history invariant in docs/ARCHITECTURE.md §9.1 — an +// archived game replays from decoded values plus its variant metadata alone. +func ReplayBoard(rs *rules.Ruleset, records []MoveRecord) (*board.Board, error) { + b := board.New(rs.Rows, rs.Cols) + for _, rec := range records { + if rec.Action != ActionPlay { + continue + } + placements := make([]scrabble.Placement, len(rec.Tiles)) + for i, t := range rec.Tiles { + idx, err := rs.Alphabet.Index(t.Letter) + if err != nil { + return nil, fmt.Errorf("engine: replay letter %q at (%d,%d): %w", t.Letter, t.Row, t.Col, err) + } + placements[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank} + } + scrabble.Apply(b, scrabble.Move{Tiles: placements}) + } + return b, nil +} diff --git a/backend/internal/engine/decode_test.go b/backend/internal/engine/decode_test.go new file mode 100644 index 0000000..a8bade9 --- /dev/null +++ b/backend/internal/engine/decode_test.go @@ -0,0 +1,68 @@ +package engine + +import ( + "testing" + + "scrabble-solver/scrabble" +) + +// blankCellFlag is the bit board cells set for a blank tile (board.go encoding). +const blankCellFlag byte = 0x80 + +// TestDecodeBlankPlayAndReplay places "cat" with the C drawn from a blank, then +// checks the decoded record keeps the concrete letter and the blank flag, and +// that ReplayBoard — using only the ruleset, no dictionary — reproduces the +// blank on the board. +func TestDecodeBlankPlayAndReplay(t *testing.T) { + g := newEnglishGame(t, 1) + rs := g.rules + row, col := centre(rs) + + idx := func(s string) byte { + t.Helper() + i, err := rs.Alphabet.Index(s) + if err != nil { + t.Fatalf("index %q: %v", s, err) + } + return i + } + ps := []scrabble.Placement{ + {Row: row, Col: col, Letter: idx("c"), Blank: true}, + {Row: row, Col: col + 1, Letter: idx("a")}, + {Row: row, Col: col + 2, Letter: idx("t")}, + } + + move, err := g.solver.ValidatePlay(g.board, scrabble.Horizontal, ps) + if err != nil { + t.Fatalf("validate: %v", err) + } + rec := g.recordPlay(0, move) + + if rec.Action != ActionPlay || len(rec.Tiles) != 3 { + t.Fatalf("record = %+v, want a 3-tile play", rec) + } + if blank := rec.Tiles[0]; blank.Letter != "c" || !blank.Blank { + t.Errorf("blank tile = %+v, want letter \"c\" with Blank=true", blank) + } + if rec.Tiles[1].Blank || rec.Tiles[1].Letter != "a" { + t.Errorf("second tile = %+v, want plain \"a\"", rec.Tiles[1]) + } + if len(rec.Words) == 0 || rec.Words[0] != "cat" { + t.Errorf("words = %v, want main word \"cat\"", rec.Words) + } + + rs2, err := Ruleset(VariantEnglish) + if err != nil { + t.Fatalf("ruleset: %v", err) + } + b, err := ReplayBoard(rs2, []MoveRecord{rec}) + if err != nil { + t.Fatalf("replay: %v", err) + } + if b.At(row, col)&blankCellFlag == 0 { + t.Error("replayed centre cell lost its blank flag") + } + if !b.Filled(row, col+1) || b.At(row, col+1)&blankCellFlag != 0 { + t.Error("replayed \"a\" cell should be a filled, non-blank tile") + } +} diff --git a/backend/internal/engine/engine.go b/backend/internal/engine/engine.go new file mode 100644 index 0000000..49d75bf --- /dev/null +++ b/backend/internal/engine/engine.go @@ -0,0 +1,101 @@ +// Package engine is the backend's in-process bridge to the scrabble-solver +// library. It catalogues the playable variants, loads versioned dictionaries +// into a registry of solvers, and exposes a pure rules engine (the in-memory +// Game) that drives a match through legal plays, passes, exchanges and +// resignations while detecting the end of the game. +// +// Two invariants shape the package. First, the solver speaks alphabet-index +// bytes that are meaningful only alongside the matching ruleset; every value +// that leaves the engine for persistence or display is decoded to concrete +// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games +// replay independently of any dictionary. Second, the engine owns rules and +// scoring only: turn scheduling, the 24-hour timeout, persistence and transport +// belong to the game domain in a later stage. +package engine + +import ( + "errors" + "fmt" + + "scrabble-solver/rules" +) + +// Variant identifies a Scrabble variant the backend offers. Each maps to a +// scrabble-solver ruleset and a committed dictionary. +type Variant uint8 + +const ( + // VariantEnglish is standard English Scrabble (the SOWPODS dictionary). + VariantEnglish Variant = iota + // VariantRussianScrabble is Russian Scrabble. + VariantRussianScrabble + // VariantErudit is the Russian "Эрудит" variant. + VariantErudit +) + +// String returns the variant's stable identifier, used in logs and as a metadata +// label on persisted games. +func (v Variant) String() string { + switch v { + case VariantEnglish: + return "english" + case VariantRussianScrabble: + return "russian_scrabble" + case VariantErudit: + return "erudit" + } + return "unknown" +} + +// ruleset returns the scrabble-solver ruleset backing the variant and true, or +// (nil, false) for an unrecognised variant. +func (v Variant) ruleset() (*rules.Ruleset, bool) { + switch v { + case VariantEnglish: + return rules.English(), true + case VariantRussianScrabble: + return rules.RussianScrabble(), true + case VariantErudit: + return rules.Erudit(), true + } + return nil, false +} + +// Variants returns the variants the backend offers, in catalogue order. +func Variants() []Variant { + return []Variant{VariantEnglish, VariantRussianScrabble, VariantErudit} +} + +// Ruleset returns the scrabble-solver ruleset for variant. It needs no +// dictionary, so it supports dictionary-independent board replay (see +// ReplayBoard) from a finished game's variant metadata alone. +func Ruleset(v Variant) (*rules.Ruleset, error) { + rs, ok := v.ruleset() + if !ok { + return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v) + } + return rs, nil +} + +// Sentinel errors returned across the engine. Callers match them with +// errors.Is; the wrapped detail carries the offending value. +var ( + // ErrUnknownVariant is returned for a variant the engine does not recognise. + ErrUnknownVariant = errors.New("engine: unknown variant") + // ErrUnknownVersion is returned when no dictionary is registered for a + // (variant, version) pair. + ErrUnknownVersion = errors.New("engine: unknown dictionary version") + // ErrIllegalPlay wraps a solver validation failure: off-board geometry, a + // word absent from the dictionary, or a play that does not connect. + ErrIllegalPlay = errors.New("engine: illegal play") + // ErrTilesNotOnRack is returned when a play or exchange references tiles the + // acting player does not hold. + ErrTilesNotOnRack = errors.New("engine: tiles not on the player's rack") + // ErrNotEnoughTilesToExchange is returned when an exchange is attempted while + // the bag holds fewer tiles than a full rack. + ErrNotEnoughTilesToExchange = errors.New("engine: not enough tiles in the bag to exchange") + // ErrNothingToExchange is returned for an exchange of zero tiles. + ErrNothingToExchange = errors.New("engine: exchange requires at least one tile") + // ErrGameOver is returned when a transition is attempted on a finished game. + ErrGameOver = errors.New("engine: game is over") +) diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go new file mode 100644 index 0000000..3970d9e --- /dev/null +++ b/backend/internal/engine/game.go @@ -0,0 +1,423 @@ +package engine + +import ( + "fmt" + + "scrabble-solver/board" + "scrabble-solver/rack" + "scrabble-solver/rules" + "scrabble-solver/scrabble" +) + +// scorelessLimit is the number of consecutive scoreless turns (passes and +// exchanges) that ends a game, per docs/ARCHITECTURE.md §6. +const scorelessLimit = 6 + +// EndReason explains why a game finished. +type EndReason uint8 + +const ( + // EndNotOver marks a game still in progress. + EndNotOver EndReason = iota + // EndOutOfTiles fires when the bag is empty and a player empties their rack. + EndOutOfTiles + // EndScoreless fires after scorelessLimit consecutive passes/exchanges. + EndScoreless + // EndResign fires when a player resigns. + EndResign +) + +// String renders the end reason for logs and diagnostics. +func (r EndReason) String() string { + switch r { + case EndNotOver: + return "not_over" + case EndOutOfTiles: + return "out_of_tiles" + case EndScoreless: + return "scoreless" + case EndResign: + return "resign" + } + return "unknown" +} + +// Options configures a new game. +type Options struct { + // Variant selects the rules and dictionary. + Variant Variant + // Version pins the dictionary version; empty selects the registry's latest. + Version string + // Players is the number of seats, 2 to 4. + Players int + // Seed seeds the tile bag, making the game reproducible. + Seed int64 +} + +// Game is the in-memory state of a single match and the pure rules engine over +// it. It owns the board, the bag, each player's hand, the running scores, whose +// turn it is and the decoded move log, and it detects the end of the game. It +// performs no scheduling, persistence or I/O and is not safe for concurrent use. +type Game struct { + solver *scrabble.Solver + rules *rules.Ruleset + variant Variant + version string + + board *board.Board + bag *Bag + hands [][]byte // per player, alphabet-index bytes with blankTile for blanks + scores []int + toMove int + scorelessRun int + over bool + reason EndReason + log []MoveRecord +} + +// New starts a game described by opts over a dictionary from reg. It resolves +// the solver (failing with ErrUnknownVariant/ErrUnknownVersion), builds an empty +// board and a seeded bag, and deals each player a full rack. +func New(reg *Registry, opts Options) (*Game, error) { + if opts.Players < 2 || opts.Players > 4 { + return nil, fmt.Errorf("engine: players must be between 2 and 4, got %d", opts.Players) + } + var ( + solver *scrabble.Solver + version = opts.Version + err error + ) + if version == "" { + version, solver, err = reg.Latest(opts.Variant) + } else { + solver, err = reg.Solver(opts.Variant, version) + } + if err != nil { + return nil, err + } + + rs := solver.Rules() + g := &Game{ + solver: solver, + rules: rs, + variant: opts.Variant, + version: version, + board: board.New(rs.Rows, rs.Cols), + bag: NewBag(rs, opts.Seed), + hands: make([][]byte, opts.Players), + scores: make([]int, opts.Players), + } + for i := range g.hands { + g.hands[i] = g.bag.Draw(rs.RackSize) + } + return g, nil +} + +// Play validates and applies the current player's placement of tiles forming a +// word in direction dir. It scores the play, refills the rack from the bag, +// advances the turn and may end the game. It returns ErrTilesNotOnRack when the +// player does not hold the tiles, ErrIllegalPlay when the solver rejects the +// play, and ErrGameOver on a finished game. +func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRecord, error) { + if g.over { + return MoveRecord{}, ErrGameOver + } + player := g.toMove + if err := g.checkHolds(player, placementTiles(tiles)); err != nil { + return MoveRecord{}, err + } + move, err := g.solver.ValidatePlay(g.board, dir, tiles) + if err != nil { + return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) + } + + scrabble.Apply(g.board, move) + g.removeFromHand(player, placementTiles(tiles)) + g.scores[player] += move.Score + g.refill(player) + g.scorelessRun = 0 + + rec := g.recordPlay(player, move) + g.log = append(g.log, rec) + + if len(g.hands[player]) == 0 && g.bag.Len() == 0 { + g.finish(EndOutOfTiles) + } else { + g.advance() + } + return rec, nil +} + +// Pass forfeits the current player's turn. It extends the scoreless run, which +// may end the game (EndScoreless), and otherwise advances the turn. +func (g *Game) Pass() (MoveRecord, error) { + if g.over { + return MoveRecord{}, ErrGameOver + } + player := g.toMove + g.scorelessRun++ + rec := MoveRecord{Player: player, Action: ActionPass, Total: g.scores[player]} + g.log = append(g.log, rec) + g.endTurnAfterScoreless() + return rec, nil +} + +// Exchange swaps the current player's tiles (alphabet-index bytes, blankTile for +// blanks) for fresh ones. It is legal only while the bag holds at least a full +// rack. The fresh tiles are drawn before the swapped ones return, so a player +// cannot draw back their own tiles. It extends the scoreless run, which may end +// the game (EndScoreless). +func (g *Game) Exchange(tiles []byte) (MoveRecord, error) { + if g.over { + return MoveRecord{}, ErrGameOver + } + if len(tiles) == 0 { + return MoveRecord{}, ErrNothingToExchange + } + if g.bag.Len() < g.rules.RackSize { + return MoveRecord{}, ErrNotEnoughTilesToExchange + } + player := g.toMove + if err := g.checkHolds(player, tiles); err != nil { + return MoveRecord{}, err + } + + g.removeFromHand(player, tiles) + g.hands[player] = append(g.hands[player], g.bag.Draw(len(tiles))...) + g.bag.Return(tiles) + g.scorelessRun++ + + rec := MoveRecord{Player: player, Action: ActionExchange, Count: len(tiles), Total: g.scores[player]} + g.log = append(g.log, rec) + g.endTurnAfterScoreless() + return rec, nil +} + +// Resign ends the game on the current player's turn (EndReason EndResign). In a +// two-player match this is the only resignation case; richer multi-player +// handling belongs to the game domain in a later stage. +func (g *Game) Resign() (MoveRecord, error) { + if g.over { + return MoveRecord{}, ErrGameOver + } + player := g.toMove + rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]} + g.log = append(g.log, rec) + g.finish(EndResign) + return rec, nil +} + +// GenerateMoves returns every legal play for the current player's rack, ranked +// by descending score. It is empty when the player has no legal play. +func (g *Game) GenerateMoves() []scrabble.Move { + return g.solver.GenerateMoves(g.board, g.rackOf(g.toMove), scrabble.Both) +} + +// Hint returns the highest-scoring legal play for the current player and true, +// or the zero move and false when there is none. It is the top-1 move the +// one-per-game hint reveals. +func (g *Game) Hint() (scrabble.Move, bool) { + moves := g.GenerateMoves() + if len(moves) == 0 { + return scrabble.Move{}, false + } + return moves[0], true +} + +// Variant returns the variant the game is played under. +func (g *Game) Variant() Variant { return g.variant } + +// Version returns the pinned dictionary version. +func (g *Game) Version() string { return g.version } + +// Players returns the number of seats in the game. +func (g *Game) Players() int { return len(g.hands) } + +// ToMove returns the index of the player whose turn it is. On a finished game it +// is the player who made the final move. +func (g *Game) ToMove() int { return g.toMove } + +// Over reports whether the game has finished. +func (g *Game) Over() bool { return g.over } + +// Reason returns why the game finished, or EndNotOver while it is in progress. +func (g *Game) Reason() EndReason { return g.reason } + +// Score returns the current score of the player at index player. +func (g *Game) Score(player int) int { return g.scores[player] } + +// BagLen returns the number of tiles left in the bag. +func (g *Game) BagLen() int { return g.bag.Len() } + +// BoardClone returns a deep copy of the board, safe for the caller to read or +// mutate without affecting the game. +func (g *Game) BoardClone() *board.Board { return g.board.Clone() } + +// Log returns a copy of the dictionary-independent move log. +func (g *Game) Log() []MoveRecord { + out := make([]MoveRecord, len(g.log)) + copy(out, g.log) + return out +} + +// Result is the outcome of a finished game. +type Result struct { + Over bool + Reason EndReason + Scores []int + // Winner is the index of the single highest score, or -1 on a tie or while + // the game is unfinished. + Winner int +} + +// Result reports the current outcome. Final scores already include the standard +// end-game rack adjustment applied when the game finished. +func (g *Game) Result() Result { + scores := make([]int, len(g.scores)) + copy(scores, g.scores) + return Result{Over: g.over, Reason: g.reason, Scores: scores, Winner: g.winner()} +} + +// finish marks the game over with reason and applies the end-game rack +// adjustment to the scores. +func (g *Game) finish(reason EndReason) { + g.over = true + g.reason = reason + g.applyEndAdjustment(reason) +} + +// applyEndAdjustment settles the unplayed racks. When a player goes out (bag +// empty, rack empty) they gain the sum of every opponent's rack value and each +// opponent loses their own; otherwise (scoreless stalemate or resignation) each +// player simply forfeits their own rack value. +func (g *Game) applyEndAdjustment(reason EndReason) { + if reason == EndOutOfTiles { + out := g.toMove + var bonus int + for i := range g.hands { + if i == out { + continue + } + v := g.rackValue(i) + g.scores[i] -= v + bonus += v + } + g.scores[out] += bonus + return + } + for i := range g.hands { + g.scores[i] -= g.rackValue(i) + } +} + +// endTurnAfterScoreless ends the game when the scoreless run reaches the limit, +// otherwise advances the turn. Used by Pass and Exchange. +func (g *Game) endTurnAfterScoreless() { + if g.scorelessRun >= scorelessLimit { + g.finish(EndScoreless) + return + } + g.advance() +} + +// advance moves play to the next seat. +func (g *Game) advance() { g.toMove = (g.toMove + 1) % len(g.hands) } + +// winner returns the index of the single highest-scoring player, or -1 on a tie +// for the lead or while the game is unfinished. +func (g *Game) winner() int { + if !g.over { + return -1 + } + best, tie := 0, false + for i := 1; i < len(g.scores); i++ { + switch { + case g.scores[i] > g.scores[best]: + best, tie = i, false + case g.scores[i] == g.scores[best]: + tie = true + } + } + if tie { + return -1 + } + return best +} + +// rackOf builds a generation rack from player's hand. +func (g *Game) rackOf(player int) rack.Rack { + r := rack.New(g.rules.Size()) + for _, t := range g.hands[player] { + if t == blankTile { + r.AddBlank() + } else { + r.Add(t) + } + } + return r +} + +// rackValue sums the tile values left on player's hand; blanks count zero. +func (g *Game) rackValue(player int) int { + var v int + for _, t := range g.hands[player] { + if t != blankTile { + v += g.rules.Values[t] + } + } + return v +} + +// checkHolds reports ErrTilesNotOnRack unless player holds every tile in want. +func (g *Game) checkHolds(player int, want []byte) error { + avail := tileCounts(g.hands[player]) + for tile, n := range tileCounts(want) { + if avail[tile] < n { + return ErrTilesNotOnRack + } + } + return nil +} + +// removeFromHand takes one tile per entry of used off player's hand. +func (g *Game) removeFromHand(player int, used []byte) { + hand := g.hands[player] + for _, t := range used { + for i, h := range hand { + if h == t { + hand = append(hand[:i], hand[i+1:]...) + break + } + } + } + g.hands[player] = hand +} + +// refill draws from the bag until player's hand is full or the bag is empty. +func (g *Game) refill(player int) { + if need := g.rules.RackSize - len(g.hands[player]); need > 0 { + g.hands[player] = append(g.hands[player], g.bag.Draw(need)...) + } +} + +// placementTiles maps placements to the tiles they consume (blankTile for blanks). +func placementTiles(tiles []scrabble.Placement) []byte { + out := make([]byte, len(tiles)) + for i, p := range tiles { + if p.Blank { + out[i] = blankTile + } else { + out[i] = p.Letter + } + } + return out +} + +// tileCounts tallies a multiset of tiles by value. +func tileCounts(tiles []byte) map[byte]int { + m := make(map[byte]int, len(tiles)) + for _, t := range tiles { + m[t]++ + } + return m +} diff --git a/backend/internal/engine/game_test.go b/backend/internal/engine/game_test.go new file mode 100644 index 0000000..1e9147c --- /dev/null +++ b/backend/internal/engine/game_test.go @@ -0,0 +1,225 @@ +package engine + +import ( + "errors" + "testing" + + "scrabble-solver/board" + "scrabble-solver/scrabble" +) + +// newEnglishGame starts a two-player English game with the given seed. +func newEnglishGame(t *testing.T, seed int64) *Game { + t.Helper() + g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 2, Seed: seed}) + if err != nil { + t.Fatalf("new game: %v", err) + } + return g +} + +// openingGame returns a two-player English game whose opening rack has at least +// one legal move, searching a deterministic range of seeds. +func openingGame(t *testing.T) *Game { + t.Helper() + for seed := int64(1); seed <= 100; seed++ { + g := newEnglishGame(t, seed) + if len(g.GenerateMoves()) > 0 { + return g + } + } + t.Fatal("no opening move found in seeds 1..100") + return nil +} + +// boardsEqual reports whether two boards have identical dimensions and cells. +func boardsEqual(a, b *board.Board) bool { + if a.Rows() != b.Rows() || a.Cols() != b.Cols() { + return false + } + for r := range a.Rows() { + for c := range a.Cols() { + if a.At(r, c) != b.At(r, c) { + return false + } + } + } + return true +} + +// TestNewDealsRacks checks the initial state of a fresh game. +func TestNewDealsRacks(t *testing.T) { + g := newEnglishGame(t, 1) + if g.Players() != 2 { + t.Errorf("players = %d, want 2", g.Players()) + } + if g.ToMove() != 0 { + t.Errorf("to move = %d, want 0", g.ToMove()) + } + if g.Over() { + t.Error("a fresh game must not be over") + } + if g.Score(0) != 0 || g.Score(1) != 0 { + t.Errorf("scores = (%d, %d), want (0, 0)", g.Score(0), g.Score(1)) + } + rackSize := g.rules.RackSize + if len(g.hands[0]) != rackSize || len(g.hands[1]) != rackSize { + t.Fatalf("hand sizes = (%d, %d), want %d each", len(g.hands[0]), len(g.hands[1]), rackSize) + } + if want := len(allTiles(g.rules)) - 2*rackSize; g.BagLen() != want { + t.Errorf("bag len = %d, want %d", g.BagLen(), want) + } +} + +// TestNewRejectsBadPlayerCount rejects player counts outside 2..4. +func TestNewRejectsBadPlayerCount(t *testing.T) { + for _, n := range []int{0, 1, 5} { + if _, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: n, Seed: 1}); err == nil { + t.Errorf("players=%d: expected an error", n) + } + } +} + +// TestNewUnknownVariant surfaces the registry's not-found error. +func TestNewUnknownVariant(t *testing.T) { + if _, err := New(testReg, Options{Variant: Variant(99), Version: testVersion, Players: 2}); !errors.Is(err, ErrUnknownVariant) { + t.Fatalf("got %v, want ErrUnknownVariant", err) + } +} + +// TestPlayScoresAndAdvances plays the top opening move and checks the score, +// running total, refill and turn advance. +func TestPlayScoresAndAdvances(t *testing.T) { + g := openingGame(t) + move := g.GenerateMoves()[0] + played := len(move.Tiles) + bagBefore := g.BagLen() + + rec, err := g.Play(move.Dir, move.Tiles) + if err != nil { + t.Fatalf("play: %v", err) + } + if rec.Action != ActionPlay { + t.Errorf("action = %v, want play", rec.Action) + } + if rec.Score != move.Score || g.Score(0) != move.Score { + t.Errorf("score: rec=%d game=%d, want %d", rec.Score, g.Score(0), move.Score) + } + if rec.Total != move.Score { + t.Errorf("running total = %d, want %d", rec.Total, move.Score) + } + if len(rec.Tiles) != played { + t.Errorf("recorded tiles = %d, want %d", len(rec.Tiles), played) + } + if g.ToMove() != 1 { + t.Errorf("to move = %d, want 1", g.ToMove()) + } + if len(g.hands[0]) != g.rules.RackSize { + t.Errorf("hand refilled to %d, want %d", len(g.hands[0]), g.rules.RackSize) + } + if g.BagLen() != bagBefore-played { + t.Errorf("bag len = %d, want %d", g.BagLen(), bagBefore-played) + } +} + +// TestPlayRejectsTilesNotOnRack rejects a play using tiles the player lacks. +func TestPlayRejectsTilesNotOnRack(t *testing.T) { + g := newEnglishGame(t, 1) + row, col := centre(g.rules) + ps := placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "cat") + // Clear the hand so the player provably lacks the tiles; the holds check + // must reject the play before any dictionary check. + g.hands[0] = nil + if _, err := g.Play(scrabble.Horizontal, ps); !errors.Is(err, ErrTilesNotOnRack) { + t.Fatalf("got %v, want ErrTilesNotOnRack", err) + } +} + +// TestExchangeSwapsTiles exchanges two tiles and checks the bag and turn state. +func TestExchangeSwapsTiles(t *testing.T) { + g := newEnglishGame(t, 1) + bagBefore := g.BagLen() + swap := append([]byte(nil), g.hands[0][:2]...) + + rec, err := g.Exchange(swap) + if err != nil { + t.Fatalf("exchange: %v", err) + } + if rec.Action != ActionExchange || rec.Count != 2 { + t.Errorf("record = %+v, want exchange of 2", rec) + } + if len(g.hands[0]) != g.rules.RackSize { + t.Errorf("hand size = %d, want %d", len(g.hands[0]), g.rules.RackSize) + } + if g.BagLen() != bagBefore { + t.Errorf("bag len = %d, want %d (draw and return cancel)", g.BagLen(), bagBefore) + } + if g.ToMove() != 1 { + t.Errorf("to move = %d, want 1", g.ToMove()) + } + if g.scorelessRun != 1 { + t.Errorf("scoreless run = %d, want 1", g.scorelessRun) + } +} + +// TestExchangeNeedsFullBag rejects an exchange once the bag is below a rack. +func TestExchangeNeedsFullBag(t *testing.T) { + g := newEnglishGame(t, 1) + g.bag.Draw(g.bag.Len()) // drain the bag + if _, err := g.Exchange(g.hands[0][:1]); !errors.Is(err, ErrNotEnoughTilesToExchange) { + t.Fatalf("got %v, want ErrNotEnoughTilesToExchange", err) + } +} + +// TestPassEndsAfterSixScoreless ends the game after the scoreless limit and then +// rejects further transitions. +func TestPassEndsAfterSixScoreless(t *testing.T) { + g := newEnglishGame(t, 1) + for i := range scorelessLimit { + if _, err := g.Pass(); err != nil { + t.Fatalf("pass %d: %v", i, err) + } + } + if !g.Over() { + t.Fatal("game must be over after six scoreless turns") + } + if g.Reason() != EndScoreless { + t.Errorf("reason = %v, want scoreless", g.Reason()) + } + if _, err := g.Pass(); !errors.Is(err, ErrGameOver) { + t.Errorf("pass after end: got %v, want ErrGameOver", err) + } +} + +// TestGreedyPlaythroughEndsAndReplays drives a full greedy game to its end and +// proves the dictionary-independent replay reproduces the final board. +func TestGreedyPlaythroughEndsAndReplays(t *testing.T) { + g := newEnglishGame(t, 20250602) + const maxTurns = 600 + for turn := 0; turn < maxTurns && !g.Over(); turn++ { + if moves := g.GenerateMoves(); len(moves) > 0 { + if _, err := g.Play(moves[0].Dir, moves[0].Tiles); err != nil { + t.Fatalf("turn %d play: %v", turn, err) + } + continue + } + if _, err := g.Pass(); err != nil { + t.Fatalf("turn %d pass: %v", turn, err) + } + } + if !g.Over() { + t.Fatalf("game did not finish within %d turns", maxTurns) + } + + rs, err := Ruleset(VariantEnglish) + if err != nil { + t.Fatalf("ruleset: %v", err) + } + replayed, err := ReplayBoard(rs, g.Log()) + if err != nil { + t.Fatalf("replay: %v", err) + } + if !boardsEqual(replayed, g.BoardClone()) { + t.Fatal("replayed board differs from the final board") + } +} diff --git a/backend/internal/engine/helpers_test.go b/backend/internal/engine/helpers_test.go new file mode 100644 index 0000000..2131eee --- /dev/null +++ b/backend/internal/engine/helpers_test.go @@ -0,0 +1,71 @@ +package engine + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "scrabble-solver/rules" + "scrabble-solver/scrabble" +) + +// testVersion labels the single dictionary version the tests register. +const testVersion = "test" + +// testReg is the shared registry of all three variants, hydrated once by +// TestMain and reused by the read-only tests. +var testReg *Registry + +// TestMain loads the committed dictionaries once and shares them with every +// test. It fails loudly when the dictionary directory is absent (per +// docs/TESTING.md) rather than skipping coverage. +func TestMain(m *testing.M) { + reg, err := Open(testDictDir(), testVersion) + if err != nil { + fmt.Fprintln(os.Stderr, "engine test setup:", err) + os.Exit(1) + } + testReg = reg + code := m.Run() + _ = reg.Close() + os.Exit(code) +} + +// testDictDir resolves the directory holding the committed scrabble-solver +// DAWGs: BACKEND_DICT_DIR when set (used in CI), otherwise the sibling checkout +// located relative to this test file. +func testDictDir() string { + if dir := os.Getenv("BACKEND_DICT_DIR"); dir != "" { + return dir + } + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "scrabble-solver", "dawg") +} + +// centre returns the centre square coordinates of rs. +func centre(rs *rules.Ruleset) (row, col int) { + return rs.Center / rs.Cols, rs.Center % rs.Cols +} + +// placementsForWord lays word out from (row, col) along dir, resolving each rune +// through the ruleset's alphabet. It expresses no blanks. +func placementsForWord(t *testing.T, rs *rules.Ruleset, row, col int, dir scrabble.Direction, word string) []scrabble.Placement { + t.Helper() + var ps []scrabble.Placement + for i, r := range []rune(word) { + idx, err := rs.Alphabet.Index(string(r)) + if err != nil { + t.Fatalf("index %q: %v", string(r), err) + } + rr, cc := row, col + if dir == scrabble.Horizontal { + cc += i + } else { + rr += i + } + ps = append(ps, scrabble.Placement{Row: rr, Col: cc, Letter: idx}) + } + return ps +} diff --git a/backend/internal/engine/registry.go b/backend/internal/engine/registry.go new file mode 100644 index 0000000..a125c7b --- /dev/null +++ b/backend/internal/engine/registry.go @@ -0,0 +1,155 @@ +package engine + +import ( + "fmt" + "path/filepath" + "sort" + "sync" + + dawg "github.com/iliadenisov/dafsa" + + "scrabble-solver/scrabble" +) + +// dictFiles maps each variant to its committed DAWG filename, as built by +// scrabble-solver and delivered in the dictionary directory. +var dictFiles = map[Variant]string{ + VariantEnglish: "en_sowpods.dawg", + VariantRussianScrabble: "ru_scrabble.dawg", + VariantErudit: "ru_erudit.dawg", +} + +// entry is one resident dictionary: the loaded finder and the solver built over +// it. The finder is retained so Close can release it. +type entry struct { + finder dawg.Finder + solver *scrabble.Solver +} + +// Registry holds the dictionaries resident in memory, addressed by variant and +// dictionary version, and the solvers built over them. Several versions of a +// variant may be resident at once; a game pins the version it started on. The +// admin reload flow (a later stage) registers a new version through Load. +// Registry is safe for concurrent use. +type Registry struct { + mu sync.RWMutex + entries map[Variant]map[string]entry + latest map[Variant]string +} + +// NewRegistry constructs an empty Registry. Use Load or Open to populate it. +func NewRegistry() *Registry { + return &Registry{ + entries: make(map[Variant]map[string]entry), + latest: make(map[Variant]string), + } +} + +// Open builds a Registry by loading, at dictionary version, the committed DAWG of +// every requested variant (or all variants when none are named) from dir. It +// fails if a variant is unknown or its file is missing or unreadable; a +// partially loaded registry is closed before the error is returned. +func Open(dir, version string, variants ...Variant) (*Registry, error) { + if len(variants) == 0 { + variants = Variants() + } + r := NewRegistry() + for _, v := range variants { + if err := r.Load(v, version, dir); err != nil { + _ = r.Close() + return nil, err + } + } + return r, nil +} + +// Load reads the committed DAWG of variant from dir, builds a solver over it and +// registers it under version. Reloading the same (variant, version) replaces the +// previous entry, closing its finder. The most recently loaded version of a +// variant becomes its latest. +func (r *Registry) Load(v Variant, version, dir string) error { + rs, ok := v.ruleset() + if !ok { + return fmt.Errorf("%w: %d", ErrUnknownVariant, v) + } + path := filepath.Join(dir, dictFiles[v]) + finder, err := dawg.Load(path) + if err != nil { + return fmt.Errorf("engine: load %s dictionary %q from %s: %w", v, version, path, err) + } + + r.mu.Lock() + defer r.mu.Unlock() + if r.entries[v] == nil { + r.entries[v] = make(map[string]entry) + } + if old, ok := r.entries[v][version]; ok { + _ = old.finder.Close() + } + r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)} + r.latest[v] = version + return nil +} + +// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant +// when the variant is absent and ErrUnknownVersion when only the version is. +func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) { + r.mu.RLock() + defer r.mu.RUnlock() + versions, ok := r.entries[v] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v) + } + e, ok := versions[version] + if !ok { + return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version) + } + return e.solver, nil +} + +// Latest returns the most recently loaded version of variant and its solver, or +// ErrUnknownVariant when none is resident. +func (r *Registry) Latest(v Variant) (string, *scrabble.Solver, error) { + r.mu.RLock() + defer r.mu.RUnlock() + version, ok := r.latest[v] + if !ok { + return "", nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v) + } + return version, r.entries[v][version].solver, nil +} + +// Versions returns the dictionary versions resident for variant, sorted, or nil +// when none are. +func (r *Registry) Versions(v Variant) []string { + r.mu.RLock() + defer r.mu.RUnlock() + if len(r.entries[v]) == 0 { + return nil + } + versions := make([]string, 0, len(r.entries[v])) + for ver := range r.entries[v] { + versions = append(versions, ver) + } + sort.Strings(versions) + return versions +} + +// Close releases every resident dictionary and empties the registry. It is safe +// to call more than once; the first close error is returned after all finders +// have been closed. +func (r *Registry) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + var firstErr error + for v, versions := range r.entries { + for ver, e := range versions { + if err := e.finder.Close(); err != nil && firstErr == nil { + firstErr = fmt.Errorf("engine: close %s/%s dictionary: %w", v, ver, err) + } + } + } + r.entries = make(map[Variant]map[string]entry) + r.latest = make(map[Variant]string) + return firstErr +} diff --git a/backend/internal/engine/registry_test.go b/backend/internal/engine/registry_test.go new file mode 100644 index 0000000..52ed0ce --- /dev/null +++ b/backend/internal/engine/registry_test.go @@ -0,0 +1,100 @@ +package engine + +import ( + "errors" + "testing" + + "scrabble-solver/board" + "scrabble-solver/scrabble" +) + +// TestRegistryOpensEveryVariant checks that Open loads all three variants at the +// requested version and reports them through Latest and Versions. +func TestRegistryOpensEveryVariant(t *testing.T) { + for _, v := range Variants() { + version, solver, err := testReg.Latest(v) + if err != nil { + t.Fatalf("latest %s: %v", v, err) + } + if version != testVersion { + t.Errorf("latest %s version = %q, want %q", v, version, testVersion) + } + if solver == nil { + t.Errorf("latest %s solver is nil", v) + } + if got := testReg.Versions(v); len(got) != 1 || got[0] != testVersion { + t.Errorf("versions %s = %v, want [%q]", v, got, testVersion) + } + } +} + +// TestRegistryValidatesKnownWords is the per-variant smoke test: a known word +// laid over the centre validates against the loaded dictionary, including the +// Эрудит variant. +func TestRegistryValidatesKnownWords(t *testing.T) { + cases := []struct { + variant Variant + word string + }{ + {VariantEnglish, "cat"}, + {VariantRussianScrabble, "кот"}, + {VariantErudit, "кот"}, + } + for _, tc := range cases { + t.Run(tc.variant.String(), func(t *testing.T) { + solver, err := testReg.Solver(tc.variant, testVersion) + if err != nil { + t.Fatalf("solver: %v", err) + } + rs := solver.Rules() + row, col := centre(rs) + ps := placementsForWord(t, rs, row, col, scrabble.Horizontal, tc.word) + if _, err := solver.ValidatePlay(board.New(rs.Rows, rs.Cols), scrabble.Horizontal, ps); err != nil { + t.Fatalf("validate %q against %s: %v", tc.word, tc.variant, err) + } + }) + } +} + +// TestRegistryUnknownLookups covers the not-found error taxonomy. +func TestRegistryUnknownLookups(t *testing.T) { + reg, err := Open(testDictDir(), testVersion, VariantEnglish) + if err != nil { + t.Fatalf("open english-only registry: %v", err) + } + defer reg.Close() + + if _, err := reg.Solver(VariantEnglish, "absent"); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("solver with bad version: got %v, want ErrUnknownVersion", err) + } + if _, err := reg.Solver(VariantErudit, testVersion); !errors.Is(err, ErrUnknownVariant) { + t.Errorf("solver for unloaded variant: got %v, want ErrUnknownVariant", err) + } + if _, _, err := reg.Latest(VariantErudit); !errors.Is(err, ErrUnknownVariant) { + t.Errorf("latest for unloaded variant: got %v, want ErrUnknownVariant", err) + } + if got := reg.Versions(VariantErudit); got != nil { + t.Errorf("versions for unloaded variant = %v, want nil", got) + } +} + +// TestRegistryCloseIdempotent verifies Close may be called more than once. +func TestRegistryCloseIdempotent(t *testing.T) { + reg, err := Open(testDictDir(), testVersion, VariantEnglish) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := reg.Close(); err != nil { + t.Fatalf("first close: %v", err) + } + if err := reg.Close(); err != nil { + t.Fatalf("second close: %v", err) + } +} + +// TestRegistryOpenMissingDir fails when a dictionary file is absent. +func TestRegistryOpenMissingDir(t *testing.T) { + if _, err := Open(t.TempDir(), testVersion, VariantEnglish); err == nil { + t.Fatal("expected an error opening a registry over an empty directory") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 053a14d..02e4f07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -100,20 +100,43 @@ arrive from a platform rather than completing a mandatory registration). ## 5. Game engine integration (`scrabble-solver`) -`backend` embeds the solver library (see [`CLAUDE.md`](../CLAUDE.md) for the -exact public API and constraints). Key points: +`backend` embeds the solver library in-process behind `internal/engine`, the +only package that imports `scrabble-solver` (see [`CLAUDE.md`](../CLAUDE.md) for +the solver's public API and constraints). The engine is a self-contained rules +library — no persistence, transport or scheduling; the game domain drives it. +Key points: -- Variants at launch: **English Scrabble**, **Russian Scrabble**, **Эрудит** — - `rules.English()`, `rules.RussianScrabble()`, `rules.Erudit()`. -- Dictionaries are committed DAWGs loaded with `dawg.Load`; held in memory and - addressed by `(variant, dict_version)`. -- **Dictionary versioning — pin per game.** A game records the `dict_version` - it started on and finishes on that version; new games use the latest. Multiple - versions may be resident at once. An admin reload endpoint *(planned)* adds a - new version; delivery is the DAWG file in the image / a mounted volume. +- Variants at launch: **English Scrabble**, **Russian Scrabble**, **Эрудит** + (`engine.Variant`, mapping to `rules.English()` / `RussianScrabble()` / + `Erudit()`). Эрудит's specifics (non-doubling centre, `ё` with no tiles, 3 + blanks, a 15-point bonus) live entirely in the solver ruleset, so the engine + treats every variant uniformly. +- **Dictionaries** are committed DAWGs loaded with `dawg.Load` from a directory + (a parameter today; a configurable `BACKEND_DICT_DIR` is wired when the first + consumer needs it). The `engine.Registry` holds them in memory addressed by + `(variant, dict_version)`, tracking the latest version per variant. +- **Dictionary versioning — pin per game.** A game records the `dict_version` it + started on and finishes on that version; new games use the latest. Multiple + versions may be resident at once. An admin reload *(planned, Stage 9)* + registers a new version through `Registry.Load`; delivery is the DAWG file in + the image / a volume mounted at the dictionary directory. (A future split of + the solver into engine + dictionary generator with versioned artifacts is + recorded in [`../PLAN.md`](../PLAN.md) TODO-2.) - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), - `Solver.ValidatePlay`, `Solver.ScorePlay`; board mutation uses - `scrabble.Apply`. Tile bag follows the `selfplay.Bag` pattern. + `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses + `scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag** + that can return tiles (an exchange needs this; the solver's self-play bag + cannot). +- **`engine.Game`** is the in-memory match state and the pure rules engine: it + deals racks, applies legal plays / passes / exchanges / resignations, refills + from the bag, keeps the scores and whose turn it is, and **detects the end of + the game** — empty bag with an empty rack, six consecutive scoreless turns, or + a resignation — applying the end-game rack-value adjustment. The 24-hour + timeout / auto-resign, turn scheduling and persistence belong to the game + domain *(Stage 3)*. +- History is dictionary-independent (§9.1): the engine emits decoded + `MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard` + (alphabet only, no dictionary). ## 6. Game rules @@ -242,5 +265,9 @@ is something to deploy. `integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled, serial). Further workflows (ui-test, deploy) are added with the components they cover. +- Since Stage 2 both Go workflows clone the public `scrabble-solver` sibling + (master HEAD, no credentials) into `../scrabble-solver` before building, so the + `go.work` `replace` resolves; the engine tests read the committed DAWGs from + that checkout via `BACKEND_DICT_DIR`. - After any push, the run is watched to green before a stage is declared done (`python3 ~/.claude/bin/gitea-ci-watch.py`). diff --git a/docs/TESTING.md b/docs/TESTING.md index 821a954..a6b5127 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,10 +15,17 @@ tests or touching CI. by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow. - **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright (e2e), mirroring the chosen plain-Svelte + Vite toolchain. -- **Engine** — correctness of scoring and move generation is owned by - `scrabble-solver`'s own GCG-backed tests. The backend adds regression tests - for end-conditions, the 24-hour timeout / auto-resign, robot balance and - margin targeting, and **dictionary-independent history replay**. +- **Engine** *(Stage 2+)* — correctness of scoring and move generation is owned + by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on + top of the embedded solver: per-variant smoke tests (load all three committed + DAWGs and validate a known word, including Эрудит), bag draw/return determinism + and exchange accounting, the `Game` end-conditions (empty bag with an empty + rack, and six scoreless turns) with end-game rack scoring, and + **dictionary-independent history replay** (`ReplayBoard` reproduces a full + greedy game's final board from decoded records alone). The engine tests read + the DAWGs from `BACKEND_DICT_DIR` (or the sibling `scrabble-solver` checkout) + and fail loudly when it is absent. The 24-hour timeout / auto-resign and robot + balance/margin regression tests arrive with those stages. ## Principles diff --git a/go.work b/go.work index e87beab..73ff722 100644 --- a/go.work +++ b/go.work @@ -1,3 +1,9 @@ go 1.26.3 use ./backend + +// The scrabble-solver engine is consumed in-process as a library. Its module +// path is the bare "scrabble-solver" (not a URL), so it cannot be fetched as a +// versioned dependency via VCS; the workspace points it at the sibling checkout. +// CI clones that sibling next to this repository before building. +replace scrabble-solver => ../scrabble-solver diff --git a/go.work.sum b/go.work.sum index 465e4a0..d7f32a4 100644 --- a/go.work.sum +++ b/go.work.sum @@ -18,6 +18,10 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2V github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk= +github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw= +github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4= +github.com/iliadenisov/dafsa v1.1.0/go.mod h1:mG6Y0DdfRrqdXGqTEMb9Zx0Fl0NkP3ZDYesvxR+e14o= github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= @@ -54,6 +58,7 @@ github.com/ydb-platform/ydb-go-sdk/v3 v3.135.0/go.mod h1:VYUUkRJkKuQPkIpgtZJj6+5 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=