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

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:
Ilia Denisov
2026-06-20 08:47:18 +02:00
parent 76d4610e6f
commit caefc8f579
27 changed files with 1661 additions and 59 deletions
+36 -2
View File
@@ -26,9 +26,10 @@ import (
// assembly, and the stats reader. Helpers used by a single test file stay in
// that file; everything reused across files lives here.
// newGameService builds a game service over the shared pool and registry.
// newGameService builds a game service over the shared pool and registry, with a
// deterministic first-move draw so the suite keeps a stable turn order.
func newGameService() *game.Service {
return game.NewService(
svc := game.NewService(
game.NewStore(testDB),
account.NewStore(testDB),
testRegistry,
@@ -40,6 +41,39 @@ func newGameService() *game.Service {
},
zap.NewNop(),
)
svc.SetFirstMoveEntropy(seatZeroFirstMove)
return svc
}
// seatZeroFirstMove is a first-move-draw entropy factory that always elects the first
// listed account as the leader, keeping the integration suite's turn order stable
// despite the real draw's randomness: the first contender draws a blank — the best
// possible tile — and wins outright, the rest draw the first remaining tile. It is a
// factory so each game restarts the one-shot "blank" pick.
func seatZeroFirstMove() func(n int) (int, error) {
first := true
return func(n int) (int, error) {
if first {
first = false
return n - 1, nil // a blank sits last in the bag → best rank
}
return 0, nil
}
}
// seatOneFirstMove is a first-move-draw entropy factory that elects the second contender (in
// auto-match, the synthetic opponent) as the leader, so the caller is seated at seat 1: the
// first contender draws the first remaining tile and the second draws a blank, winning.
func seatOneFirstMove() func(n int) (int, error) {
pick := 0
return func(n int) (int, error) {
i := 0
if pick == 1 {
i = n - 1 // the second contender draws a blank → best rank
}
pick++
return i, nil
}
}
// newSocialService builds a social service over the shared pool, reading game