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:
@@ -0,0 +1,144 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user