Files
scrabble-game/backend/internal/engine/bag.go
T
Ilia Denisov 6d0dd4fb14
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 7s
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).
2026-06-02 15:10:08 +02:00

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] })
}