635f2fd9fc
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn') - #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback - #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin) - #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4) - #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only - #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal) - ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
902 lines
29 KiB
Go
902 lines
29 KiB
Go
package game
|
||
|
||
import (
|
||
"context"
|
||
crand "crypto/rand"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"go.uber.org/zap"
|
||
|
||
"scrabble/backend/internal/account"
|
||
"scrabble/backend/internal/engine"
|
||
"scrabble/backend/internal/notify"
|
||
)
|
||
|
||
// Service is the game domain: it drives the engine over a single match, persists
|
||
// the event-sourced journal, keeps live games warm in a cache, serves hints and
|
||
// the word-check tool, exports GCG and runs the turn-timeout sweeper. It is the
|
||
// only writer of the game tables and is safe for concurrent use (per-game
|
||
// serialised by an internal keyed mutex).
|
||
type Service struct {
|
||
store *Store
|
||
accounts *account.Store
|
||
registry *engine.Registry
|
||
cache *gameCache
|
||
locks *keyedMutex
|
||
version string
|
||
clock func() time.Time
|
||
rng func() int64
|
||
pub notify.Publisher
|
||
metrics *gameMetrics
|
||
log *zap.Logger
|
||
}
|
||
|
||
// NewService constructs a Service. store and accounts wrap the same pool;
|
||
// registry holds the resident dictionaries; cfg supplies the pinned version and
|
||
// the cache idle window; log is used by the background sweeper.
|
||
func NewService(store *Store, accounts *account.Store, registry *engine.Registry, cfg Config, log *zap.Logger) *Service {
|
||
clock := func() time.Time { return time.Now().UTC() }
|
||
return &Service{
|
||
store: store,
|
||
accounts: accounts,
|
||
registry: registry,
|
||
cache: newGameCache(cfg.CacheTTL, clock),
|
||
locks: newKeyedMutex(),
|
||
version: cfg.DictVersion,
|
||
clock: clock,
|
||
rng: randomSeed,
|
||
pub: notify.Nop{},
|
||
metrics: defaultGameMetrics(),
|
||
log: log,
|
||
}
|
||
}
|
||
|
||
// SetNotifier installs the live-event publisher. It must be called during
|
||
// startup wiring, before the service serves traffic or the sweeper runs; the
|
||
// default is notify.Nop (no live events). The game service emits your_turn and
|
||
// opponent_moved after every committed move, whatever the source (a player's
|
||
// request, the robot driver or the timeout sweeper, which all funnel through
|
||
// commit).
|
||
func (svc *Service) SetNotifier(p notify.Publisher) {
|
||
if p != nil {
|
||
svc.pub = p
|
||
}
|
||
}
|
||
|
||
// Create starts and persists a new game seating the given accounts in turn order
|
||
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
|
||
// the player count (2–4), the move clock, the hint allowance and that every seat
|
||
// is a distinct existing account.
|
||
func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, error) {
|
||
if n := len(params.Seats); n < 2 || n > 4 {
|
||
return Game{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidConfig, n)
|
||
}
|
||
if params.HintsPerPlayer < 0 {
|
||
return Game{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidConfig)
|
||
}
|
||
timeout := params.TurnTimeout
|
||
if timeout == 0 {
|
||
timeout = DefaultTurnTimeout
|
||
}
|
||
if !allowedTimeout(timeout) {
|
||
return Game{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
|
||
}
|
||
seen := make(map[uuid.UUID]bool, len(params.Seats))
|
||
for _, id := range params.Seats {
|
||
if seen[id] {
|
||
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
|
||
}
|
||
seen[id] = true
|
||
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
|
||
if errors.Is(err, account.ErrNotFound) {
|
||
return Game{}, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, id)
|
||
}
|
||
return Game{}, err
|
||
}
|
||
}
|
||
|
||
seed := params.Seed
|
||
if seed == 0 {
|
||
seed = svc.rng()
|
||
}
|
||
g, err := engine.New(svc.registry, engine.Options{
|
||
Variant: params.Variant,
|
||
Version: svc.version,
|
||
Players: len(params.Seats),
|
||
Seed: seed,
|
||
DropoutTiles: params.DropoutTiles,
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
|
||
return Game{}, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||
}
|
||
return Game{}, err
|
||
}
|
||
|
||
id, err := uuid.NewV7()
|
||
if err != nil {
|
||
return Game{}, fmt.Errorf("game: new id: %w", err)
|
||
}
|
||
ins := gameInsert{
|
||
id: id,
|
||
variant: params.Variant.String(),
|
||
dictVersion: svc.version,
|
||
seed: seed,
|
||
players: len(params.Seats),
|
||
turnTimeoutSecs: int(timeout / time.Second),
|
||
hintsAllowed: params.HintsAllowed,
|
||
hintsPerPlayer: params.HintsPerPlayer,
|
||
dropoutTiles: params.DropoutTiles.String(),
|
||
}
|
||
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
||
return Game{}, err
|
||
}
|
||
svc.cache.put(id, g, params.Variant.String())
|
||
svc.metrics.recordStarted(ctx, params.Variant)
|
||
return svc.store.GetGame(ctx, id)
|
||
}
|
||
|
||
// engineOp applies one transition to the live game, returning the decoded record
|
||
// and, for an exchange, the swapped tiles.
|
||
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
|
||
|
||
// SubmitPlay validates, scores and commits the player's placement.
|
||
func (svc *Service) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (MoveResult, error) {
|
||
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
|
||
rec, err := g.SubmitPlay(dir, tiles)
|
||
return rec, nil, err
|
||
})
|
||
}
|
||
|
||
// Pass commits a forfeited turn.
|
||
func (svc *Service) Pass(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
|
||
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
|
||
rec, err := g.Pass()
|
||
return rec, nil, err
|
||
})
|
||
}
|
||
|
||
// Exchange swaps the named tiles ("?" for a blank) and commits the turn.
|
||
func (svc *Service) Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (MoveResult, error) {
|
||
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
|
||
rec, err := g.SubmitExchange(tiles)
|
||
return rec, tiles, err
|
||
})
|
||
}
|
||
|
||
// Resign ends the game on the player's turn; the remaining player wins.
|
||
func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (MoveResult, error) {
|
||
return svc.transition(ctx, gameID, accountID, func(g *engine.Game) (engine.MoveRecord, []string, error) {
|
||
rec, err := g.Resign()
|
||
return rec, nil, err
|
||
})
|
||
}
|
||
|
||
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
||
// indices to concrete letters before delegating to the letter-based play, exchange and
|
||
// word-check methods (Stage 13), keeping a single domain path shared with the robot.
|
||
func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
|
||
return svc.store.GetGameVariant(ctx, gameID)
|
||
}
|
||
|
||
// transition validates the actor and turn, applies op under the per-game lock and
|
||
// commits the result.
|
||
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
seat, ok := pre.seatOf(accountID)
|
||
if !ok {
|
||
return MoveResult{}, ErrNotAPlayer
|
||
}
|
||
if pre.Status != StatusActive {
|
||
return MoveResult{}, ErrFinished
|
||
}
|
||
if pre.ToMove != seat {
|
||
return MoveResult{}, ErrNotYourTurn
|
||
}
|
||
|
||
unlock := svc.locks.lock(gameID)
|
||
defer unlock()
|
||
|
||
g, err := svc.liveGame(ctx, pre)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
if g.Over() {
|
||
return MoveResult{}, ErrFinished
|
||
}
|
||
if g.ToMove() != seat {
|
||
return MoveResult{}, ErrNotYourTurn
|
||
}
|
||
|
||
rackBefore := g.Hand(seat)
|
||
rec, exchanged, err := op(g)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
post, err := svc.commit(ctx, gameID, g, rec, rec.Action.String(), rackBefore, exchanged, pre.Seats)
|
||
if err != nil {
|
||
return MoveResult{}, err
|
||
}
|
||
// Record the seat's think time (turn start to commit) for the move-duration
|
||
// metric; the timeout path commits separately and is excluded by design.
|
||
svc.metrics.recordMoveDuration(ctx, pre.Variant, post.MoveCount, svc.clock().Sub(pre.TurnStartedAt))
|
||
return MoveResult{Move: rec, Game: post}, nil
|
||
}
|
||
|
||
// commit persists a just-applied transition: the journal row, the post-move turn
|
||
// cursor and scores, and on a game-ending move the finish stamp and statistics.
|
||
// On a persistence failure it evicts the now-divergent live game so the next
|
||
// access rebuilds from the journal.
|
||
func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game, rec engine.MoveRecord, action string, rackBefore, exchanged []string, seats []Seat) (Game, error) {
|
||
now := svc.clock()
|
||
logLen := len(g.Log())
|
||
scores := make([]int, g.Players())
|
||
for i := range scores {
|
||
scores[i] = g.Score(i)
|
||
}
|
||
c := commit{
|
||
gameID: gameID,
|
||
seq: logLen - 1,
|
||
seat: rec.Player,
|
||
action: action,
|
||
score: rec.Score,
|
||
runningTotal: rec.Total,
|
||
exchanged: exchanged,
|
||
rec: rec,
|
||
rackBefore: rackBefore,
|
||
toMove: g.ToMove(),
|
||
turnStartedAt: now,
|
||
moveCount: logLen,
|
||
scores: scores,
|
||
now: now,
|
||
}
|
||
if g.Over() {
|
||
c.finished = true
|
||
c.finishedAt = now
|
||
c.endReason = g.Reason().String()
|
||
if action == "timeout" {
|
||
c.endReason = "timeout"
|
||
}
|
||
c.winner = g.Result().Winner
|
||
statSeats, err := svc.nonGuestSeats(ctx, seats)
|
||
if err != nil {
|
||
svc.cache.remove(gameID)
|
||
return Game{}, err
|
||
}
|
||
c.stats = buildStats(g, statSeats)
|
||
}
|
||
if err := svc.store.CommitMove(ctx, c); err != nil {
|
||
svc.cache.remove(gameID)
|
||
return Game{}, err
|
||
}
|
||
if c.finished {
|
||
svc.cache.remove(gameID)
|
||
}
|
||
post, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return Game{}, err
|
||
}
|
||
svc.emitMove(post, rec)
|
||
return post, nil
|
||
}
|
||
|
||
// emitMove publishes the live events for a just-committed move: opponent_moved to
|
||
// every seat — including the actor's own account, so the mover's other devices (and
|
||
// their lobby) refresh too — and your_turn to the next mover while the game is still
|
||
// active. opponent_moved is in-app only (the gateway never turns it into an
|
||
// out-of-app push), so the actor is not notified out of band about their own move.
|
||
// Delivery is best-effort (notify.Publisher never blocks) and the gateway fans each
|
||
// event out to all of the recipient's live streams.
|
||
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
|
||
intents := make([]notify.Intent, 0, len(post.Seats)+1)
|
||
for _, s := range post.Seats {
|
||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
|
||
}
|
||
if post.Status == StatusActive {
|
||
if next, ok := seatAccount(post.Seats, post.ToMove); ok {
|
||
deadline := post.TurnStartedAt.Add(post.TurnTimeout)
|
||
intents = append(intents, notify.YourTurn(next, post.ID, deadline))
|
||
}
|
||
}
|
||
svc.pub.Publish(intents...)
|
||
}
|
||
|
||
// seatAccount returns the account seated at the given seat index, or false when
|
||
// no seat matches (the slice is not assumed to be ordered by seat).
|
||
func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) {
|
||
for _, s := range seats {
|
||
if s.Seat == seat {
|
||
return s.AccountID, true
|
||
}
|
||
}
|
||
return uuid.UUID{}, false
|
||
}
|
||
|
||
// timeoutGame auto-resigns the to-move player of an overdue game. It re-checks,
|
||
// under the per-game lock, that the game is still active and still past the
|
||
// effective deadline (so a move made since the sweep is not clobbered), records
|
||
// the move as a timeout, and reports whether it timed the game out.
|
||
func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.Time) (bool, error) {
|
||
unlock := svc.locks.lock(gameID)
|
||
defer unlock()
|
||
|
||
cur, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if cur.Status != StatusActive {
|
||
return false, nil
|
||
}
|
||
seat := cur.ToMove
|
||
if seat < 0 || seat >= len(cur.Seats) {
|
||
return false, nil
|
||
}
|
||
acc, err := svc.accounts.GetByID(ctx, cur.Seats[seat].AccountID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
deadline := effectiveDeadline(cur.TurnStartedAt, cur.TurnTimeout, loadLocation(acc.TimeZone), minutesOfDay(acc.AwayStart), minutesOfDay(acc.AwayEnd))
|
||
if now.Before(deadline) {
|
||
return false, nil
|
||
}
|
||
|
||
g, err := svc.liveGame(ctx, cur)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if g.Over() {
|
||
return false, nil
|
||
}
|
||
rackBefore := g.Hand(g.ToMove())
|
||
rec, err := g.Resign()
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
|
||
return false, err
|
||
}
|
||
svc.metrics.recordAbandoned(ctx, cur.Variant)
|
||
return true, nil
|
||
}
|
||
|
||
// EvaluatePlay previews a tentative play for a seated player against the current
|
||
// board without committing it: whether it is legal and what it would score.
|
||
func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (EvalResult, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return EvalResult{}, err
|
||
}
|
||
if _, ok := pre.seatOf(accountID); !ok {
|
||
return EvalResult{}, ErrNotAPlayer
|
||
}
|
||
if pre.Status != StatusActive {
|
||
return EvalResult{}, ErrFinished
|
||
}
|
||
|
||
unlock := svc.locks.lock(gameID)
|
||
defer unlock()
|
||
g, err := svc.liveGame(ctx, pre)
|
||
if err != nil {
|
||
return EvalResult{}, err
|
||
}
|
||
validateStart := time.Now()
|
||
rec, err := g.EvaluatePlay(dir, tiles)
|
||
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
|
||
if err != nil {
|
||
if errors.Is(err, engine.ErrIllegalPlay) {
|
||
return EvalResult{Valid: false}, nil
|
||
}
|
||
return EvalResult{}, err
|
||
}
|
||
return EvalResult{Valid: true, Score: rec.Score, Words: rec.Words}, nil
|
||
}
|
||
|
||
// CheckWord reports whether word is in the game's pinned dictionary. It is the
|
||
// unlimited word-check tool; an input outside the variant's alphabet is simply
|
||
// not a word.
|
||
func (svc *Service) CheckWord(ctx context.Context, gameID uuid.UUID, word string) (bool, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return svc.lookupWord(pre.Variant, pre.DictVersion, word)
|
||
}
|
||
|
||
// FileComplaint records a word-check complaint against the game's dictionary for
|
||
// later admin review, stamping the disputed lookup result.
|
||
func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UUID, word, note string) (Complaint, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return Complaint{}, err
|
||
}
|
||
if _, ok := pre.seatOf(accountID); !ok {
|
||
return Complaint{}, ErrNotAPlayer
|
||
}
|
||
normalized := normalizeWord(word)
|
||
valid, err := svc.lookupWord(pre.Variant, pre.DictVersion, normalized)
|
||
if err != nil {
|
||
return Complaint{}, err
|
||
}
|
||
return svc.store.FileComplaint(ctx, Complaint{
|
||
ComplainantID: accountID,
|
||
GameID: gameID,
|
||
Variant: pre.Variant,
|
||
DictVersion: pre.DictVersion,
|
||
Word: normalized,
|
||
WasValid: valid,
|
||
Note: note,
|
||
})
|
||
}
|
||
|
||
// ListComplaints returns word-check complaints for the admin review queue,
|
||
// newest first. status filters by lifecycle state ("" = all); limit is clamped
|
||
// to a sane page size and offset is floored at zero.
|
||
func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
|
||
return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset))
|
||
}
|
||
|
||
// GetComplaint loads a single complaint for the admin detail view.
|
||
func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
|
||
return svc.store.GetComplaint(ctx, id)
|
||
}
|
||
|
||
// CountComplaints returns the number of complaints, optionally restricted to a
|
||
// status, for the admin queue pager and the dashboard counts.
|
||
func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) {
|
||
return svc.store.CountComplaints(ctx, status)
|
||
}
|
||
|
||
// ResolveComplaint closes a complaint with an operator disposition (reject /
|
||
// accept_add / accept_remove) and an optional note. An accepted complaint then
|
||
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
|
||
// change is marked applied. It returns ErrInvalidConfig for an unknown
|
||
// disposition and ErrNotFound when no complaint matches.
|
||
func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) {
|
||
if !validDisposition(disposition) {
|
||
return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition)
|
||
}
|
||
return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock())
|
||
}
|
||
|
||
// DictionaryChanges returns the pending wordlist edits implied by resolved,
|
||
// accepted complaints not yet marked applied — the input to the offline DAWG
|
||
// rebuild.
|
||
func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) {
|
||
rows, err := svc.store.ListDictionaryChanges(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]DictionaryChange, 0, len(rows))
|
||
for _, c := range rows {
|
||
ch := DictionaryChange{
|
||
ComplaintID: c.ID,
|
||
Variant: c.Variant,
|
||
Word: c.Word,
|
||
Add: c.Disposition == DispositionAcceptAdd,
|
||
Note: c.Note,
|
||
}
|
||
if c.ResolvedAt != nil {
|
||
ch.ResolvedAt = *c.ResolvedAt
|
||
}
|
||
out = append(out, ch)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// MarkChangesApplied records that every pending accepted change for variant has
|
||
// been folded into the dictionary version that was just hot-reloaded, removing
|
||
// them from DictionaryChanges. It returns the number of changes marked.
|
||
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
|
||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||
}
|
||
|
||
// Hint reveals the top-scoring legal play for the requesting player on their
|
||
// turn, spending one hint from their per-game allowance and then their profile
|
||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||
// appropriate.
|
||
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return HintResult{}, err
|
||
}
|
||
seat, ok := pre.seatOf(accountID)
|
||
if !ok {
|
||
return HintResult{}, ErrNotAPlayer
|
||
}
|
||
if pre.Status != StatusActive {
|
||
return HintResult{}, ErrFinished
|
||
}
|
||
if pre.ToMove != seat {
|
||
return HintResult{}, ErrNotYourTurn
|
||
}
|
||
if !pre.HintsAllowed {
|
||
return HintResult{}, ErrHintsDisabled
|
||
}
|
||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||
if err != nil {
|
||
return HintResult{}, err
|
||
}
|
||
used := pre.Seats[seat].HintsUsed
|
||
fromAllowance := used < pre.HintsPerPlayer
|
||
if !fromAllowance && acc.HintBalance <= 0 {
|
||
return HintResult{}, ErrNoHintsLeft
|
||
}
|
||
|
||
unlock := svc.locks.lock(gameID)
|
||
defer unlock()
|
||
g, err := svc.liveGame(ctx, pre)
|
||
if err != nil {
|
||
return HintResult{}, err
|
||
}
|
||
move, ok := g.HintView()
|
||
if !ok {
|
||
return HintResult{}, ErrNoHintAvailable
|
||
}
|
||
|
||
walletAfter := acc.HintBalance
|
||
if fromAllowance {
|
||
if err := svc.store.SpendHintAllowance(ctx, gameID, seat); err != nil {
|
||
return HintResult{}, err
|
||
}
|
||
used++
|
||
} else {
|
||
spent, err := svc.accounts.SpendHint(ctx, accountID)
|
||
if err != nil {
|
||
return HintResult{}, err
|
||
}
|
||
if !spent {
|
||
return HintResult{}, ErrNoHintsLeft
|
||
}
|
||
walletAfter--
|
||
}
|
||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
|
||
}
|
||
|
||
// Candidates returns the to-move player's legal plays for a seated player on
|
||
// their turn, ranked by descending score. It is the read the robot opponent uses
|
||
// to choose a move by margin; it spends nothing and mutates no state. It returns
|
||
// ErrNotAPlayer, ErrFinished or ErrNotYourTurn like the other turn-scoped reads.
|
||
func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
seat, ok := pre.seatOf(accountID)
|
||
if !ok {
|
||
return nil, ErrNotAPlayer
|
||
}
|
||
if pre.Status != StatusActive {
|
||
return nil, ErrFinished
|
||
}
|
||
if pre.ToMove != seat {
|
||
return nil, ErrNotYourTurn
|
||
}
|
||
|
||
unlock := svc.locks.lock(gameID)
|
||
defer unlock()
|
||
g, err := svc.liveGame(ctx, pre)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return g.Candidates(), nil
|
||
}
|
||
|
||
// RobotTurns returns the robot driver's view of every active game seating one of
|
||
// robotIDs. It is the robot scheduler's periodic scan, mirroring the timeout
|
||
// sweeper's ActiveGames read; the driver derives each robot's deadline from the
|
||
// returned seed and turn cursor.
|
||
func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]RobotTurn, error) {
|
||
return svc.store.RobotTurns(ctx, robotIDs)
|
||
}
|
||
|
||
// GameState returns a seated player's view of the game: the shared summary plus
|
||
// their private rack, the bag size and their remaining hint budget.
|
||
func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) {
|
||
pre, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return StateView{}, err
|
||
}
|
||
seat, ok := pre.seatOf(accountID)
|
||
if !ok {
|
||
return StateView{}, ErrNotAPlayer
|
||
}
|
||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||
if err != nil {
|
||
return StateView{}, err
|
||
}
|
||
|
||
unlock := svc.locks.lock(gameID)
|
||
defer unlock()
|
||
g, err := svc.liveGame(ctx, pre)
|
||
if err != nil {
|
||
return StateView{}, err
|
||
}
|
||
return StateView{
|
||
Game: pre,
|
||
Seat: seat,
|
||
Rack: g.Hand(seat),
|
||
BagLen: g.BagLen(),
|
||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||
}, nil
|
||
}
|
||
|
||
// Participants returns the seated account IDs in seat order, the seat index whose
|
||
// turn it is, and the game status. It is a snapshot read (no engine, no lock) that
|
||
// lets the social package gate per-game chat and nudges without importing the
|
||
// engine or the game's private state.
|
||
func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) {
|
||
g, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return nil, 0, "", err
|
||
}
|
||
seats := make([]uuid.UUID, len(g.Seats))
|
||
for _, s := range g.Seats {
|
||
seats[s.Seat] = s.AccountID
|
||
}
|
||
return seats, g.ToMove, g.Status, nil
|
||
}
|
||
|
||
// SharedGame reports whether accounts a and b are seated together in any game
|
||
// (active or finished). It backs the social package's "befriend an opponent"
|
||
// request gate without exposing the games tables; a self-pair is never shared.
|
||
func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) {
|
||
if a == b {
|
||
return false, nil
|
||
}
|
||
return svc.store.SharedGameExists(ctx, a, b)
|
||
}
|
||
|
||
// ListForAccount returns every game the account is seated in, newest first, for the
|
||
// lobby's active/finished lists. The live position is not loaded — the summaries come
|
||
// straight from the durable rows.
|
||
func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
|
||
return svc.store.ListGamesForAccount(ctx, accountID)
|
||
}
|
||
|
||
// GameByID returns a game with its seats for the admin console detail view.
|
||
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
|
||
return svc.store.GetGame(ctx, id)
|
||
}
|
||
|
||
// ListGames returns games for the admin list, newest-updated first, paginated,
|
||
// optionally filtered by status.
|
||
func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
|
||
return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset))
|
||
}
|
||
|
||
// CountGames returns the game count, optionally filtered by status, for the admin
|
||
// list pager and dashboard.
|
||
func (svc *Service) CountGames(ctx context.Context, status string) (int, error) {
|
||
return svc.store.CountGames(ctx, status)
|
||
}
|
||
|
||
// History returns a game's full, dictionary-independent move journal.
|
||
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
||
g, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return HistoryView{}, err
|
||
}
|
||
moves, err := svc.store.GetJournal(ctx, gameID)
|
||
if err != nil {
|
||
return HistoryView{}, err
|
||
}
|
||
return HistoryView{Game: g, Moves: moves}, nil
|
||
}
|
||
|
||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
|
||
// is allowed only on a finished game: exporting an in-progress game would leak the
|
||
// full move journal mid-play, so an active game yields ErrGameActive.
|
||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||
g, err := svc.store.GetGame(ctx, gameID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if g.Status != StatusFinished {
|
||
return "", ErrGameActive
|
||
}
|
||
moves, err := svc.store.GetJournal(ctx, gameID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return writeGCG(g, svc.seatNames(ctx, g), moves), nil
|
||
}
|
||
|
||
// liveGame returns the live engine.Game for pre, rebuilding it from the journal
|
||
// on a cache miss. Callers must hold the per-game lock.
|
||
func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error) {
|
||
if g, ok := svc.cache.get(pre.ID); ok {
|
||
return g, nil
|
||
}
|
||
g, err := svc.replay(ctx, pre)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !g.Over() {
|
||
svc.cache.put(pre.ID, g, pre.Variant.String())
|
||
}
|
||
return g, nil
|
||
}
|
||
|
||
// replay reconstructs an engine.Game by dealing from the pinned seed and
|
||
// re-applying every journalled move in order. The deterministic bag makes the
|
||
// reconstruction exact.
|
||
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
|
||
defer svc.metrics.recordReplay(ctx, pre.Variant, time.Now())
|
||
seed, err := svc.store.GameSeed(ctx, pre.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
g, err := engine.New(svc.registry, engine.Options{
|
||
Variant: pre.Variant,
|
||
Version: pre.DictVersion,
|
||
Players: pre.Players,
|
||
Seed: seed,
|
||
DropoutTiles: pre.DropoutTiles,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
moves, err := svc.store.GetJournal(ctx, pre.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, mv := range moves {
|
||
if err := replayMove(g, mv); err != nil {
|
||
return nil, fmt.Errorf("game: replay %s move %d: %w", pre.ID, mv.Seq, err)
|
||
}
|
||
}
|
||
return g, nil
|
||
}
|
||
|
||
// replayMove re-applies one journalled move to g through the decoded engine API.
|
||
func replayMove(g *engine.Game, mv HistoryMove) error {
|
||
switch mv.Action {
|
||
case "play":
|
||
dir := engine.Horizontal
|
||
if mv.Dir == "V" {
|
||
dir = engine.Vertical
|
||
}
|
||
_, err := g.SubmitPlay(dir, mv.Tiles)
|
||
return err
|
||
case "pass":
|
||
_, err := g.Pass()
|
||
return err
|
||
case "exchange":
|
||
_, err := g.SubmitExchange(mv.Exchanged)
|
||
return err
|
||
case "resign", "timeout":
|
||
_, err := g.Resign()
|
||
return err
|
||
default:
|
||
return fmt.Errorf("unknown action %q", mv.Action)
|
||
}
|
||
}
|
||
|
||
// buildStats derives each seat's statistics contribution from a finished game:
|
||
// win/loss/draw from the (resignation-aware) winner, the final score, and the
|
||
// best single-move score from the log.
|
||
func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||
res := g.Result()
|
||
best := make(map[int]int)
|
||
for _, rec := range g.Log() {
|
||
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
|
||
best[rec.Player] = rec.Score
|
||
}
|
||
}
|
||
out := make([]statDelta, 0, len(seats))
|
||
for _, s := range seats {
|
||
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
|
||
switch {
|
||
case res.Winner < 0:
|
||
d.draws = 1
|
||
case res.Winner == s.Seat:
|
||
d.wins = 1
|
||
default:
|
||
d.losses = 1
|
||
}
|
||
out = append(out, d)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// nonGuestSeats filters out guest seats so the finish-time statistics are
|
||
// recomputed for durable non-guest accounts only — guests never accrue
|
||
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
|
||
func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, error) {
|
||
out := make([]Seat, 0, len(seats))
|
||
for _, s := range seats {
|
||
acc, err := svc.accounts.GetByID(ctx, s.AccountID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if acc.IsGuest {
|
||
continue
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// seatNames resolves each seat's display name for GCG export.
|
||
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
|
||
names := make([]string, g.Players)
|
||
for _, s := range g.Seats {
|
||
if acc, err := svc.accounts.GetByID(ctx, s.AccountID); err == nil {
|
||
names[s.Seat] = acc.DisplayName
|
||
}
|
||
}
|
||
return names
|
||
}
|
||
|
||
// lookupWord checks word against a variant/version dictionary, treating an
|
||
// out-of-alphabet input as simply not a word (a real registry error still
|
||
// surfaces).
|
||
func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bool, error) {
|
||
present, err := svc.registry.Lookup(variant, version, normalizeWord(word))
|
||
if err != nil {
|
||
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
|
||
return false, err
|
||
}
|
||
return false, nil
|
||
}
|
||
return present, nil
|
||
}
|
||
|
||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
||
// allowance plus the profile wallet.
|
||
func hintsRemaining(allowance, used, wallet int) int {
|
||
return max(0, allowance-used) + wallet
|
||
}
|
||
|
||
// allowedTimeout reports whether d is one of the offered move clocks.
|
||
func allowedTimeout(d time.Duration) bool {
|
||
return slices.Contains(AllowedTurnTimeouts, d)
|
||
}
|
||
|
||
// normalizeWord lower-cases and trims a word-check input to the alphabet's form.
|
||
func normalizeWord(word string) string {
|
||
return strings.ToLower(strings.TrimSpace(word))
|
||
}
|
||
|
||
// validDisposition reports whether d is an accepted complaint disposition.
|
||
func validDisposition(d string) bool {
|
||
switch d {
|
||
case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// clampPageSize bounds an admin list page size to [1, 200], defaulting an unset
|
||
// (non-positive) request to 50.
|
||
func clampPageSize(limit int) int {
|
||
switch {
|
||
case limit <= 0:
|
||
return 50
|
||
case limit > 200:
|
||
return 200
|
||
default:
|
||
return limit
|
||
}
|
||
}
|
||
|
||
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
|
||
// system source fails.
|
||
func randomSeed() int64 {
|
||
var b [8]byte
|
||
if _, err := crand.Read(b[:]); err != nil {
|
||
return time.Now().UnixNano()
|
||
}
|
||
return int64(binary.LittleEndian.Uint64(b[:]))
|
||
}
|