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
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.
169 lines
4.8 KiB
Go
169 lines
4.8 KiB
Go
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
|
||
}
|