feat(game): official first-move tile draw + admin step-by-step replay
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Decide who moves first by the official rule: each seated player draws one tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader remains. Each draw uses honest per-draw crypto/rand entropy (not the deterministic bag seed), so the recorded draw — not a seed — is the only account of the outcome. The leader takes seat 0, so the engine and journal replay are unchanged. The draw is recorded with the game (game_setup_draws, migration 00013) for future tournaments, designed as a discrete "player N draws a tile" step. Friend/AI games draw at creation. Auto-match draws when the game opens, against a synthetic uuid.Nil opponent whose draw rows (NULL account_id) are back-filled to the real opponent on join — so the opener's seat is fixed up front and the existing open-game pre-move is preserved with no reseating. Admin /_gm/games/:id gains the recorded draw list and a simple step-by-step board replay (game.ReplayTimeline + a vanilla-JS stepper): a board with A-O/1-15 headers and highlighted premium squares, placed letters with their tile value as a subscript, rack panels around the board (seat 0 top, 1 bottom, 2 left, 3 right) with the current player highlighted, and a per-move log with the tiles drawn and the bag remainder. Docs: ARCHITECTURE §6/§9, FUNCTIONAL (+_ru), PRERELEASE (FM row), design spec.
This commit is contained in:
@@ -38,7 +38,12 @@ type Service struct {
|
||||
version string
|
||||
clock func() time.Time
|
||||
rng func() int64
|
||||
pub notify.Publisher
|
||||
// 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
|
||||
@@ -59,17 +64,18 @@ type Service struct {
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +107,18 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account
|
||||
svc.clearNudges = 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.
|
||||
@@ -191,15 +209,32 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
}
|
||||
}
|
||||
seen := make(map[uuid.UUID]bool, len(params.Seats))
|
||||
seats := make([]seatInsert, len(params.Seats))
|
||||
for i, id := range params.Seats {
|
||||
for _, id := range params.Seats {
|
||||
if seen[id] {
|
||||
return Game{}, fmt.Errorf("%w: account %s seated twice", ErrInvalidConfig, id)
|
||||
}
|
||||
seen[id] = true
|
||||
// Snapshot each seat's display name at creation. 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).
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -249,7 +284,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
vsAI: params.VsAI,
|
||||
}
|
||||
if err := svc.store.CreateGame(ctx, ins, seats); err != nil {
|
||||
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
svc.cache.put(id, g, params.Variant.String())
|
||||
@@ -312,14 +347,27 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
}
|
||||
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first), snapshotting their
|
||||
// display name; the other seat is left empty (a zero seatInsert) for the opponent.
|
||||
caller := seatInsert{accountID: accountID, displayName: acc.DisplayName}
|
||||
seats := []seatInsert{caller, {}}
|
||||
if seed&1 == 1 {
|
||||
seats = []seatInsert{{}, caller}
|
||||
// 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
|
||||
}
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, acc.DisplayName, ins, seats, exclude)
|
||||
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
|
||||
}
|
||||
@@ -547,8 +595,9 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
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.
|
||||
// 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
|
||||
}
|
||||
@@ -1287,6 +1336,14 @@ func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user