Stage 2: engine package over scrabble-solver #2

Merged
developer merged 2 commits from feature/stage-2-engine into master 2026-06-02 13:12:56 +00:00
19 changed files with 1591 additions and 20 deletions
+11 -1
View File
@@ -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/...
+11 -1
View File
@@ -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/...
+57 -1
View File
@@ -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 34, 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.50.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`.
+26 -1
View File
@@ -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.
+2
View File
@@ -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 (
+68
View File
@@ -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] })
}
+78
View File
@@ -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))
}
}
+134
View File
@@ -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
}
+68
View File
@@ -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")
}
}
+101
View File
@@ -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")
)
+423
View File
@@ -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
}
+225
View File
@@ -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")
}
}
+71
View File
@@ -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
}
+155
View File
@@ -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
}
+100
View File
@@ -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")
}
}
+39 -12
View File
@@ -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`).
+11 -4
View File
@@ -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
+6
View File
@@ -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
+5
View File
@@ -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=