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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user