Files
scrabble-game/backend/internal/game/replay_timeline.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

145 lines
4.7 KiB
Go

package game
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
)
// ReplayStep is one step of an admin game replay: the move that produced it (nil for the
// initial dealt state, step 0) and the resulting position — every seat's rack, the running
// scores, whose turn it is and the bag remainder. Step k's board is the union of every
// play's placements through step k, which the renderer accumulates onto an empty grid
// (docs/ARCHITECTURE.md §9.1 visual replay).
type ReplayStep struct {
// Move is the journalled move that produced this state, or nil for the initial deal.
Move *HistoryMove
// Drawn lists the tiles the mover drew from the bag after this move ("?" for a blank);
// empty for the initial deal, a pass or a resignation.
Drawn []string
// Racks holds every seat's rack at this step, indexed by seat ("?" for a blank).
Racks [][]string
// Scores holds every seat's running score, indexed by seat.
Scores []int
// ToMove is the seat to move at this step.
ToMove int
// BagLen is the number of tiles left in the bag at this step.
BagLen int
}
// ReplayTimelineView is the admin replay of a game: the persisted game plus the ordered
// replay steps (the initial deal followed by one step per journalled move).
type ReplayTimelineView struct {
Game Game
Steps []ReplayStep
}
// ReplayTimeline rebuilds a game from its pinned seed and journal and returns the ordered
// replay steps for the admin console: the initial deal (step 0) then one step per
// journalled move, each carrying the resulting racks, scores, turn cursor, bag size and the
// tiles the mover drew. The deterministic bag makes the reconstruction exact. It needs no
// dictionary beyond the engine the seed deals, and — like the live replay — stops early if a
// committed move became illegal under tightened rules rather than failing.
func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (ReplayTimelineView, error) {
pre, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
seed, err := svc.store.GameSeed(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
})
if err != nil {
return ReplayTimelineView{}, err
}
moves, err := svc.store.GetJournal(ctx, gameID)
if err != nil {
return ReplayTimelineView{}, err
}
steps := make([]ReplayStep, 0, len(moves)+1)
steps = append(steps, snapshotStep(g, nil, nil))
for i := range moves {
mv := moves[i]
before := g.Hand(mv.Seat)
if err := replayMove(g, mv); err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
g.Abort()
break
}
return ReplayTimelineView{}, fmt.Errorf("game: replay-timeline %s move %d: %w", gameID, mv.Seq, err)
}
moveCopy := mv
steps = append(steps, snapshotStep(g, &moveCopy, drawnTiles(before, g.Hand(mv.Seat), usedTiles(mv))))
}
return ReplayTimelineView{Game: pre, Steps: steps}, nil
}
// snapshotStep captures the position after applying move (nil for the initial deal): every
// seat's rack and score, the turn cursor and the bag size, with the supplied drawn tiles.
func snapshotStep(g *engine.Game, move *HistoryMove, drawn []string) ReplayStep {
n := g.Players()
racks := make([][]string, n)
scores := make([]int, n)
for i := 0; i < n; i++ {
racks[i] = g.Hand(i)
scores[i] = g.Score(i)
}
return ReplayStep{Move: move, Drawn: drawn, Racks: racks, Scores: scores, ToMove: g.ToMove(), BagLen: g.BagLen()}
}
// usedTiles returns the rack tiles a move consumed ("?" for a blank): the placed tiles of a
// play or the swapped tiles of an exchange; a pass or resignation consumes none.
func usedTiles(mv HistoryMove) []string {
switch mv.Action {
case "play":
used := make([]string, len(mv.Tiles))
for i, t := range mv.Tiles {
if t.Blank {
used[i] = "?" // a placed blank leaves the rack as the blank marker
} else {
used[i] = t.Letter
}
}
return used
case "exchange":
return mv.Exchanged
}
return nil
}
// drawnTiles returns the tiles the mover drew from the bag: the post-move rack (after) minus
// the tiles kept (before minus used). It compares the racks as multisets, so duplicate
// letters are counted correctly.
func drawnTiles(before, after, used []string) []string {
kept := make(map[string]int, len(before))
for _, t := range before {
kept[t]++
}
for _, t := range used {
if kept[t] > 0 {
kept[t]--
}
}
var drawn []string
for _, t := range after {
if kept[t] > 0 {
kept[t]--
continue
}
drawn = append(drawn, t)
}
return drawn
}