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:
@@ -18,8 +18,9 @@ import (
|
||||
// The open-game suite covers an auto-match game that a player enters immediately and
|
||||
// waits inside (status 'open', the opponent seat empty) until a human or a robot joins.
|
||||
|
||||
// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
|
||||
// moves first) whose fresh two-player English opening rack has a legal move.
|
||||
// evenOpeningSeed returns a seed whose fresh two-player English opening rack (seat 0) has a
|
||||
// legal move, for tests that drive or attempt an opening play. It scans even seeds; seat
|
||||
// order no longer depends on the seed — the first-move draw decides it (docs/ARCHITECTURE.md §6).
|
||||
func evenOpeningSeed(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
for seed := int64(2); seed <= 400; seed += 2 {
|
||||
@@ -59,9 +60,9 @@ func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) ga
|
||||
return g
|
||||
}
|
||||
|
||||
// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
|
||||
// while the game is open, after which it is the empty opponent seat's turn and the
|
||||
// starter just waits.
|
||||
// TestOpenGameStarterMovesThenWaits checks the starter may move on their turn while the game
|
||||
// is open (the first-move draw, run when the game opened, put them at seat 0), after which it
|
||||
// is the empty opponent seat's turn and the starter just waits.
|
||||
func TestOpenGameStarterMovesThenWaits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
@@ -89,28 +90,30 @@ func TestOpenGameStarterMovesThenWaits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
|
||||
// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
|
||||
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the first-move draw seats
|
||||
// the starter at seat 1 (the synthetic opponent won the open draw), the still-empty seat 0 is
|
||||
// to move, so the starter cannot act until an opponent fills it.
|
||||
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
svc.SetFirstMoveEntropy(seatOneFirstMove) // the synthetic opponent wins → the caller sits at seat 1
|
||||
starter := provisionAccount(t)
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
|
||||
t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
|
||||
t.Fatalf("opponent-won open draw: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
|
||||
}
|
||||
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
|
||||
t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after
|
||||
// the starter has already made their first move, landing on the board mid-opening and
|
||||
// able to act on their turn.
|
||||
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after the
|
||||
// starter has already made their first move: the seats were fixed by the draw at open, so the
|
||||
// joiner takes the empty seat without disturbing the journal, and can act on their turn.
|
||||
func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
|
||||
Reference in New Issue
Block a user