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:
@@ -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] })
|
||||
}
|
||||
Reference in New Issue
Block a user