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
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.
146 lines
4.8 KiB
Go
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
|
|
}
|