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" "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 version string clock func() time.Time rng func() int64 pub notify.Publisher 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, 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 } } // 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, params.Variant.String()) svc.metrics.recordStarted(ctx, params.Variant) 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 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) } post, err := svc.store.GetGame(ctx, gameID) if err != nil { return Game{}, err } svc.emitMove(post, rec) return post, nil } // emitMove publishes the live events for a just-committed move: opponent_moved to // every seat other than the actor, and your_turn to the next mover while the game // is still active. Delivery is best-effort (notify.Publisher never blocks). func (svc *Service) emitMove(post Game, rec engine.MoveRecord) { intents := make([]notify.Intent, 0, len(post.Seats)+1) for _, s := range post.Seats { if s.Seat == rec.Player { continue } intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total)) } if post.Status == StatusActive { if next, ok := seatAccount(post.Seats, post.ToMove); ok { deadline := post.TurnStartedAt.Add(post.TurnTimeout) intents = append(intents, notify.YourTurn(next, post.ID, deadline)) } } svc.pub.Publish(intents...) } // 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); err != nil { return false, err } svc.metrics.recordAbandoned(ctx, cur.Variant) 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 } validateStart := time.Now() rec, err := g.EvaluatePlay(dir, tiles) svc.metrics.recordValidate(ctx, pre.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}, 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 hot-reloaded, 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 != 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 } // 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 != StatusActive { 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) } // 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 } // 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, for the // lobby's active/finished lists. 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) } // 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 } // ExportGCG renders a game as GCG text from the journal alone (no dictionary). 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. func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) { g, err := svc.store.GetGame(ctx, gameID) if err != nil { return "", err } if g.Status != StatusFinished { return "", ErrGameActive } 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, pre.Variant.String()) } 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, }) 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 } // 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 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)) } // 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[:])) }