Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
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

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.
This commit is contained in:
Ilia Denisov
2026-06-02 17:33:49 +02:00
parent f36f3df748
commit 751e74b14f
45 changed files with 4220 additions and 103 deletions
+629
View File
@@ -0,0 +1,629 @@
package game
import (
"context"
crand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
)
// Service is the game domain: it drives the engine over a single match, persists
// the event-sourced journal, keeps live games warm in a cache, serves hints and
// the word-check tool, exports GCG and runs the turn-timeout sweeper. It is the
// only writer of the game tables and is safe for concurrent use (per-game
// serialised by an internal keyed mutex).
type Service struct {
store *Store
accounts *account.Store
registry *engine.Registry
cache *gameCache
locks *keyedMutex
version string
clock func() time.Time
rng func() int64
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
// registry holds the resident dictionaries; cfg supplies the pinned version and
// the cache idle window; log is used by the background sweeper.
func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service {
clock := func() time.Time { return time.Now().UTC() }
return &Service{
store: store,
accounts: accounts,
registry: registry,
cache: newGameCache(cfg.CacheTTL, clock),
locks: newKeyedMutex(),
version: cfg.DictVersion,
clock: clock,
rng: randomSeed,
log: log,
}
}
// Create starts and persists a new game seating the given accounts in turn order
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (24), the move clock, the hint allowance and that every seat
// is a distinct existing account.
func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, error) {
if n := len(params.Seats); n < 2 || n > 4 {
return Game{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidConfig, n)
}
if params.HintsPerPlayer < 0 {
return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
}
timeout := params.TurnTimeout
if timeout == 0 {
timeout = DefaultTurnTimeout
}
if !allowedTimeout(timeout) {
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
}
seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range params.Seats {
if seen[id] {
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
}
seen[id] = true
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
}
return Game{}, err
}
}
seed := params.Seed
if seed == 0 {
seed = svc.rng()
}
g, err := engine.New(svc.registry, engine.Options{
Variant: params.Variant,
Version: svc.version,
Players: len(params.Seats),
Seed: seed,
})
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
return Game{}, err
}
id, err := uuid.NewV7()
if err != nil {
return Game{}, fmt.Errorf("game: new id: %w", err)
}
ins := gameInsert{
id: id,
variant: params.Variant.String(),
dictVersion: svc.version,
seed: seed,
players: len(params.Seats),
turnTimeoutSecs: int(timeout / time.Second),
hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer,
}
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err
}
svc.cache.put(id, g)
return svc.store.GetGame(ctx, id)
}
// engineOp applies one transition to the live game, returning the decoded record
// and, for an exchange, the swapped tiles.
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
// SubmitPlay validates, scores and commits the player's placement.
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.SubmitPlay(dir, tiles)
return rec, nil, err
})
}
// Pass commits a forfeited turn.
func (svc *Service) Pass(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.Pass()
return rec, nil, err
})
}
// Exchange swaps the named tiles ("?" for a blank) and commits the turn.
func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.SubmitExchange(tiles)
return rec, tiles, err
})
}
// Resign ends the game on the player's turn; the remaining player wins.
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
rec, err := g.Resign()
return rec, nil, err
})
}
// transition validates the actor and turn, applies op under the per-game lock and
// commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return MoveResult{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return MoveResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return MoveResult{}, ErrFinished
}
if pre.ToMove != seat {
return MoveResult{}, ErrNotYourTurn
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return MoveResult{}, err
}
if g.Over() {
return MoveResult{}, ErrFinished
}
if g.ToMove() != seat {
return MoveResult{}, ErrNotYourTurn
}
rackBefore := g.Hand(seat)
rec, exchanged, err := op(g)
if err != nil {
return MoveResult{}, err
}
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats)
if err != nil {
return MoveResult{}, err
}
return MoveResult{Move: rec, Game: post}, nil
}
// commit persists a just-applied transition: the journal row, the post-move turn
// cursor and scores, and on a game-ending move the finish stamp and statistics.
// On a persistence failure it evicts the now-divergent live game so the next
// access rebuilds from the journal.
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat) (Game, error) {
now := svc.clock()
logLen := len(g.Log())
scores := make([]int, g.Players())
for i := range scores {
scores[i] = g.Score(i)
}
c := commit{
gameID: gameID,
seq: logLen - 1,
seat: rec.Player,
action: action,
score: rec.Score,
runningTotal: rec.Total,
exchanged: exchanged,
rec: rec,
rackBefore: rackBefore,
toMove: g.ToMove(),
turnStartedAt: now,
moveCount: logLen,
scores: scores,
now: now,
}
if g.Over() {
c.finished = true
c.finishedAt = now
c.endReason = g.Reason().String()
if action == "timeout" {
c.endReason = "timeout"
}
c.winner = g.Result().Winner
c.stats = buildStats(g, seats)
}
if err := svc.store.CommitMove(ctx, c); err != nil {
svc.cache.remove(gameID)
return Game{}, err
}
if c.finished {
svc.cache.remove(gameID)
}
return svc.store.GetGame(ctx, gameID)
}
// timeoutGame auto-resigns the to-move player of an overdue game. It re-checks,
// under the per-game lock, that the game is still active and still past the
// effective deadline (so a move made since the sweep is not clobbered), records
// the move as a timeout, and reports whether it timed the game out.
func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.Time) (bool, error) {
unlock := svc.locks.lock(gameID)
defer unlock()
cur, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return false, err
}
if cur.Status != StatusActive {
return false, nil
}
seat := cur.ToMove
if seat < 0 || seat >= len(cur.Seats) {
return false, nil
}
acc, err := svc.accounts.GetByID(ctx, cur.Seats[seat].AccountID)
if err != nil {
return false, err
}
deadline := effectiveDeadline(cur.TurnStartedAt, cur.TurnTimeout, loadLocation(acc.TimeZone), minutesOfDay(acc.AwayStart), minutesOfDay(acc.AwayEnd))
if now.Before(deadline) {
return false, nil
}
g, err := svc.liveGame(ctx, cur)
if err != nil {
return false, err
}
if g.Over() {
return false, nil
}
rackBefore := g.Hand(g.ToMove())
rec, err := g.Resign()
if err != nil {
return false, err
}
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
return false, err
}
return true, nil
}
// EvaluatePlay previews a tentative play for a seated player against the current
// board without committing it: whether it is legal and what it would score.
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (EvalResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return EvalResult{}, err
}
if _, ok := pre.seatOf(accountID); !ok {
return EvalResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return EvalResult{}, ErrFinished
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return EvalResult{}, err
}
rec, err := g.EvaluatePlay(dir, tiles)
if err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
return EvalResult{Valid: false}, nil
}
return EvalResult{}, err
}
return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words}, nil
}
// CheckWord reports whether word is in the game's pinned dictionary. It is the
// unlimited word-check tool; an input outside the variant's alphabet is simply
// not a word.
func (svc *Service) CheckWord(ctx context.Context, gameID uuid.UUID, word string) (bool, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return false, err
}
return svc.lookupWord(pre.Variant, pre.DictVersion, word)
}
// FileComplaint records a word-check complaint against the game's dictionary for
// later admin review, stamping the disputed lookup result.
func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UUID, word, note string) (Complaint, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Complaint{}, err
}
if _, ok := pre.seatOf(accountID); !ok {
return Complaint{}, ErrNotAPlayer
}
normalized := normalizeWord(word)
valid, err := svc.lookupWord(pre.Variant, pre.DictVersion, normalized)
if err != nil {
return Complaint{}, err
}
return svc.store.FileComplaint(ctx, Complaint{
ComplainantID: accountID,
GameID: gameID,
Variant: pre.Variant,
DictVersion: pre.DictVersion,
Word: normalized,
WasValid: valid,
Note: note,
})
}
// Hint reveals the top-scoring legal play for the requesting player on their
// turn, spending one hint from their per-game allowance and then their profile
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
// appropriate.
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return HintResult{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return HintResult{}, ErrNotAPlayer
}
if pre.Status != StatusActive {
return HintResult{}, ErrFinished
}
if pre.ToMove != seat {
return HintResult{}, ErrNotYourTurn
}
if !pre.HintsAllowed {
return HintResult{}, ErrHintsDisabled
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return HintResult{}, err
}
used := pre.Seats[seat].HintsUsed
fromAllowance := used < pre.HintsPerPlayer
if !fromAllowance && acc.HintBalance <= 0 {
return HintResult{}, ErrNoHintsLeft
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return HintResult{}, err
}
move, ok := g.HintView()
if !ok {
return HintResult{}, ErrNoHintAvailable
}
walletAfter := acc.HintBalance
if fromAllowance {
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
return HintResult{}, err
}
used++
} else {
spent, err := svc.accounts.SpendHint(ctx, accountID)
if err != nil {
return HintResult{}, err
}
if !spent {
return HintResult{}, ErrNoHintsLeft
}
walletAfter--
}
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
}
// GameState returns a seated player's view of the game: the shared summary plus
// their private rack, the bag size and their remaining hint budget.
func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return StateView{}, err
}
seat, ok := pre.seatOf(accountID)
if !ok {
return StateView{}, ErrNotAPlayer
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID)
defer unlock()
g, err := svc.liveGame(ctx, pre)
if err != nil {
return StateView{}, err
}
return StateView{
Game: pre,
Seat: seat,
Rack: g.Hand(seat),
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
}, nil
}
// History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return HistoryView{}, err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return HistoryView{}, err
}
return HistoryView{Game: g, Moves: moves}, nil
}
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return "", err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return "", err
}
return writeGCG(g, svc.seatNames(ctx, g), moves), nil
}
// liveGame returns the live engine.Game for pre, rebuilding it from the journal
// on a cache miss. Callers must hold the per-game lock.
func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) {
if g, ok := svc.cache.get(pre.ID); ok {
return g, nil
}
g, err := svc.replay(ctx, pre)
if err != nil {
return nil, err
}
if !g.Over() {
svc.cache.put(pre.ID, g)
}
return g, nil
}
// replay reconstructs an engine.Game by dealing from the pinned seed and
// re-applying every journalled move in order. The deterministic bag makes the
// reconstruction exact.
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
seed, err := svc.store.GameSeed(ctx, pre.ID)
if err != nil {
return nil, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
})
if err != nil {
return nil, err
}
moves, err := svc.store.GetJournal(ctx, pre.ID)
if err != nil {
return nil, err
}
for _, mv := range moves {
if err := replayMove(g, mv); err != nil {
return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err)
}
}
return g, nil
}
// replayMove re-applies one journalled move to g through the decoded engine API.
func replayMove(g *engine.Game, mv HistoryMove) error {
switch mv.Action {
case "play":
dir := engine.Horizontal
if mv.Dir == "V" {
dir = engine.Vertical
}
_, err := g.SubmitPlay(dir, mv.Tiles)
return err
case "pass":
_, err := g.Pass()
return err
case "exchange":
_, err := g.SubmitExchange(mv.Exchanged)
return err
case "resign", "timeout":
_, err := g.Resign()
return err
default:
return fmt.Errorf("unknown action %q", mv.Action)
}
}
// buildStats derives each seat's statistics contribution from a finished game:
// win/loss/draw from the (resignation-aware) winner, the final score, and the
// best single-move score from the log.
func buildStats(g *engine.Game, seats []Seat) []statDelta {
res := g.Result()
best := make(map[int]int)
for _, rec := range g.Log() {
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
best[rec.Player] = rec.Score
}
}
out := make([]statDelta, 0, len(seats))
for _, s := range seats {
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
switch {
case res.Winner < 0:
d.draws = 1
case res.Winner == s.Seat:
d.wins = 1
default:
d.losses = 1
}
out = append(out, d)
}
return out
}
// seatNames resolves each seat's display name for GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
names := make([]string, g.Players)
for _, s := range g.Seats {
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
names[s.Seat] = acc.DisplayName
}
}
return names
}
// lookupWord checks word against a variant/version dictionary, treating an
// out-of-alphabet input as simply not a word (a real registry error still
// surfaces).
func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bool, error) {
present, err := svc.registry.Lookup(variant, version, normalizeWord(word))
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
return false, err
}
return false, nil
}
return present, nil
}
// hintsRemaining is a player's remaining hint budget: the unspent per-game
// allowance plus the profile wallet.
func hintsRemaining(allowance, used, wallet int) int {
return max(0, allowance-used) + wallet
}
// allowedTimeout reports whether d is one of the offered move clocks.
func allowedTimeout(d time.Duration) bool {
return slices.Contains(AllowedTurnTimeouts, d)
}
// normalizeWord lower-cases and trims a word-check input to the alphabet's form.
func normalizeWord(word string) string {
return strings.ToLower(strings.TrimSpace(word))
}
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
// system source fails.
func randomSeed() int64 {
var b [8]byte
if _, err := crand.Read(b[:]); err != nil {
return time.Now().UnixNano()
}
return int64(binary.LittleEndian.Uint64(b[:]))
}