Files
scrabble-game/backend/internal/game/service.go
T
Ilia Denisov 4999478ded
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m16s
Stage 17 #5: hide finished games from your own lobby list
A player can remove a finished game from their own 'my games' list. The action is
per-account, finished-only and irreversible (the game stays for the other players;
there is no un-hide).

- backend: migration 00012 game_hidden(account_id, game_id); store HideGame +
  hiddenGameIDs + ListGamesForAccount filtering; service HideGame (seat + finished
  checks, reusing ErrNotAPlayer / ErrGameActive); POST /api/v1/user/games/:id/hide.
- gateway: game.hide edge op (reuses GameActionRequest -> Ack) + backendclient.HideGame.
- ui: finished rows reveal a delete via swipe-left (touch) or a kebab tap (desktop),
  active rows get an inert chevron for icon alignment; optimistic removal + lobby-cache
  sync; mock + transport + client wiring; lobby.hideGame label (en/ru).
- tests: integration (active->ErrGameActive, outsider->ErrNotAPlayer, per-account,
  idempotent), gateway transcode round-trip, mock e2e (kebab -> delete); hardened a
  pre-existing chat-screen .back transition flake surfaced by the new test's timing.
- docs: ARCHITECTURE persistence list, FUNCTIONAL (+ _ru) lobby story, PLAN tracker.
2026-06-09 00:26:35 +02:00

991 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package game
import (
"context"
crand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"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 (24), the move clock, the hint allowance and that every seat
// is a distinct existing account.
func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, error) {
if n := len(params.Seats); n < 2 || n > 4 {
return Game{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidConfig, n)
}
if params.HintsPerPlayer < 0 {
return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
}
timeout := params.TurnTimeout
if timeout == 0 {
timeout = DefaultTurnTimeout
}
if !allowedTimeout(timeout) {
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
}
seen := make(map[uuid.UUID]bool, len(params.Seats))
for _, id := range params.Seats {
if seen[id] {
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
}
seen[id] = true
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
if errors.Is(err, account.ErrNotFound) {
return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
}
return Game{}, err
}
}
seed := params.Seed
if seed == 0 {
seed = svc.rng()
}
g, err := engine.New(svc.registry, engine.Options{
Variant: params.Variant,
Version: svc.version,
Players: len(params.Seats),
Seed: seed,
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.
// 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
}
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)
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
}
// 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 (Stage 13), 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 (Stage 17).
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
return svc.store.LastMoveAt(ctx, gameID, accountID)
}
// 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
}
svc.afterCommitDrafts(ctx, gameID, accountID, rec)
// 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}, nil
}
// afterCommitDrafts maintains the Stage 17 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) (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 — including the actor's own account, so the mover's other devices (and
// their lobby) refresh too — and your_turn to the next mover while the game is still
// active. 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(post Game, rec engine.MoveRecord) {
intents := make([]notify.Intent, 0, len(post.Seats)+1)
for _, s := range post.Seats {
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)
}
// 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 (Stage 17).
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
}
// 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[:]))
}