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:
@@ -127,11 +127,14 @@ type seatInsert struct {
|
||||
displayName string
|
||||
}
|
||||
|
||||
// CreateGame inserts the games row and one game_players row per seat (seat 0
|
||||
// first) inside a single transaction.
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert) error {
|
||||
// CreateGame inserts the games row, one game_players row per seat (seat 0 first) and
|
||||
// the first-move seeding draws inside a single transaction.
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInsert, draws []SetupDraw) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
return insertGameTx(ctx, tx, ins, seats)
|
||||
if err := insertGameTx(ctx, tx, ins, seats); err != nil {
|
||||
return err
|
||||
}
|
||||
return insertSetupDrawsTx(ctx, tx, ins.id, draws)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -173,6 +176,30 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertSetupDrawsTx appends the first-move seeding draws for game gameID on tx —
|
||||
// the dictionary-independent record of how the first player was chosen
|
||||
// (docs/ARCHITECTURE.md §6). The games row must already exist on tx (the foreign
|
||||
// key), so it runs after insertGameTx. An empty draws slice is a no-op.
|
||||
func insertSetupDrawsTx(ctx context.Context, tx *sql.Tx, gameID uuid.UUID, draws []SetupDraw) error {
|
||||
for _, d := range draws {
|
||||
// A uuid.Nil account marks the synthetic opponent of an auto-match draw, persisted as
|
||||
// a NULL account_id and back-filled when a real opponent joins.
|
||||
var acc any = d.Account
|
||||
if d.Account == uuid.Nil {
|
||||
acc = postgres.NULL
|
||||
}
|
||||
di := table.GameSetupDraws.INSERT(
|
||||
table.GameSetupDraws.GameID, table.GameSetupDraws.Round, table.GameSetupDraws.PickNo,
|
||||
table.GameSetupDraws.AccountID, table.GameSetupDraws.Letter, table.GameSetupDraws.IsBlank,
|
||||
table.GameSetupDraws.DrawRank,
|
||||
).VALUES(gameID, d.Round, d.PickNo, acc, d.Letter, d.Blank, d.Rank)
|
||||
if _, err := di.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert setup draw round %d pick %d: %w", d.Round, d.PickNo, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the
|
||||
// advisory-lock key that serialises concurrent enqueues for that bucket, so two
|
||||
// players never both open a game instead of pairing.
|
||||
@@ -199,8 +226,9 @@ func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen
|
||||
// order) used only when a game is created; callerName is the caller's display-name
|
||||
// snapshot, stamped on their seat whether they open a fresh game or fill another
|
||||
// player's open one.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
// player's open one. seats and draws (the first-move draw recorded against the synthetic
|
||||
// opponent, docs/ARCHITECTURE.md §6) are used only when a game is created.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert, exclude []uuid.UUID, draws []SetupDraw) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
@@ -222,7 +250,7 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID, uuidArrayLiteral(exclude)).Scan(&other); {
|
||||
case e == nil:
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID, callerName); er != nil {
|
||||
if er := fillAndActivate(ctx, tx, other, accountID, callerName); er != nil {
|
||||
return er
|
||||
}
|
||||
gameID, joined = other, true
|
||||
@@ -230,10 +258,15 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
|
||||
case !errors.Is(e, sql.ErrNoRows):
|
||||
return fmt.Errorf("find open game: %w", e)
|
||||
}
|
||||
// 2. None waiting — open a fresh game seating the caller (the other seat empty).
|
||||
// 2. None waiting — open a fresh game seating the caller (the other seat empty) and
|
||||
// recording the first-move draw; the synthetic opponent's rows carry a NULL account
|
||||
// until a real opponent joins and back-fills them.
|
||||
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := insertSetupDrawsTx(ctx, tx, ins.id, draws); e != nil {
|
||||
return e
|
||||
}
|
||||
gameID, created = ins.id, true
|
||||
return nil
|
||||
})
|
||||
@@ -258,7 +291,7 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
|
||||
if status != StatusOpen {
|
||||
return nil
|
||||
}
|
||||
if e := fillOpenSeat(ctx, tx, gameID, robotID, displayName); e != nil {
|
||||
if e := fillAndActivate(ctx, tx, gameID, robotID, displayName); e != nil {
|
||||
return e
|
||||
}
|
||||
attached = true
|
||||
@@ -267,15 +300,23 @@ func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, disp
|
||||
return attached, err
|
||||
}
|
||||
|
||||
// fillOpenSeat seats accountID in an open game's empty opponent seat — stamping
|
||||
// displayName as the seat's display-name snapshot — and flips the game to active with a
|
||||
// fresh turn clock. The caller holds the game row.
|
||||
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
|
||||
// fillAndActivate seats accountID in an open game's empty opponent seat — stamping
|
||||
// displayName as the seat's snapshot — back-fills the first-move draw rows the open game
|
||||
// recorded for the then-unknown opponent (docs/ARCHITECTURE.md §6), and flips the game to
|
||||
// active with a fresh turn clock. The seat the joiner takes was already fixed by the draw at
|
||||
// open time, so the journal (any opening move the starter made while waiting) is never
|
||||
// disturbed. The caller holds the game row.
|
||||
func fillAndActivate(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID, displayName string) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.game_players SET account_id = $2, display_name = $3 WHERE game_id = $1 AND account_id IS NULL`,
|
||||
gameID, accountID, displayName); err != nil {
|
||||
return fmt.Errorf("fill opponent seat: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.game_setup_draws SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
|
||||
gameID, accountID); err != nil {
|
||||
return fmt.Errorf("back-fill opponent draws: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
|
||||
WHERE game_id = $1`, gameID); err != nil {
|
||||
@@ -587,6 +628,37 @@ func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, er
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetupDraws loads the ordered first-move seeding draws for a game (round then pick
|
||||
// order), or an empty slice for a game created before the draw was recorded. It
|
||||
// backs the admin console's first-move section.
|
||||
func (s *Store) SetupDraws(ctx context.Context, id uuid.UUID) ([]SetupDraw, error) {
|
||||
stmt := postgres.SELECT(table.GameSetupDraws.AllColumns).
|
||||
FROM(table.GameSetupDraws).
|
||||
WHERE(table.GameSetupDraws.GameID.EQ(postgres.UUID(id))).
|
||||
ORDER_BY(table.GameSetupDraws.Round.ASC(), table.GameSetupDraws.PickNo.ASC())
|
||||
var rows []model.GameSetupDraws
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("game: get setup draws %s: %w", id, err)
|
||||
}
|
||||
out := make([]SetupDraw, len(rows))
|
||||
for i, r := range rows {
|
||||
// A NULL account is the synthetic opponent of an auto-match draw not yet back-filled.
|
||||
acc := uuid.Nil
|
||||
if r.AccountID != nil {
|
||||
acc = *r.AccountID
|
||||
}
|
||||
out[i] = SetupDraw{
|
||||
Round: int(r.Round),
|
||||
PickNo: int(r.PickNo),
|
||||
Account: acc,
|
||||
Letter: r.Letter,
|
||||
Blank: r.IsBlank,
|
||||
Rank: int(r.DrawRank),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CommitMove appends the move and applies the post-move game state — the turn
|
||||
// cursor and per-seat scores, plus the finish stamp and statistics when the move
|
||||
// ended the game — in one transaction.
|
||||
|
||||
Reference in New Issue
Block a user