Files
scrabble-game/backend/internal/game/service.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

630 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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[:]))
}