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
@@ -523,6 +523,34 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
}
}
}
// First-move draw (docs/ARCHITECTURE.md §6) and the step-by-step replay timeline. Both are
// best-effort: a game that predates the draw or cannot be replayed still renders its
// summary and seats.
names := make(map[string]string, len(view.Seats))
for _, st := range view.Seats {
names[st.AccountID] = st.DisplayName
}
if draws, derr := s.games.SetupDraws(ctx, g.ID); derr == nil {
for _, d := range draws {
row := adminconsole.SetupDrawRow{Round: d.Round, Letter: strings.ToUpper(d.Letter), Blank: d.Blank, Rank: d.Rank}
if d.Account == uuid.Nil {
row.Name = "(opponent)"
} else {
row.Name = names[d.Account.String()]
row.AccountID = d.Account.String()
}
view.SetupDraws = append(view.SetupDraws, row)
}
}
if len(view.Seats) > 0 {
view.FirstMover = view.Seats[0].DisplayName
}
if tl, terr := s.games.ReplayTimeline(ctx, g.ID); terr == nil {
if rj, jerr := buildReplayJSON(g.Variant, tl, view.Seats); jerr == nil {
view.ReplayJSON = rj
view.HasReplay = len(tl.Steps) > 0
}
}
s.renderConsole(c, "game_detail", "games", "Game", view)
}
@@ -0,0 +1,168 @@
package server
import (
"encoding/json"
"html/template"
"strings"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// The admin game-replay payload embedded in the game_detail page for its vanilla-JS stepper:
// the premium board layout, the seats and one step per replay frame (the dealt racks, then
// one per move) carrying the resulting racks, scores, turn cursor and bag size. Letters are
// upper-cased for display and carry their tile value (0 renders without a subscript).
type replayTileJSON struct {
L string `json:"l"`
V int `json:"v"`
B bool `json:"b,omitempty"`
}
type replayPlaceJSON struct {
R int `json:"r"`
C int `json:"c"`
L string `json:"l"`
V int `json:"v"`
B bool `json:"b,omitempty"`
}
type replayMoveJSON struct {
Seat int `json:"seat"`
Action string `json:"action"`
Words []string `json:"words,omitempty"`
Score int `json:"score"`
Placements []replayPlaceJSON `json:"placements,omitempty"`
Exchanged []replayTileJSON `json:"exchanged,omitempty"`
}
type replayStepJSON struct {
Move *replayMoveJSON `json:"move"`
Drawn []replayTileJSON `json:"drawn,omitempty"`
Racks [][]replayTileJSON `json:"racks"`
Scores []int `json:"scores"`
ToMove int `json:"toMove"`
BagLen int `json:"bagLen"`
}
type replaySeatJSON struct {
Seat int `json:"seat"`
Name string `json:"name"`
AccountID string `json:"accountId"`
}
type replayDataJSON struct {
Centre [2]int `json:"centre"`
Premium [][]string `json:"premium"`
Seats []replaySeatJSON `json:"seats"`
Steps []replayStepJSON `json:"steps"`
}
// buildReplayJSON renders a game's replay timeline as the JSON the stepper consumes, resolving
// each tile's value from the variant's alphabet. The result is safe to embed in a <script>:
// encoding/json escapes <, > and & to \u00xx, so a user-chosen seat name cannot break out.
func buildReplayJSON(variant engine.Variant, tl game.ReplayTimelineView, seats []adminconsole.SeatRow) (template.JS, error) {
values := map[string]int{}
if table, err := engine.AlphabetTable(variant); err == nil {
for _, e := range table {
values[e.Letter] = e.Value
}
}
tile := func(letter string, blank bool) replayTileJSON {
v := 0
if !blank {
v = values[strings.ToLower(letter)]
}
return replayTileJSON{L: strings.ToUpper(letter), V: v, B: blank}
}
tiles := func(letters []string) []replayTileJSON {
out := make([]replayTileJSON, len(letters))
for i, l := range letters {
out[i] = tile(l, l == "?")
}
return out
}
data := replayDataJSON{Centre: [2]int{7, 7}, Premium: premiumGrid(variant)}
for _, s := range seats {
data.Seats = append(data.Seats, replaySeatJSON{Seat: s.Seat, Name: s.DisplayName, AccountID: s.AccountID})
}
for _, st := range tl.Steps {
js := replayStepJSON{
Racks: make([][]replayTileJSON, len(st.Racks)),
Scores: st.Scores,
ToMove: st.ToMove,
BagLen: st.BagLen,
Drawn: tiles(st.Drawn),
}
for i, r := range st.Racks {
js.Racks[i] = tiles(r)
}
if st.Move != nil {
m := &replayMoveJSON{Seat: st.Move.Seat, Action: st.Move.Action, Words: st.Move.Words, Score: st.Move.Score}
for _, p := range st.Move.Tiles {
t := tile(p.Letter, p.Blank)
m.Placements = append(m.Placements, replayPlaceJSON{R: p.Row, C: p.Col, L: t.L, V: t.V, B: t.B})
}
m.Exchanged = tiles(st.Move.Exchanged)
js.Move = m
}
data.Steps = append(data.Steps, js)
}
b, err := json.Marshal(data)
if err != nil {
return "", err
}
return template.JS(b), nil
}
// premiumRows is the standard Scrabble premium-square layout: T triple-word, D double-word,
// t triple-letter, d double-letter, * the centre double-word, '.' a plain square.
var premiumRows = []string{
"T..d...T...d..T",
".D...t...t...D.",
"..D...d.d...D..",
"d..D...d...D..d",
"....D.....D....",
".t...t...t...t.",
"..d...d.d...d..",
"T..d...*...d..T",
"..d...d.d...d..",
".t...t...t...t.",
"....D.....D....",
"d..D...d...D..d",
"..D...d.d...D..",
".D...t...t...D.",
"T..d...T...d..T",
}
// premiumGrid returns the 15×15 premium-square codes ("tw"/"dw"/"tl"/"dl"/"") for variant.
// Erudite carries no double-word premium on the centre square (only the start star).
func premiumGrid(variant engine.Variant) [][]string {
code := func(ch byte) string {
switch ch {
case 'T':
return "tw"
case 'D', '*':
return "dw"
case 't':
return "tl"
case 'd':
return "dl"
}
return ""
}
grid := make([][]string, len(premiumRows))
for r, row := range premiumRows {
grid[r] = make([]string, len(row))
for c := 0; c < len(row); c++ {
grid[r][c] = code(row[c])
}
}
if variant == engine.VariantErudit {
grid[7][7] = ""
}
return grid
}