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 }