package game import ( "context" crand "crypto/rand" "encoding/binary" "errors" "fmt" "slices" "strconv" "strings" "sync" "time" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/notify" ) // 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 // verMu guards version, the dictionary version new games pin. It is read on // the game-create path and rewritten when an operator activates a new version // through the admin console, so access is serialised. verMu sync.RWMutex version string clock func() time.Time rng func() int64 // firstMoveEntropy returns the entropy source for one game's first-move draw // (docs/ARCHITECTURE.md §6). The default is crypto/rand — an honest, seedless draw; // tests override it via SetFirstMoveEntropy for a deterministic turn order. It is a // factory so a stateful test source restarts cleanly per game. firstMoveEntropy func() drawIntn pub notify.Publisher // aiTrigger, when set, is called after an honest-AI game is created or advanced and // is still on a robot's potential turn, so the robot replies at once instead of // waiting for the next driver scan. It is fire-and-forget (the robot package wires // its asynchronous TriggerMove); nil disables the fast path (the scan still covers // these games). Kept as a func so the game package never imports the robot package. aiTrigger func(gameID uuid.UUID) // clearNudges, when set, marks the actor's pending nudges in a game read once they // have committed a move (a nudge answered by moving stops counting as unread). It is // best-effort and kept as a func so the game package never imports the social package. clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error // expireNudges, when set, marks every pending nudge in a game read once the game // finishes (the nudge badge is stale on a completed game). Unlike clearNudges it is // keyed by game alone — it clears all seats' nudges, not one mover's — and runs on // every completion path through commit. Best-effort; a func so the game package never // imports the social package. expireNudges func(ctx context.Context, gameID uuid.UUID) error metrics *gameMetrics 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, firstMoveEntropy: func() drawIntn { return cryptoIntn }, pub: notify.Nop{}, metrics: defaultGameMetrics(), log: log, } } // SetNotifier installs the live-event publisher. It must be called during // startup wiring, before the service serves traffic or the sweeper runs; the // default is notify.Nop (no live events). The game service emits your_turn and // opponent_moved after every committed move, whatever the source (a player's // request, the robot driver or the timeout sweeper, which all funnel through // commit). func (svc *Service) SetNotifier(p notify.Publisher) { if p != nil { svc.pub = p } } // SetAITrigger installs the honest-AI fast-move hook called when a vs_ai game is // created or advanced and a robot may now be on the clock. It must be called during // startup wiring; the default (nil) leaves only the periodic driver scan. The robot // package wires its asynchronous TriggerMove here. func (svc *Service) SetAITrigger(trigger func(gameID uuid.UUID)) { svc.aiTrigger = trigger } // SetNudgeClearer installs the hook that marks a mover's pending nudges read after // their move commits. It must be called during startup wiring; the default (nil) // leaves nudges to be cleared only when the recipient opens the move history or chat. // The social package wires its ClearNudges here. func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, accountID uuid.UUID) error) { svc.clearNudges = fn } // SetNudgeExpirer installs the hook that marks every pending nudge in a game read once the // game finishes, on any completion path (a closing move, a resignation, a turn-timeout or a // forfeit). It must be called during startup wiring; the default (nil) leaves a finished // game's nudges to expire only when a recipient opens the move history or chat. The social // package wires its ExpireNudges here. Chat messages are deliberately left unread. func (svc *Service) SetNudgeExpirer(fn func(ctx context.Context, gameID uuid.UUID) error) { svc.expireNudges = fn } // SetFirstMoveEntropy overrides the entropy source for the first-move draw // (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any // game is created; the production default is crypto/rand and is never overridden. // factory returns a fresh draw function per game, so a stateful deterministic test // source restarts cleanly for each game. It exists for deterministic tests. func (svc *Service) SetFirstMoveEntropy(factory func() func(n int) (int, error)) { if factory == nil { return } svc.firstMoveEntropy = func() drawIntn { return drawIntn(factory()) } } // triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort, // fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is // installed, so callers can invoke it unconditionally after a create or commit. func (svc *Service) triggerAI(g Game) { if svc.aiTrigger != nil && g.VsAI && g.Status == StatusActive { svc.aiTrigger(g.ID) } } // activeVersion returns the dictionary version new games currently pin. func (svc *Service) activeVersion() string { svc.verMu.RLock() defer svc.verMu.RUnlock() return svc.version } // ActiveVersion returns the dictionary version new games currently pin. It backs // the admin console's "active version" display. func (svc *Service) ActiveVersion() string { return svc.activeVersion() } // InitActiveVersion reconciles the persisted active dictionary version with the // registry at startup. If a version was persisted and is resident, it becomes the // active version; otherwise the configured seed version is kept and persisted as // the initial active version. Call once during wiring, before serving traffic. func (svc *Service) InitActiveVersion(ctx context.Context) error { persisted, ok, err := svc.store.GetActiveDictVersion(ctx) if err != nil { return err } if ok && svc.versionResident(persisted) { svc.verMu.Lock() svc.version = persisted svc.verMu.Unlock() return nil } return svc.store.SetActiveDictVersion(ctx, svc.activeVersion()) } // SetActiveVersion records version as the active dictionary version, persisting it // and updating the in-memory pointer new games read. The caller must have made the // version resident in the registry first. func (svc *Service) SetActiveVersion(ctx context.Context, version string) error { if err := svc.store.SetActiveDictVersion(ctx, version); err != nil { return err } svc.verMu.Lock() svc.version = version svc.verMu.Unlock() return nil } // versionResident reports whether any variant of version is loaded in the // registry, so a persisted active version pointing at a no-longer-present version // (e.g. after a volume loss) falls back to the seed. func (svc *Service) versionResident(version string) bool { for _, v := range engine.Variants() { if _, err := svc.registry.Solver(v, version); err == nil { return true } } return false } // 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 params.VsAI { // Honest-AI games use the fixed 7-day inactivity clock, not a user-chosen value // from AllowedTurnTimeouts (it is never offered in the creation UI). timeout = AIInactivityTimeout } else { 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 } // Decide who moves first by the official draw (docs/ARCHITECTURE.md §6): each seated // account draws a tile, the one closest to "A" leads (a blank supersedes all letters), // ties re-drawing until a single leader remains. Each draw uses fresh entropy, not the // game seed, so the recorded draws — persisted with the game — are the only account of // the outcome. The winner takes seat 0; the rest keep their order. seeding, err := seedFirstMove(params.Variant, params.Seats, svc.firstMoveEntropy()) if err != nil { if errors.Is(err, engine.ErrUnknownVariant) { return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err) } return Game{}, err } // Build the seats in the drawn turn order, snapshotting each seat's display name. For a // vs-AI game this stamps the robot's seeded account name (unchanged behaviour); a // disguised auto-match robot instead gets a fresh per-game name when the reaper attaches // it (AttachRobot). seats := make([]seatInsert, len(seeding.order)) for i, id := range seeding.order { acc, err := svc.accounts.GetByID(ctx, id) if err != nil { if errors.Is(err, account.ErrNotFound) { return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id) } return Game{}, err } seats[i] = seatInsert{accountID: id, displayName: acc.DisplayName} } seed := params.Seed if seed == 0 { seed = svc.rng() } // Capture the active version once so the engine game and the persisted // dict_version agree even if an operator activates a new version concurrently. version := svc.activeVersion() g, err := engine.New(svc.registry, engine.Options{ Variant: params.Variant, Version: version, Players: len(params.Seats), Seed: seed, DropoutTiles: params.DropoutTiles, MultipleWordsPerTurn: params.MultipleWordsPerTurn, }) 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: version, seed: seed, players: len(params.Seats), turnTimeoutSecs: int(timeout / time.Second), hintsAllowed: params.HintsAllowed, hintsPerPlayer: params.HintsPerPlayer, dropoutTiles: params.DropoutTiles.String(), multipleWordsPerTurn: params.MultipleWordsPerTurn, vsAI: params.VsAI, } if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil { return Game{}, err } svc.metrics.recordStarted(ctx, params.Variant, params.VsAI) created, err := svc.store.GetGame(ctx, id) if err != nil { return Game{}, err } svc.cache.put(id, g, params.Variant.String(), created.Seats) // Honest-AI game seated with a robot: if the robot moves first, reply at once // (the periodic driver is the fallback). No-op for every human-only game. svc.triggerAI(created) return created, nil } // OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in // params and returns the game they land in immediately: another waiting player's open // game (joined=true), or a fresh open game seating only the caller with an empty // opponent seat that a human or the reaper's robot fills later. A re-enqueue while the // caller is already waiting opens another game rather than returning their own. // openDeadline is when the reaper substitutes a robot into a freshly opened game // (ignored when joining one). The bag seed defaults to random; params.Seed // pins it. First-move fairness comes from seating the caller at seat 0 or seat 1 // (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the // caller just waits for the opponent. It backs the lobby auto-match enqueue. func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time, exclude []uuid.UUID) (Game, bool, error) { acc, err := svc.accounts.GetByID(ctx, accountID) if err != nil { if errors.Is(err, account.ErrNotFound) { return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID) } return Game{}, false, err } timeout := params.TurnTimeout if timeout == 0 { timeout = DefaultTurnTimeout } if !allowedTimeout(timeout) { return Game{}, false, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout) } id, err := uuid.NewV7() if err != nil { return Game{}, false, fmt.Errorf("game: new id: %w", err) } seed := params.Seed if seed == 0 { seed = svc.rng() } deadline := openDeadline ins := gameInsert{ id: id, variant: params.Variant.String(), dictVersion: svc.activeVersion(), seed: seed, players: 2, turnTimeoutSecs: int(timeout / time.Second), hintsAllowed: params.HintsAllowed, hintsPerPlayer: params.HintsPerPlayer, dropoutTiles: params.DropoutTiles.String(), multipleWordsPerTurn: params.MultipleWordsPerTurn, status: StatusOpen, openDeadline: &deadline, } // Decide the first move now by the official draw, with the not-yet-arrived opponent as a // synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves // first — before either player acts (docs/ARCHITECTURE.md §6). The caller takes their // drawn seat; the opponent seat is left empty. The opponent's draw rows are recorded with // a NULL account and back-filled when a real opponent joins. Each draw uses fresh entropy, // not the game seed. seats/draws are used only when a fresh game is opened. seeding, err := seedFirstMove(params.Variant, []uuid.UUID{accountID, uuid.Nil}, svc.firstMoveEntropy()) if err != nil { if errors.Is(err, engine.ErrUnknownVariant) { return Game{}, false, fmt.Errorf("%w: %v", ErrInvalidConfig, err) } return Game{}, false, err } caller := seatInsert{accountID: accountID, displayName: acc.DisplayName} seats := make([]seatInsert, len(seeding.order)) for seat, who := range seeding.order { if who == accountID { seats[seat] = caller // the empty opponent seat stays a zero seatInsert } } gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude, seeding.draws) if err != nil { return Game{}, false, err } if created { svc.metrics.recordStarted(ctx, params.Variant, params.VsAI) } g, err := svc.store.GetGame(ctx, gameID) if err != nil { return Game{}, false, err } return g, joined, nil } // AttachRobot seats robotID in the empty opponent seat of open game gameID, stamping // displayName (the robot's fresh per-game name) on the seat, and flips it to active, // returning the now-active game and whether it attached (false, with a zero Game, when a // human joined first). It backs the matchmaking reaper. func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (Game, bool, error) { attached, err := svc.store.AttachRobot(ctx, gameID, robotID, displayName) if err != nil { return Game{}, false, err } if !attached { return Game{}, false, nil } g, err := svc.store.GetGame(ctx, gameID) if err != nil { return Game{}, false, err } return g, true, nil } // ExpiredOpen returns the open games due for a robot substitution (deadline at or // before now) for the matchmaking reaper. func (svc *Service) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) { return svc.store.ExpiredOpen(ctx, now) } // 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. The engine // infers the play's orientation from the tiles and the board, so the caller // supplies only the placed tiles (docs/ARCHITECTURE.md §5). func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, tiles []engine.TileRecord) (MoveResult, error) { return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) { rec, err := g.SubmitPlay(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. // Resign forfeits the game for the acting account. Unlike a play/exchange/pass it is // allowed on the opponent's turn (a resignation is not a turn-scoped move), so it does // not go through transition's turn check: it resigns the actor's own seat, whoever is to // move. The resigning seat always loses (docs/ARCHITECTURE.md §7). func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (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 } // Resign needs a present opponent to award the win, so it is refused while the game // is still waiting for one; the UI keeps the button disabled until then. if pre.Status == StatusOpen { return MoveResult{}, ErrNoOpponentYet } if pre.Status != StatusActive { return MoveResult{}, ErrFinished } 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 } rackBefore := g.Hand(seat) rec, err := g.ResignSeat(seat) if err != nil { return MoveResult{}, err } post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, nil, pre.Seats, pre.VsAI) if err != nil { return MoveResult{}, err } svc.afterCommitDrafts(ctx, gameID, accountID, rec) // A resignation carries no think time (it can happen on the opponent's turn), so it // is intentionally excluded from the move-duration metric. return MoveResult{Move: rec, Game: post}, nil } // ForfeitAllForAccount resigns every game the account is currently playing and cancels every game // it has opened but no opponent has joined, returning how many it acted on. It is the game-side // effect of an admin block: the blocked player instantly loses each live game (the opponent // winning, exactly as a manual resignation) and leaves matchmaking so nobody joins a doomed game. // It reuses the per-game resignation path (its lock, commit and live events), so it is safe to // call while play continues and is idempotent — an already-resigned or finished game is skipped. // A per-game failure is logged and skipped so one bad game does not strand the rest; the // turn-timeout sweeper remains the backstop for anything missed in a race. func (svc *Service) ForfeitAllForAccount(ctx context.Context, accountID uuid.UUID) (int, error) { games, err := svc.store.ListGamesForAccount(ctx, accountID) if err != nil { return 0, err } count := 0 for _, g := range games { if g.Status != StatusActive && g.Status != StatusOpen { continue // a finished game holds no turn to forfeit } acted, err := svc.forfeitOne(ctx, g.ID, accountID) if err != nil { svc.log.Warn("forfeit on block", zap.String("game", g.ID.String()), zap.Error(err)) continue } if acted { count++ } } return count, nil } // forfeitOne resigns one game on behalf of accountID, or cancels it when it is an open auto-match // game still awaiting an opponent. It reports whether it changed the game. A game that has already // finished, or in which the account no longer holds a seat, is a benign no-op. func (svc *Service) forfeitOne(ctx context.Context, gameID, accountID uuid.UUID) (bool, error) { _, err := svc.Resign(ctx, gameID, accountID) switch { case err == nil: return true, nil case errors.Is(err, ErrNoOpponentYet): // An open game with no opponent cannot be resigned (there is no one to award the win), so // delete it to clear it from the matchmaking pool. If it filled in the race window it is // now active, so resign the seat instead. deleted, derr := svc.store.DeleteOpenGame(ctx, gameID) if derr != nil { return false, derr } if deleted { svc.cache.remove(gameID) return true, nil } if _, err := svc.Resign(ctx, gameID, accountID); err != nil && !errors.Is(err, ErrFinished) && !errors.Is(err, ErrNoOpponentYet) { return false, err } return true, nil case errors.Is(err, ErrFinished), errors.Is(err, ErrNotAPlayer), errors.Is(err, ErrNotFound): return false, nil default: return false, err } } // GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet // indices to concrete letters before delegating to the letter-based play, exchange and // word-check methods, keeping a single domain path shared with the robot. func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) { return svc.store.GetGameVariant(ctx, gameID) } // RobotSchedule returns a game's bag seed and turn-start time, for the admin console's // robot-schedule panel (the deterministic play-to-win intent and next-move ETA). func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { return svc.store.RobotSchedule(ctx, gameID) } // LastMoveAt returns the time of an account's most recent move in a game (and whether it // has moved). The social service uses it to reset the nudge cooldown once a player has // taken a turn. func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) { return svc.store.LastMoveAt(ctx, gameID, accountID) } // TurnStartedAt returns the start time of a game's current turn. The social service uses // it as the per-turn boundary for the one-chat-message-per-turn limit. func (svc *Service) TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error) { return svc.store.TurnStartedAt(ctx, gameID) } // 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 } // A move is allowed while the game is active or still open (the starter may move on // their turn before an opponent joins; the first-move draw ran when the game opened, so // the seats are already fixed); only a finished game rejects it. The turn check below // keeps the starter off the still-empty opponent seat. if pre.Status == StatusFinished { 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, pre.VsAI) if err != nil { return MoveResult{}, err } svc.afterCommitDrafts(ctx, gameID, accountID, rec) // A nudge the actor answered by moving stops counting as unread (best-effort; the // move has committed, so a cleanup failure is logged, not surfaced). if svc.clearNudges != nil { if err := svc.clearNudges(ctx, gameID, accountID); err != nil { svc.log.Warn("clear nudges after move", zap.Error(err)) } } // Record the seat's think time (turn start to commit) for the move-duration // metric; the timeout path commits separately and is excluded by design. svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt)) return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil } // afterCommitDrafts maintains the drafts after a committed move: the actor's own // composition is consumed, so clear it; a play's tiles may overlap an opponent's board // draft, which is then reset. Best-effort — the move is already committed, so a draft // cleanup failure is logged rather than failing the move. func (svc *Service) afterCommitDrafts(ctx context.Context, gameID, accountID uuid.UUID, rec engine.MoveRecord) { if err := svc.store.clearDraft(ctx, gameID, accountID); err != nil { svc.log.Warn("clear actor draft", zap.Error(err)) } if rec.Action == engine.ActionPlay { if err := svc.store.resetConflictingBoardDrafts(ctx, gameID, accountID, draftTilesFrom(rec)); err != nil { svc.log.Warn("reset conflicting board drafts", zap.Error(err)) } } } // 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, vsAI bool) (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 // Honest-AI games are practice and never touch player statistics (like guest // games); a human game records them for its non-guest seats. if !vsAI { statSeats, err := svc.nonGuestSeats(ctx, seats) if err != nil { svc.cache.remove(gameID) return Game{}, err } c.stats = buildStats(g, statSeats) } } if err := svc.store.CommitMove(ctx, c); err != nil { svc.cache.remove(gameID) return Game{}, err } if c.finished { svc.cache.remove(gameID) // A finished game's nudges are stale, so clear them all here — every completion path // funnels through commit (a closing move, a resignation, a forfeit or a turn-timeout), // and only the move path also clears the mover's nudge on its own. Best-effort like // clearNudges: the finish has committed, so a cleanup failure is logged, not surfaced. // ExpireNudges leaves chat messages unread. if svc.expireNudges != nil { if err := svc.expireNudges(ctx, gameID); err != nil { svc.log.Warn("expire nudges on game finish", zap.Error(err)) } } } post, err := svc.store.GetGame(ctx, gameID) if err != nil { return Game{}, err } svc.emitMove(ctx, post, rec, g.BagLen()) // Honest-AI game still going: nudge the robot to take its turn at once (the // periodic driver is the fallback). No-op for human games and finished ones. svc.triggerAI(post) return post, nil } // emitMove publishes the live events for a just-committed move: opponent_moved to // every seat — including the actor's own account, so the mover's other devices (and // their lobby) refresh too — and, in human games only, your_turn to the next mover // while the game is still active (an honest-AI game suppresses your_turn, since the // robot replies at once). opponent_moved is in-app only (the gateway never turns it into an // out-of-app push), so the actor is not notified out of band about their own move. // Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each // event out to all of the recipient's live streams. func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveRecord, bagLen int) { // Resolve the seat names once and reuse them for every recipient's enriched summary. names := svc.seatNames(ctx, post) summary := gameSummary(post, names) intents := make([]notify.Intent, 0, 2*len(post.Seats)) for _, s := range post.Seats { if s.AccountID == uuid.Nil { continue // an open game's opponent seat is not yet filled — nobody to notify } intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen)) } switch post.Status { case StatusActive: // Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn" // notification would arrive together with the AI's move and carry no value — the // human's UI already advances from opponent_moved. Only human games signal the mover. if next, ok := seatAccount(post.Seats, post.ToMove); ok && !post.VsAI { deadline := post.TurnStartedAt.Add(post.TurnTimeout) action := rec.Action.String() word := "" if action == "play" && len(rec.Words) > 0 { word = rec.Words[0] } opponent := svc.displayName(ctx, post.Seats, rec.Player) yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount) intents = append(intents, yourTurn) } case StatusFinished: // The game just ended (any path: a closing play, all-pass, resign or timeout). Tell every // seat, each with their own perspective + recipient-first score, so an offline player gets // an out-of-app "game over" push (online players take it from the in-app refresh). for _, s := range post.Seats { if s.AccountID == uuid.Nil { continue } over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary) intents = append(intents, over) } } svc.pub.Publish(intents...) } // seatDisplayName returns the name shown for a seat: its captured display-name snapshot // (docs/ARCHITECTURE.md §7), falling back to the account's current display name for a // pre-snapshot legacy row, or "" when neither is available. func (svc *Service) seatDisplayName(ctx context.Context, s Seat) string { if s.DisplayName != "" { return s.DisplayName } if svc.accounts == nil || s.AccountID == uuid.Nil { return "" } if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil { return acc.DisplayName } return "" } // displayName resolves the display name shown for the account at the given seat, or "" // when the seat is absent (the enriched push then falls back to its plain text). func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string { for _, s := range seats { if s.Seat == seat { return svc.seatDisplayName(ctx, s) } } return "" } // scoreLine formats the running scores with recipientSeat's score first, then the remaining // seats in seat order, colon-joined (e.g. "120:95:80") — the recipient-first form used in the // out-of-app notifications. func scoreLine(g Game, recipientSeat int) string { n := len(g.Seats) bySeat := make([]int, n) for _, s := range g.Seats { if s.Seat >= 0 && s.Seat < n { bySeat[s.Seat] = s.Score } } parts := make([]string, 0, n) if recipientSeat >= 0 && recipientSeat < n { parts = append(parts, strconv.Itoa(bySeat[recipientSeat])) } for seat := 0; seat < n; seat++ { if seat != recipientSeat { parts = append(parts, strconv.Itoa(bySeat[seat])) } } return strings.Join(parts, ":") } // seatResult reports the finished-game outcome from recipientSeat's perspective: "draw" when no // seat is flagged the winner, "won" when recipientSeat is, otherwise "lost". func seatResult(seats []Seat, recipientSeat int) string { winner := false for _, s := range seats { if s.IsWinner { winner = true if s.Seat == recipientSeat { return "won" } } } if !winner { return "draw" } return "lost" } // seatAccount returns the account seated at the given seat index, or false when // no seat matches (the slice is not assumed to be ordered by seat). func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) { for _, s := range seats { if s.Seat == seat { return s.AccountID, true } } return uuid.UUID{}, false } // 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, cur.VsAI); err != nil { return false, err } svc.metrics.recordAbandoned(ctx, cur.Variant, cur.VsAI) 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, tiles []engine.TileRecord) (EvalResult, error) { unlock := svc.locks.lock(gameID) defer unlock() // Hot path: an active game stays cached — the engine game is mutated in place across // moves and evicted only when it finishes — so on a hit the cached live game and its // immutable seat list answer the membership check and the score with no DB read. This // preview is fired on every tile placement, the hottest gameplay call at scale. g, seats, ok := svc.cache.get(gameID) if !ok { // Cold path: load and validate from the store, then replay into the cache. pre, err := svc.store.GetGame(ctx, gameID) if err != nil { return EvalResult{}, err } if pre.Status == StatusFinished { return EvalResult{}, ErrFinished } if g, err = svc.liveGame(ctx, pre); err != nil { return EvalResult{}, err } seats = pre.Seats } if !seatedIn(seats, accountID) { return EvalResult{}, ErrNotAPlayer } validateStart := time.Now() rec, err := g.EvaluatePlay(tiles) svc.metrics.recordValidate(ctx, g.Variant(), validateStart) 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, Dir: rec.Dir.String()}, 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, }) } // ListComplaints returns word-check complaints for the admin review queue, // newest first. status filters by lifecycle state ("" = all); limit is clamped // to a sane page size and offset is floored at zero. func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) { return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset)) } // GetComplaint loads a single complaint for the admin detail view. func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) { return svc.store.GetComplaint(ctx, id) } // CountComplaints returns the number of complaints, optionally restricted to a // status, for the admin queue pager and the dashboard counts. func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) { return svc.store.CountComplaints(ctx, status) } // ResolveComplaint closes a complaint with an operator disposition (reject / // accept_add / accept_remove) and an optional note. An accepted complaint then // appears in DictionaryChanges until a rebuilt dictionary is loaded and the // change is marked applied. It returns ErrInvalidConfig for an unknown // disposition and ErrNotFound when no complaint matches. func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) { if !validDisposition(disposition) { return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition) } return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock()) } // DictionaryChanges returns the pending wordlist edits implied by resolved, // accepted complaints not yet marked applied — the input to the offline DAWG // rebuild. func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) { rows, err := svc.store.ListDictionaryChanges(ctx) if err != nil { return nil, err } out := make([]DictionaryChange, 0, len(rows)) for _, c := range rows { ch := DictionaryChange{ ComplaintID: c.ID, Variant: c.Variant, Word: c.Word, Add: c.Disposition == DispositionAcceptAdd, Note: c.Note, } if c.ResolvedAt != nil { ch.ResolvedAt = *c.ResolvedAt } out = append(out, ch) } return out, nil } // MarkChangesApplied records that every pending accepted change for variant has // been folded into the dictionary version that was just installed, removing // them from DictionaryChanges. It returns the number of changes marked. func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) { return svc.store.MarkChangesApplied(ctx, variant.String(), version) } // 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 == StatusFinished { 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 { spent, err := svc.accounts.SpendHint(ctx, accountID) if err != nil { return HintResult{}, err } if !spent { return HintResult{}, ErrNoHintsLeft } walletAfter-- } // hints_used is the per-game total (allowance + wallet): every hint increments it. The first // HintsPerPlayer hints are the free allowance (so fromAllowance above stays correct); the rest // are charged to the wallet. Counting all hints feeds the player's lifetime hint statistics. if err := svc.store.IncHintsUsed(ctx, gameID, seat); err != nil { return HintResult{}, err } used++ return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil } // Candidates returns the to-move player's legal plays for a seated player on // their turn, ranked by descending score. It is the read the robot opponent uses // to choose a move by margin; it spends nothing and mutates no state. It returns // ErrNotAPlayer, ErrFinished or ErrNotYourTurn like the other turn-scoped reads. func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) { pre, err := svc.store.GetGame(ctx, gameID) if err != nil { return nil, err } seat, ok := pre.seatOf(accountID) if !ok { return nil, ErrNotAPlayer } if pre.Status == StatusFinished { return nil, ErrFinished } if pre.ToMove != seat { return nil, ErrNotYourTurn } unlock := svc.locks.lock(gameID) defer unlock() g, err := svc.liveGame(ctx, pre) if err != nil { return nil, err } return g.Candidates(), nil } // RobotTurns returns the robot driver's view of every active game seating one of // robotIDs. It is the robot scheduler's periodic scan, mirroring the timeout // sweeper's ActiveGames read; the driver derives each robot's deadline from the // returned seed and turn cursor. func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]RobotTurn, error) { return svc.store.RobotTurns(ctx, robotIDs) } // RobotTurn returns the robot driver's view of a single active game seating one of // robotIDs, and true, or false when the game holds no pooled robot or is no longer // active. It backs the honest-AI fast-move trigger, which drives just the one game. func (svc *Service) RobotTurn(ctx context.Context, gameID uuid.UUID, robotIDs []uuid.UUID) (RobotTurn, bool, error) { return svc.store.RobotTurnByGame(ctx, gameID, robotIDs) } // VsAI reports whether a game is an honest-AI game (games.vs_ai). The social // service uses it to reject chat and nudge in AI games (which otherwise report // status 'active'). func (svc *Service) VsAI(ctx context.Context, gameID uuid.UUID) (bool, error) { return svc.store.GameVsAI(ctx, gameID) } // 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 } if g.Reason() == engine.EndAborted { // liveGame voided the game; re-read so the view reflects the finished/aborted state. if pre, err = svc.store.GetGame(ctx, gameID); 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), WalletBalance: acc.HintBalance, }, nil } // InitialState returns accountID's full initial view of game gameID as the notify // PlayerState carried by the match_found / game_started events, so a client can // render a freshly started game from the event without a follow-up fetch. The variant // alphabet table is always embedded (the recipient may be seeing the variant for the // first time). It satisfies lobby.GameCreator. func (svc *Service) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) { v, err := svc.GameState(ctx, gameID, accountID) if err != nil { return notify.PlayerState{}, err } names := svc.seatNames(ctx, v.Game) return playerState(v, names, true) } // 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 } // SeatName returns the display name shown for the account seated in the game — the seat's // captured snapshot, falling back to the account's current name (see seatDisplayName) — or "" // when the account holds no seat. It lets the social package name a nudge's sender by the same // per-game identity the rest of the game uses, without exposing the seats. func (svc *Service) SeatName(ctx context.Context, gameID, accountID uuid.UUID) (string, error) { g, err := svc.store.GetGame(ctx, gameID) if err != nil { return "", err } for _, s := range g.Seats { if s.AccountID == accountID { return svc.seatDisplayName(ctx, s), nil } } return "", nil } // SharedGame reports whether accounts a and b are seated together in any game // (active or finished). It backs the social package's "befriend an opponent" // request gate without exposing the games tables; a self-pair is never shared. func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) { if a == b { return false, nil } return svc.store.SharedGameExists(ctx, a, b) } // ListForAccount returns every game the account is seated in, newest first — the full // set behind the admin console, the account-merge count and the block-time forfeit // sweep. The player's own lobby uses ListForLobby, which drops the honest-AI games the // player left. The live position is not loaded — the summaries come straight from the // durable rows. func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) { return svc.store.ListGamesForAccount(ctx, accountID) } // ListForLobby returns the games shown in the account's own lobby: ListForAccount minus // the honest-AI games the player left — by resigning or by abandoning to the inactivity // timeout (games.end_reason 'resign'/'timeout') — which drop out of the finished list // automatically. The robot never leaves (it moves at once, never sleeps, never resigns), // so only a human's departure matches; the predicate is a game property, not a seat // check, so it extends to any player should the robot ever resign. Admin and // account-merge views keep using ListForAccount and see the full set. func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Game, error) { games, err := svc.ListForAccount(ctx, accountID) if err != nil { return nil, err } kept := games[:0] for _, g := range games { if g.VsAI && (g.EndReason == "resign" || g.EndReason == "timeout") { continue } kept = append(kept, g) } return kept, nil } // CountActiveQuickGames reports how many in-progress quick games the account holds — // the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It // counts active and still-open quick games (including honest-AI ones) and excludes // friend games created by invitation and finished games. See Store.CountActiveQuickGames. func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) { return svc.store.CountActiveQuickGames(ctx, accountID) } // HideGame hides a finished game from accountID's own lobby (it stays visible to the other // players); it is irreversible by design. Only a player of a finished game may hide it // (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op. func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error { g, err := svc.store.GetGame(ctx, gameID) if err != nil { return err } seated := false for _, s := range g.Seats { if s.AccountID == accountID { seated = true break } } if !seated { return ErrNotAPlayer } if g.Status != StatusFinished { return ErrGameActive } return svc.store.HideGame(ctx, accountID, gameID) } // GameByID returns a game with its seats for the admin console detail view. func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) { return svc.store.GetGame(ctx, id) } // ListGames returns games for the admin list, newest-updated first, paginated, // optionally filtered by status. func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) { return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset)) } // CountGames returns the game count, optionally filtered by status, for the admin // list pager and dashboard. func (svc *Service) CountGames(ctx context.Context, status string) (int, error) { return svc.store.CountGames(ctx, status) } // 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 } // SetupDraws returns a game's recorded first-move draws (docs/ARCHITECTURE.md §6), ordered by // round then pick. It backs the admin console's first-move section. An auto-match opponent's // draws carry uuid.Nil until a real opponent joins and back-fills them; an empty slice means // the game predates the draw record. func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDraw, error) { return svc.store.SetupDraws(ctx, gameID) } // ExportView returns a finished game with its journal and per-seat display names — // the material every export artifact (the GCG text, the PNG render payload) is built // from. It is allowed only on a finished game: exporting an in-progress game would // leak the full move journal mid-play, so an active game yields ErrGameActive. In an // honest-AI game the robot seat is labelled "AI", not its pool name. func (svc *Service) ExportView(ctx context.Context, gameID uuid.UUID) (Game, []HistoryMove, []string, error) { g, err := svc.store.GetGame(ctx, gameID) if err != nil { return Game{}, nil, nil, err } if g.Status != StatusFinished { return Game{}, nil, nil, ErrGameActive } moves, err := svc.store.GetJournal(ctx, gameID) if err != nil { return Game{}, nil, nil, err } names := svc.seatNames(ctx, g) if g.VsAI { for _, s := range g.Seats { if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot { names[s.Seat] = aiPlayerName } } } return g, moves, names, nil } // EnsureExportable reports whether a game may be exported (it exists and is // finished) without loading the journal — the export-URL mint check. func (svc *Service) EnsureExportable(ctx context.Context, gameID uuid.UUID) error { g, err := svc.store.GetGame(ctx, gameID) if err != nil { return err } if g.Status != StatusFinished { return ErrGameActive } return 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, moves, names, err := svc.ExportView(ctx, gameID) if err != nil { return "", err } return writeGCG(g, names, 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.Reason() == engine.EndAborted && pre.Status != StatusFinished { // First open after the game became unreplayable: persist the void (a finished draw) // so the lobby and later opens see it settled. Guarded so it runs exactly once. if err := svc.voidGame(ctx, pre, g); err != nil { return nil, err } } if !g.Over() { svc.cache.put(pre.ID, g, pre.Variant.String(), pre.Seats) } 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) { defer svc.metrics.recordReplay(ctx, pre.Variant, time.Now()) 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, MultipleWordsPerTurn: pre.MultipleWordsPerTurn, }) 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 { if errors.Is(err, engine.ErrIllegalPlay) { // A committed move is no longer legal under the current rules, so the game // cannot be reconstructed past it: close it as a draw (liveGame persists the // void) rather than leave it unopenable. Other errors are genuine and propagate. g.Abort() break } return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err) } } return g, nil } // voidGame closes pre as a draw because its journal can no longer be replayed; g is the // partial reconstruction, already Aborted. It persists the finish (end_reason 'aborted'), // each seat's partial score as a draw, and the draw statistics for the non-guest seats. The // journal is left intact. func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) error { scores := make([]int, g.Players()) for i := range scores { scores[i] = g.Score(i) } statSeats, err := svc.nonGuestSeats(ctx, pre.Seats) if err != nil { return err } if err := svc.store.VoidGame(ctx, voidCommit{ gameID: pre.ID, endReason: g.Reason().String(), scores: scores, now: svc.clock(), stats: buildStats(g, statSeats), }); err != nil { return err } // A voided game is finished (as a draw) but bypasses commit, so clear its now-stale nudges // here too. Best-effort, like the commit path: the void has persisted, so a cleanup failure // is logged, not surfaced. if svc.expireNudges != nil { if err := svc.expireNudges(ctx, pre.ID); err != nil { svc.log.Warn("expire nudges on voided game", zap.Error(err)) } } return 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.SubmitPlayDir(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 play from the log — its score and, for the per-variant breakdown, its main // word as rendering tiles. Blank flags are taken from every blank ever placed (so a // blank laid by an earlier move and embedded in the best word is honoured), which is // equivalent to reading the final board since a placed tile never moves. func buildStats(g *engine.Game, seats []Seat) []statDelta { res := g.Result() bestRec := make(map[int]engine.MoveRecord) blanks := make(map[[2]int]bool) plays := make(map[int]int) // per player: count of plays (tile placements), for the "moves" stat for _, rec := range g.Log() { if rec.Action != engine.ActionPlay { continue } plays[rec.Player]++ for _, t := range rec.Tiles { if t.Blank { blanks[[2]int{t.Row, t.Col}] = true } } if cur, ok := bestRec[rec.Player]; !ok || rec.Score > cur.Score { bestRec[rec.Player] = rec } } variant := g.Variant().String() values := letterValues(g.Variant()) out := make([]statDelta, 0, len(seats)) for _, s := range seats { // moves counts the seat's plays; hintsUsed is the seat's total hints this game. Both are // summed into account_stats so the screen can show the hint share (hints_used / moves). d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), moves: plays[s.Seat], hintsUsed: s.HintsUsed} if rec, ok := bestRec[s.Seat]; ok { d.wordPoints = rec.Score if rec.Score > 0 { d.bestVariant = variant d.bestScore = rec.Score d.bestTiles = mainWordTiles(rec, blanks, values) } } 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 } // letterValues builds a lower-cased letter -> tile value lookup for a variant from the // engine's alphabet table, so a best-move word can be rendered with per-tile values on a // screen (statistics) that has not cached the variant's alphabet. It is empty for an // unrecognised variant, leaving every value zero. func letterValues(v engine.Variant) map[string]int { table, err := engine.AlphabetTable(v) if err != nil { return nil } m := make(map[string]int, len(table)) for _, e := range table { m[strings.ToLower(e.Letter)] = e.Value } return m } // mainWordTiles decodes a play's main word into rendering tiles: each letter with its // tile value (0 for a blank) and blank flag. blanks is the set of board coordinates a // blank was ever placed on; values maps a lower-cased letter to its tile value. It walks // the word from its first-letter coordinate along the play's orientation. func mainWordTiles(rec engine.MoveRecord, blanks map[[2]int]bool, values map[string]int) []account.BestMoveTile { if len(rec.Words) == 0 { return nil } dr, dc := 0, 1 if rec.Dir == engine.Vertical { dr, dc = 1, 0 } letters := []rune(rec.Words[0]) out := make([]account.BestMoveTile, len(letters)) for i, r := range letters { row, col := rec.MainRow+i*dr, rec.MainCol+i*dc blank := blanks[[2]int{row, col}] letter := string(r) value := 0 if !blank { value = values[strings.ToLower(letter)] } out[i] = account.BestMoveTile{Letter: letter, Value: value, Blank: blank} } return out } // nonGuestSeats filters out guest seats so the finish-time statistics are // recomputed for durable non-guest accounts only — guests never accrue // statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish. func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, error) { out := make([]Seat, 0, len(seats)) for _, s := range seats { acc, err := svc.accounts.GetByID(ctx, s.AccountID) if err != nil { return nil, err } if acc.IsGuest { continue } out = append(out, s) } return out, nil } // seatNames resolves each seat's display name — its captured snapshot, else the // account's current name (seatDisplayName) — for game state and GCG export. func (svc *Service) seatNames(ctx context.Context, g Game) []string { names := make([]string, g.Players) for _, s := range g.Seats { if s.Seat >= 0 && s.Seat < len(names) { names[s.Seat] = svc.seatDisplayName(ctx, s) } } 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 } // DictBytes returns the raw serialized dictionary for the (variant, version) pair // from the registry, backing the client-side dictionary download used by the // local move preview. It surfaces engine.ErrUnknownVariant / // engine.ErrUnknownVersion when that dictionary is not resident. func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) { return svc.registry.DictBytes(variant, version) } // 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)) } // validDisposition reports whether d is an accepted complaint disposition. func validDisposition(d string) bool { switch d { case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove: return true default: return false } } // clampPageSize bounds an admin list page size to [1, 200], defaulting an unset // (non-positive) request to 50. func clampPageSize(limit int) int { switch { case limit <= 0: return 50 case limit > 200: return 200 default: return limit } } // 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[:])) }