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 (2–4), 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, DropoutTiles: params.DropoutTiles, }) 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, dropoutTiles: params.DropoutTiles.String(), } 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 } // Participants returns the seated account IDs in seat order, the seat index whose // turn it is, and the game status. It is a snapshot read (no engine, no lock) that // lets the social package gate per-game chat and nudges without importing the // engine or the game's private state. func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) { g, err := svc.store.GetGame(ctx, gameID) if err != nil { return nil, 0, "", err } seats := make([]uuid.UUID, len(g.Seats)) for _, s := range g.Seats { seats[s.Seat] = s.AccountID } return seats, g.ToMove, g.Status, 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, DropoutTiles: pre.DropoutTiles, }) 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[:])) }