Stage 2: engine package over scrabble-solver (registry, bag, Game, replay)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 7s

backend/internal/engine wraps the sibling scrabble-solver library in-process:

- Registry: versioned DAWG load via dafsa.Load, keyed by (variant, dict_version),
  latest-per-variant; English / Russian / Эрудит handled uniformly.
- Bag: own deterministic, seeded tile bag with Draw + Return (for exchanges),
  since the solver's self-play bag cannot return tiles.
- Game: pure rules engine — deal, play/pass/exchange/resign, refill, per-move
  scoring, turn order, and end-condition detection (empty bag + empty rack, six
  scoreless turns, resignation) with end-game rack adjustment.
- decode/ReplayBoard: dictionary-independent MoveRecords and board replay via
  scrabble.Apply (no internal/encoding), realising ARCHITECTURE §9.1.

Wiring: go.work gains "replace scrabble-solver => ../scrabble-solver"; backend
requires scrabble-solver (placeholder) and github.com/iliadenisov/dafsa directly.
Both Go CI workflows clone the public solver sibling (master HEAD, no token) and
set BACKEND_DICT_DIR.

Docs: ARCHITECTURE §5/§14, TESTING engine layer, backend README, and PLAN
refinements + deferred TODOs (publish/version solver; split engine vs dictionary
generator).
This commit is contained in:
Ilia Denisov
2026-06-02 15:10:08 +02:00
parent 7bd461bfc7
commit 6d0dd4fb14
19 changed files with 1590 additions and 19 deletions
+56
View File
@@ -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`.