Files
scrabble-game/backend/internal/game/service.go
T
Ilia Denisov 1c06d1d0d1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
feat(payments): chip wallet, store-compliance gate and benefit application
Stand up the internal chip/benefit mechanic behind the narrow payments interface:
context-aware balances and benefits, an atomic chip spend, admin grants as
zero-price value sales, the one-directional store-compliance gate (VK/TG same-
origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and
per-origin hint and no-ads application with term stacking. Reads are served from
an in-process, account-keyed write-through cache (mirroring the suspension gate),
so hot paths issue no query to the payments schema.

Flip the online-game hint wallet and the ad-banner suppression from the deprecated
accounts.hint_balance / paid_account columns to the payments benefit (a hint
balance no longer suppresses the banner — only a no-ads benefit does), and fold
chip segments and benefits by origin on account merge, inside the merge tx. Add
the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus
its codec unit test; no wallet UI yet.

Bring the frozen owner decisions log into the repo at
docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it
from PLAN.md; record the read-cache design and the present-sources interface in
PLAN.md and docs/PAYMENTS.md (+ RU mirror).
2026-07-08 06:06:40 +02:00

1827 lines
69 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"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/session"
)
// 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)
// hintWallet is the payments surface the online-game hint path spends against (the
// segmented, context-aware hint balance). It is set by SetHintWallet during wiring; when
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
// wallet-free and never touch it.
hintWallet HintWallet
// 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
}
// HintWallet is the payments surface the online-game hint path uses: the context-aware hint
// balance (HintsAvailable) and a one-hint spend (SpendHint), both keyed by the trusted execution
// context and the account's present identity sources. *payments.Service satisfies it.
type HintWallet interface {
HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (int, error)
SpendHint(ctx context.Context, accountID uuid.UUID, cxt payments.Context, present []payments.Source) (bool, error)
}
// SetHintWallet installs the payments hint wallet the online-game hint path spends against. It
// must be called during startup wiring; the default (nil) serves only the free per-game allowance.
func (svc *Service) SetHintWallet(w HintWallet) {
svc.hintWallet = w
}
// walletContext resolves the payments gate inputs for an account on the current request: the
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
// the account's present identity sources (which chip/benefit segments are awake, §6).
func (svc *Service) walletContext(ctx context.Context, accountID uuid.UUID) (payments.Context, []payments.Source, error) {
var cxt payments.Context
if p, ok := session.PlatformFromContext(ctx); ok {
cxt = payments.NewContext(p.Kind, p.Subtype)
}
ids, err := svc.accounts.Identities(ctx, accountID)
if err != nil {
return payments.Context{}, nil, err
}
kinds := make([]string, len(ids))
for i, id := range ids {
kinds[i] = id.Kind
}
return cxt, payments.PresentSources(kinds), nil
}
// 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 (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 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)
}
// CountComplaintsSince counts word complaints filed after since, for the operator alert
// worker.
func (svc *Service) CountComplaintsSince(ctx context.Context, since time.Time) (int, error) {
return svc.store.CountComplaintsSince(ctx, since)
}
// 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)
}
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
const hintIdleWindow = 30 * time.Minute
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
return 0
}
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
if left <= 0 {
return 0
}
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
}
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
// hint statistic.
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
}
if pre.VsAI {
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
// touching the allowance, the wallet or hints_used.
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
return HintResult{}, ErrHintLocked
}
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
}
return HintResult{Move: move}, nil
}
cxt, present, err := svc.walletContext(ctx, accountID)
if err != nil {
return HintResult{}, err
}
wallet := 0
if svc.hintWallet != nil {
wallet, err = svc.hintWallet.HintsAvailable(ctx, accountID, cxt, present)
if err != nil {
return HintResult{}, err
}
}
used := pre.Seats[seat].HintsUsed
fromAllowance := used < pre.HintsPerPlayer
if !fromAllowance && wallet <= 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 := wallet
if !fromAllowance {
spent, err := svc.hintWallet.SpendHint(ctx, accountID, cxt, present)
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,
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
}, 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[:]))
}