6d0dd4fb14
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).
69 lines
2.1 KiB
Go
69 lines
2.1 KiB
Go
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] })
|
|
}
|