Files
scrabble-game/backend/internal/game/replay_timeline.go
T
Ilia Denisov d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
feat(rules): forbid repeating a word already on the board in Erudit
Russian "Эрудит" treats a word laid on the board as belonging to the
game: it cannot be laid again. Neither the solver, the backend nor the
offline JS port knew the rule, so a player (and the robot) could replay a
word freely. Official Scrabble places no such restriction, so both
Scrabble variants keep playing unrestricted.

The rule applies in two ways. A play whose main word is already on the
board is illegal, and is neither accepted nor generated. A play whose
perpendicular cross-word is already there stands — that word is
incidental to laying the main word — but scores nothing. The set of
played words is the game's own move journal, main words and cross-words
alike, compared decoded, so a word spelled with a blank is the same word.

It lives in the game layer, not the solver: only a game knows its
history, and the solver stays stateless and standard-rules. The backend
applies it at submit, at the move preview and over generated moves
(filtering and re-ranking them, so neither the robot nor the hint can
offer a play the engine would then refuse); the client port does the same
for the offline engine and for the on-device preview of an online game.

The rule is pinned per game (games.no_repeat_words, set from the variant
at creation) rather than keyed on the variant, because a game is replayed
from its journal on every open. Applied retroactively it would make an
already-played repeat illegal — closing that game as a draw — and would
rescore a play whose cross-word repeats an earlier word, shifting a live
game's totals. Games created before the rule keep playing without it. The
flag rides the wire as a trailing field because the client's preview must
score the way the server does, and offline games pin the same answer in
their own record.
2026-07-27 18:33:56 +02:00

146 lines
4.8 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,
NoRepeatWords: pre.NoRepeatWords,
})
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
}