aa765a0c06
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
New Game's quick game gains an explicit opponent selector — 🤖 AI (default) or 👤 Random player. AI starts a game seated with a pooled robot that joins and moves at once: no per-move timeout (a 7-day inactivity loss reusing the turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown as 🤖 everywhere. The random path (disguised robot) is unchanged. Driven by one game flag (games.vs_ai), set only on AI-started games so the disguised path is never revealed; Matchmaker.StartVsAI seats the robot directly (no open pool); the robot replies event-driven via the game service's after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView.
1445 lines
50 KiB
Go
1445 lines
50 KiB
Go
package game
|
||
|
||
import (
|
||
"context"
|
||
crand "crypto/rand"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"go.uber.org/zap"
|
||
|
||
"scrabble/backend/internal/account"
|
||
"scrabble/backend/internal/engine"
|
||
"scrabble/backend/internal/notify"
|
||
)
|
||
|
||
// Service is the game domain: it drives the engine over a single match, persists
|
||
// the event-sourced journal, keeps live games warm in a cache, serves hints and
|
||
// the word-check tool, exports GCG and runs the turn-timeout sweeper. It is the
|
||
// only writer of the game tables and is safe for concurrent use (per-game
|
||
// serialised by an internal keyed mutex).
|
||
type Service struct {
|
||
store *Store
|
||
accounts *account.Store
|
||
registry *engine.Registry
|
||
cache *gameCache
|
||
locks *keyedMutex
|
||
// verMu guards version, the dictionary version new games pin. It is read on
|
||
// the game-create path and rewritten when an operator activates a new version
|
||
// through the admin console, so access is serialised.
|
||
verMu sync.RWMutex
|
||
version string
|
||
clock func() time.Time
|
||
rng func() int64
|
||
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)
|
||
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
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// triggerAI fires the honest-AI fast-move hook for an active vs_ai game (best-effort,
|
||
// fire-and-forget). It is a no-op for non-AI games, finished games and when no hook is
|
||
// installed, so callers can invoke it unconditionally after a create or commit.
|
||
func (svc *Service) triggerAI(g Game) {
|
||
if svc.aiTrigger != nil && g.VsAI && g.Status == StatusActive {
|
||
svc.aiTrigger(g.ID)
|
||
}
|
||
}
|
||
|
||
// activeVersion returns the dictionary version new games currently pin.
|
||
func (svc *Service) activeVersion() string {
|
||
svc.verMu.RLock()
|
||
defer svc.verMu.RUnlock()
|
||
return svc.version
|
||
}
|
||
|
||
// ActiveVersion returns the dictionary version new games currently pin. It backs
|
||
// the admin console's "active version" display.
|
||
func (svc *Service) ActiveVersion() string {
|
||
return svc.activeVersion()
|
||
}
|
||
|
||
// InitActiveVersion reconciles the persisted active dictionary version with the
|
||
// registry at startup. If a version was persisted and is resident, it becomes the
|
||
// active version; otherwise the configured seed version is kept and persisted as
|
||
// the initial active version. Call once during wiring, before serving traffic.
|
||
func (svc *Service) InitActiveVersion(ctx context.Context) error {
|
||
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ok && svc.versionResident(persisted) {
|
||
svc.verMu.Lock()
|
||
svc.version = persisted
|
||
svc.verMu.Unlock()
|
||
return nil
|
||
}
|
||
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
|
||
}
|
||
|
||
// SetActiveVersion records version as the active dictionary version, persisting it
|
||
// and updating the in-memory pointer new games read. The caller must have made the
|
||
// version resident in the registry first.
|
||
func (svc *Service) SetActiveVersion(ctx context.Context, version string) error {
|
||
if err := svc.store.SetActiveDictVersion(ctx, version); err != nil {
|
||
return err
|
||
}
|
||
svc.verMu.Lock()
|
||
svc.version = version
|
||
svc.verMu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// versionResident reports whether any variant of version is loaded in the
|
||
// registry, so a persisted active version pointing at a no-longer-present version
|
||
// (e.g. after a volume loss) falls back to the seed.
|
||
func (svc *Service) versionResident(version string) bool {
|
||
for _, v := range engine.Variants() {
|
||
if _, err := svc.registry.Solver(v, version); err == nil {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Create starts and persists a new game seating the given accounts in turn order
|
||
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
|
||
// the player count (2–4), the move clock, the hint allowance and that every seat
|
||
// is a distinct existing account.
|
||
func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, error) {
|
||
if n := len(params.Seats); n < 2 || n > 4 {
|
||
return Game{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidConfig, n)
|
||
}
|
||
if params.HintsPerPlayer < 0 {
|
||
return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
|
||
}
|
||
timeout := params.TurnTimeout
|
||
if params.VsAI {
|
||
// Honest-AI games use the fixed 7-day inactivity clock, not a user-chosen value
|
||
// from AllowedTurnTimeouts (it is never offered in the creation UI).
|
||
timeout = AIInactivityTimeout
|
||
} else {
|
||
if timeout == 0 {
|
||
timeout = DefaultTurnTimeout
|
||
}
|
||
if !allowedTimeout(timeout) {
|
||
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
|
||
}
|
||
}
|
||
seen := make(map[uuid.UUID]bool, len(params.Seats))
|
||
for _, id := range params.Seats {
|
||
if seen[id] {
|
||
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
|
||
}
|
||
seen[id] = true
|
||
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()
|
||
}
|
||
// 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, params.Seats); err != nil {
|
||
return Game{}, err
|
||
}
|
||
svc.cache.put(id, g, params.Variant.String())
|
||
svc.metrics.recordStarted(ctx, params.Variant)
|
||
created, err := svc.store.GetGame(ctx, id)
|
||
if err != nil {
|
||
return Game{}, err
|
||
}
|
||
// 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), the caller's own still-open game on a re-enqueue, or a fresh open
|
||
// game seating only the caller with an empty opponent seat that a human or the reaper's
|
||
// robot fills later. 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) (Game, bool, error) {
|
||
if _, err := svc.accounts.GetByID(ctx, accountID); 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,
|
||
}
|
||
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat
|
||
// is left empty (uuid.Nil) for the opponent.
|
||
seats := []uuid.UUID{accountID, uuid.Nil}
|
||
if seed&1 == 1 {
|
||
seats = []uuid.UUID{uuid.Nil, accountID}
|
||
}
|
||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats)
|
||
if err != nil {
|
||
return Game{}, false, err
|
||
}
|
||
if created {
|
||
svc.metrics.recordStarted(ctx, params.Variant)
|
||
}
|
||
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 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) (Game, bool, error) {
|
||
attached, err := svc.store.AttachRobot(ctx, gameID, robotID)
|
||
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)
|
||
}
|
||
|
||
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
|
||
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
|
||
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||
v, err := svc.GameVariant(ctx, gameID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return v.Language(), nil
|
||
}
|
||
|
||
// 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); 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)
|
||
// 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)
|
||
}
|
||
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 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(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))
|
||
}
|
||
// Game pushes are routed out-of-app by the game's own language, not the recipient's
|
||
// last-login bot.
|
||
lang := post.Variant.Language()
|
||
switch post.Status {
|
||
case StatusActive:
|
||
if next, ok := seatAccount(post.Seats, post.ToMove); ok {
|
||
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)
|
||
if post.VsAI {
|
||
opponent = aiOpponentName // the player chose an AI game; show the robot as 🤖, not its pool name
|
||
}
|
||
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
|
||
yourTurn.Language = lang
|
||
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)
|
||
over.Language = lang
|
||
intents = append(intents, over)
|
||
}
|
||
}
|
||
svc.pub.Publish(intents...)
|
||
}
|
||
|
||
// displayName resolves the display name of the account at the given seat, or "" when the seat
|
||
// is absent or the lookup fails (the enriched push then falls back to its plain text).
|
||
func (svc *Service) displayName(ctx context.Context, seats []Seat, seat int) string {
|
||
if svc.accounts == nil {
|
||
return ""
|
||
}
|
||
id, ok := seatAccount(seats, seat)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
acc, err := svc.accounts.GetByID(ctx, id)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return acc.DisplayName
|
||
}
|
||
|
||
// 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)
|
||
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) {
|
||
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 == StatusFinished {
|
||
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(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, Dir: rec.Dir.String()}, nil
|
||
}
|
||
|
||
// CheckWord reports whether word is in the game's pinned dictionary. It is the
|
||
// unlimited word-check tool; an input outside the variant's alphabet is simply
|
||
// not a word.
|
||
func (svc *Service) CheckWord(ctx context.Context, gameID uuid.UUID, word string) (bool, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return svc.lookupWord(pre.Variant, pre.DictVersion, word)
|
||
}
|
||
|
||
// FileComplaint records a word-check complaint against the game's dictionary for
|
||
// later admin review, stamping the disputed lookup result.
|
||
func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UUID, word, note string) (Complaint, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return Complaint{}, err
|
||
}
|
||
if _, ok := pre.seatOf(accountID); !ok {
|
||
return Complaint{}, ErrNotAPlayer
|
||
}
|
||
normalized := normalizeWord(word)
|
||
valid, err := svc.lookupWord(pre.Variant, pre.DictVersion, normalized)
|
||
if err != nil {
|
||
return Complaint{}, err
|
||
}
|
||
return svc.store.FileComplaint(ctx, Complaint{
|
||
ComplainantID: accountID,
|
||
GameID: gameID,
|
||
Variant: pre.Variant,
|
||
DictVersion: pre.DictVersion,
|
||
Word: normalized,
|
||
WasValid: valid,
|
||
Note: note,
|
||
})
|
||
}
|
||
|
||
// ListComplaints returns word-check complaints for the admin review queue,
|
||
// newest first. status filters by lifecycle state ("" = all); limit is clamped
|
||
// to a sane page size and offset is floored at zero.
|
||
func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
|
||
return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset))
|
||
}
|
||
|
||
// GetComplaint loads a single complaint for the admin detail view.
|
||
func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
|
||
return svc.store.GetComplaint(ctx, id)
|
||
}
|
||
|
||
// CountComplaints returns the number of complaints, optionally restricted to a
|
||
// status, for the admin queue pager and the dashboard counts.
|
||
func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) {
|
||
return svc.store.CountComplaints(ctx, status)
|
||
}
|
||
|
||
// ResolveComplaint closes a complaint with an operator disposition (reject /
|
||
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
||
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
||
// change is marked applied. It returns ErrInvalidConfig for an unknown
|
||
// disposition and ErrNotFound when no complaint matches.
|
||
func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) {
|
||
if !validDisposition(disposition) {
|
||
return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition)
|
||
}
|
||
return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock())
|
||
}
|
||
|
||
// DictionaryChanges returns the pending wordlist edits implied by resolved,
|
||
// accepted complaints not yet marked applied — the input to the offline DAWG
|
||
// rebuild.
|
||
func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) {
|
||
rows, err := svc.store.ListDictionaryChanges(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]DictionaryChange, 0, len(rows))
|
||
for _, c := range rows {
|
||
ch := DictionaryChange{
|
||
ComplaintID: c.ID,
|
||
Variant: c.Variant,
|
||
Word: c.Word,
|
||
Add: c.Disposition == DispositionAcceptAdd,
|
||
Note: c.Note,
|
||
}
|
||
if c.ResolvedAt != nil {
|
||
ch.ResolvedAt = *c.ResolvedAt
|
||
}
|
||
out = append(out, ch)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// MarkChangesApplied records that every pending accepted change for variant has
|
||
// been folded into the dictionary version that was just installed, removing
|
||
// them from DictionaryChanges. It returns the number of changes marked.
|
||
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
|
||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||
}
|
||
|
||
// Hint reveals the top-scoring legal play for the requesting player on their
|
||
// turn, spending one hint from their per-game allowance and then their profile
|
||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||
// appropriate.
|
||
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return HintResult{}, err
|
||
}
|
||
seat, ok := pre.seatOf(accountID)
|
||
if !ok {
|
||
return HintResult{}, ErrNotAPlayer
|
||
}
|
||
if pre.Status == StatusFinished {
|
||
return HintResult{}, ErrFinished
|
||
}
|
||
if pre.ToMove != seat {
|
||
return HintResult{}, ErrNotYourTurn
|
||
}
|
||
if !pre.HintsAllowed {
|
||
return HintResult{}, ErrHintsDisabled
|
||
}
|
||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||
if err != nil {
|
||
return HintResult{}, err
|
||
}
|
||
used := pre.Seats[seat].HintsUsed
|
||
fromAllowance := used < pre.HintsPerPlayer
|
||
if !fromAllowance && acc.HintBalance <= 0 {
|
||
return HintResult{}, ErrNoHintsLeft
|
||
}
|
||
|
||
unlock := svc.locks.lock(gameID)
|
||
defer unlock()
|
||
g, err := svc.liveGame(ctx, pre)
|
||
if err != nil {
|
||
return HintResult{}, err
|
||
}
|
||
move, ok := g.HintView()
|
||
if !ok {
|
||
return HintResult{}, ErrNoHintAvailable
|
||
}
|
||
|
||
walletAfter := acc.HintBalance
|
||
if fromAllowance {
|
||
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 == 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),
|
||
}, 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
|
||
}
|
||
|
||
// 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.
|
||
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.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())
|
||
}
|
||
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
|
||
}
|
||
return svc.store.VoidGame(ctx, voidCommit{
|
||
gameID: pre.ID,
|
||
endReason: g.Reason().String(),
|
||
scores: scores,
|
||
now: svc.clock(),
|
||
stats: buildStats(g, statSeats),
|
||
})
|
||
}
|
||
|
||
// 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-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)
|
||
if svc.accounts == nil {
|
||
return names
|
||
}
|
||
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[:]))
|
||
}
|