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.
145 lines
4.7 KiB
Go
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
|
|
}
|