Files
scrabble-game/backend/internal/engine/game.go
T
Ilia Denisov 751e74b14f
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s
Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
2026-06-02 17:33:49 +02:00

439 lines
13 KiB
Go

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
resignedSeat int // seat that resigned, or -1; excludes the resigner from winning
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),
resignedSeat: -1,
}
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). The
// resigner always forfeits the win and keeps their accumulated score (it is
// neither zeroed nor docked a rack adjustment); the win goes to the highest
// score among the remaining seats — in a two-player match, unconditionally to
// the other player. A missed-turn timeout reuses Resign in the game domain, so
// it inherits this win/loss. Richer multi-player drop-out 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
g.resignedSeat = player
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. A scoreless stalemate forfeits each player's own
// rack value. A resignation freezes the scores: the win is decided by winner
// (which excludes the resigner), so no rack adjustment is applied and the
// resigner keeps their accumulated score.
func (g *Game) applyEndAdjustment(reason EndReason) {
switch reason {
case 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
case EndScoreless:
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. After a resignation the resigner
// is excluded, so a two-player game returns the remaining player even when the
// resigner led on score.
func (g *Game) winner() int {
if !g.over {
return -1
}
best, tie := -1, false
for i := range g.scores {
if g.reason == EndResign && i == g.resignedSeat {
continue
}
switch {
case best == -1 || 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
}