Files
scrabble-game/backend/internal/server/handlers_admin_replay.go
T
Ilia Denisov caefc8f579
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
feat(game): official first-move tile draw + admin step-by-step replay
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.
2026-06-20 08:47:18 +02:00

169 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}