Stage 2: engine package over scrabble-solver (registry, bag, Game, replay)
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:
+26
-1
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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] })
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user