fix(offline): keep the local composition; feat(rules): no repeated words in Erudit #287

Merged
developer merged 6 commits from feature/no-repeat-words into development 2026-07-27 17:46:35 +00:00
55 changed files with 1064 additions and 43 deletions
+12
View File
@@ -23,6 +23,13 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
prod bump goes through a solver **PR → master + a published tag**, not a local replace. prod bump goes through a solver **PR → master + a published tag**, not a local replace.
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`, - **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would. Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
- **The UI job's last step is a size gate, not a test:** `cd ui && node scripts/bundle-size.mjs`
(gzip budgets per entry, `BUDGET` in that file). `pnpm build` succeeding says nothing about it, and
the app entry usually sits within a few hundred bytes of its cap, so *any* feature touching an
always-loaded screen can fail CI green-on-everything-else. Run it, and **rebuild first** — the
script measures whatever is in `dist/`, so a stale build silently measures the wrong branch. Put
new logic behind an existing lazy import where it belongs; raising `BUDGET` is allowed but the
file's header comment records the reason for every past raise — keep that up, and ask the owner.
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to - **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise). `${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack - **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
@@ -90,6 +97,11 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde
both the FBS schema and the backend DTO. both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST - **Seat display name is built in two places** — `game.Service` live events **and** the server REST
DTOs. Change both or they drift. DTOs. Change both or they drift.
- **The client codec UPPER-CASES history words** (`lib/codec.ts` `decodeMove`) for display, while the
engine/evaluator decodes candidate words to lower. Anything comparing the two must case-fold, or it
silently never matches — this is how the Erudit no-repeat rule shipped dead on the online path
while working offline (offline compares alphabet-index keys, so it has no case at all). A test that
hand-builds such a set instead of deriving it the way the screen does will pass vacuously.
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST, - **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
then bumps it from the live event. Don't expect it on the shared broadcast. then bumps it from the live event. Don't expect it on the shared broadcast.
+4
View File
@@ -110,6 +110,10 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
if err != nil { if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
} }
move, err = g.noRepeat(move, g.playedWords())
if err != nil {
return MoveRecord{}, err
}
return g.decodeMove(move), nil return g.decodeMove(move), nil
} }
+12 -1
View File
@@ -104,6 +104,11 @@ type Options struct {
// perpendicular cross-words are ignored. Callers always set this explicitly; the // perpendicular cross-words are ignored. Callers always set this explicitly; the
// zero value (false) is the single-word rule. // zero value (false) is the single-word rule.
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
// NoRepeatWords enables the "Эрудит" rule under which a word already laid on the
// board cannot be laid again (see norepeat.go). It is pinned per game rather than
// derived from the variant, so a game started before the rule existed replays and
// scores unchanged; the zero value (false) is the unrestricted rule.
NoRepeatWords bool
} }
// Game is the in-memory state of a single match and the pure rules engine over // Game is the in-memory state of a single match and the pure rules engine over
@@ -127,6 +132,7 @@ type Game struct {
resigned []bool // per seat; a resigned seat is skipped and cannot win resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
multipleWords bool // false = single-word rule (perpendicular cross-words ignored) multipleWords bool // false = single-word rule (perpendicular cross-words ignored)
noRepeatWords bool // true = a word already on the board cannot be laid again
log []MoveRecord log []MoveRecord
} }
@@ -164,6 +170,7 @@ func New(reg *Registry, opts Options) (*Game, error) {
resigned: make([]bool, opts.Players), resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles, dropoutTiles: opts.DropoutTiles,
multipleWords: opts.MultipleWordsPerTurn, multipleWords: opts.MultipleWordsPerTurn,
noRepeatWords: opts.NoRepeatWords,
} }
for i := range g.hands { for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize) g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -195,6 +202,10 @@ func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRec
if err != nil { if err != nil {
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err) return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
} }
move, err = g.noRepeat(move, g.playedWords())
if err != nil {
return MoveRecord{}, err
}
scrabble.Apply(g.board, move) scrabble.Apply(g.board, move)
g.removeFromHand(player, placementTiles(tiles)) g.removeFromHand(player, placementTiles(tiles))
@@ -300,7 +311,7 @@ func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
// GenerateMoves returns every legal play for the current player's rack, ranked // GenerateMoves returns every legal play for the current player's rack, ranked
// by descending score. It is empty when the player has no legal play. // by descending score. It is empty when the player has no legal play.
func (g *Game) GenerateMoves() []scrabble.Move { func (g *Game) GenerateMoves() []scrabble.Move {
return g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts()) return g.noRepeatFilter(g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts()))
} }
// Hint returns the highest-scoring legal play for the current player and true, // Hint returns the highest-scoring legal play for the current player and true,
+93
View File
@@ -0,0 +1,93 @@
package engine
import (
"fmt"
"sort"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// The no-repeat-words rule of Russian "Эрудит": a word laid on the board belongs to the game
// from then on and cannot be laid again. It is a rule of that variant only — official Scrabble
// places no such restriction — and it is pinned per game (Options.NoRepeatWords) rather than
// derived from the variant, so a game started before the rule existed keeps replaying and
// scoring exactly as it did when it was played.
//
// The rule reads the game's own move log, which is the record of every word ever formed, and
// applies in two different ways:
//
// - the play's main word must not already be there — such a play is illegal, and is neither
// accepted nor offered by the move generator;
// - a perpendicular cross-word that is already there is allowed, because it is incidental to
// laying the main word, but it scores nothing.
//
// The rule is deliberately kept out of the solver, which stays a stateless, standard-rules
// engine: only this package knows a game's history.
// playedWords returns the set of words already formed in this game, as the decoded strings the
// move log records — the main word and every cross-word of every play. Both this set and the
// words a candidate move is checked against come from the same decoder, so they compare exactly.
func (g *Game) playedWords() map[string]struct{} {
played := make(map[string]struct{})
for _, rec := range g.log {
if rec.Action != ActionPlay {
continue
}
for _, w := range rec.Words {
played[w] = struct{}{}
}
}
return played
}
// noRepeat applies the no-repeat-words rule to an already validated move, given the set of words
// already played (see playedWords). It returns ErrIllegalPlay when the move's main word is one of
// them, and otherwise the move with every already-played cross-word scored down to zero and the
// total reduced to match. A game without the rule gets its move back untouched.
func (g *Game) noRepeat(m scrabble.Move, played map[string]struct{}) (scrabble.Move, error) {
if !g.noRepeatWords {
return m, nil
}
if main := g.word(m.Main); isPlayed(played, main) {
return scrabble.Move{}, fmt.Errorf("%w: word %q is already on the board", ErrIllegalPlay, main)
}
for i, cw := range m.Cross {
if !isPlayed(played, g.word(cw)) {
continue
}
m.Score -= cw.Score
m.Cross[i].Score = 0
}
return m, nil
}
// isPlayed reports whether word is in the played set. The empty string is never played: it is
// what the decoder yields for a malformed letter index, and such a word must not silently match.
func isPlayed(played map[string]struct{}, word string) bool {
if word == "" {
return false
}
_, ok := played[word]
return ok
}
// noRepeatFilter applies the rule across a generated move list: it drops the plays whose main
// word is already on the board, rescores the rest, and re-ranks them by the adjusted score. The
// sort is stable, so plays the solver ranked equally keep its order. A game without the rule
// gets its list back untouched.
func (g *Game) noRepeatFilter(moves []scrabble.Move) []scrabble.Move {
if !g.noRepeatWords {
return moves
}
played := g.playedWords()
out := moves[:0]
for _, m := range moves {
adjusted, err := g.noRepeat(m, played)
if err != nil {
continue
}
out = append(out, adjusted)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
return out
}
+188
View File
@@ -0,0 +1,188 @@
package engine
import (
"errors"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// newEruditNoRepeat builds a two-player Erudit game with the no-repeat-words rule set either
// way, plus a helper resolving a letter to its alphabet index.
func newEruditNoRepeat(t *testing.T, noRepeat bool) (*Game, func(string) byte) {
t.Helper()
g, err := New(testReg, Options{
Variant: VariantErudit,
Version: testVersion,
Players: 2,
Seed: 1,
MultipleWordsPerTurn: true,
NoRepeatWords: noRepeat,
})
if err != nil {
t.Fatalf("new erudit game: %v", err)
}
return g, func(s string) byte {
i, err := g.rules.Alphabet.Index(s)
if err != nil {
t.Fatalf("index %q: %v", s, err)
}
return i
}
}
// playKotAtCentre commits the opening horizontal КОТ across the centre square, so that the word
// is genuinely in the game's move log rather than only on the board.
func playKotAtCentre(t *testing.T, g *Game, idx func(string) byte) (row, col int) {
t.Helper()
row, col = centre(g.rules)
g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
if _, err := g.Play(scrabble.Horizontal, placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "кот")); err != nil {
t.Fatalf("opening кот: %v", err)
}
return row, col
}
// TestNoRepeatRejectsAReplayedMainWord is the rule's headline case: КОТ laid once may not be laid
// again, here as a vertical play hooking the opening word's К. Without the rule the very same play
// is legal, which pins the rejection on the rule and not on the position.
func TestNoRepeatRejectsAReplayedMainWord(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
t.Fatalf("precondition: кот must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
}
// The second play reuses the opening К and adds О and Т below it, forming КОТ downwards.
// Neither new tile has a horizontal neighbour, so the main word is the only word formed.
replay := func(t *testing.T, g *Game, idx func(string) byte, row, col int) error {
t.Helper()
g.hands[g.toMove] = []byte{idx("о"), idx("т")}
_, err := g.Play(scrabble.Vertical, []scrabble.Placement{
{Row: row + 1, Col: col, Letter: idx("о")},
{Row: row + 2, Col: col, Letter: idx("т")},
})
return err
}
t.Run("with the rule the replayed word is illegal", func(t *testing.T) {
g, idx := newEruditNoRepeat(t, true)
row, col := playKotAtCentre(t, g, idx)
if err := replay(t, g, idx, row, col); !errors.Is(err, ErrIllegalPlay) {
t.Fatalf("replaying кот: err = %v, want ErrIllegalPlay", err)
}
})
t.Run("without the rule the same play is accepted", func(t *testing.T) {
g, idx := newEruditNoRepeat(t, false)
row, col := playKotAtCentre(t, g, idx)
if err := replay(t, g, idx, row, col); err != nil {
t.Fatalf("replaying кот without the rule: %v", err)
}
})
}
// TestNoRepeatPreviewRejectsAReplayedMainWord pins the preview to the same answer as submitting:
// EvaluatePlay must reject what Play rejects, or the player would be shown a score for a move the
// server will refuse.
func TestNoRepeatPreviewRejectsAReplayedMainWord(t *testing.T) {
g, idx := newEruditNoRepeat(t, true)
row, col := playKotAtCentre(t, g, idx)
g.hands[g.toMove] = []byte{idx("о"), idx("т")}
_, err := g.EvaluatePlay([]TileRecord{
{Row: row + 1, Col: col, Letter: "о"},
{Row: row + 2, Col: col, Letter: "т"},
})
if !errors.Is(err, ErrIllegalPlay) {
t.Fatalf("previewing a replayed кот: err = %v, want ErrIllegalPlay", err)
}
}
// TestNoRepeatDropsTheScoreOfAReplayedCrossWord covers the other half of the rule: a play whose
// perpendicular cross-word is already on the board stays legal — the cross-word is incidental to
// laying the main word — but that cross-word scores nothing. The position places РОТ so that its
// Т completes a standing КО downwards into a second КОТ.
func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(t *testing.T) {
if ok, err := testReg.Lookup(VariantErudit, testVersion, "рот"); err != nil || !ok {
t.Fatalf("precondition: рот must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
}
// The main word РОТ runs along row 12; only its last tile has vertical neighbours, so КОТ
// (К and О standing at rows 10 and 11 of column 5) is the play's single cross-word.
evaluate := func(t *testing.T, noRepeat bool) MoveRecord {
t.Helper()
g, idx := newEruditNoRepeat(t, noRepeat)
playKotAtCentre(t, g, idx)
applyScaffold(t, g, idx)
g.hands[g.toMove] = []byte{idx("р"), idx("о"), idx("т")}
rec, err := g.EvaluatePlay([]TileRecord{
{Row: 12, Col: 3, Letter: "р"},
{Row: 12, Col: 4, Letter: "о"},
{Row: 12, Col: 5, Letter: "т"},
})
if err != nil {
t.Fatalf("evaluating рот (noRepeat=%v): %v", noRepeat, err)
}
return rec
}
unrestricted, restricted := evaluate(t, false), evaluate(t, true)
// Exact totals, not merely "less": the client port scores this very position in
// ui/src/lib/dict/eval.norepeat.test.ts and is pinned to the same two numbers, so a scoring
// divergence between the two engines breaks one of the two tests.
if unrestricted.Score != 10 {
t.Errorf("score without the rule = %d, want 10 (РОТ plus the КОТ cross-word)", unrestricted.Score)
}
if restricted.Score != 5 {
t.Errorf("score with the rule = %d, want 5 (РОТ alone; the repeated cross-word earns nothing)", restricted.Score)
}
// The word is still formed and still reported — it simply earns nothing.
want := []string{"рот", "кот"}
if len(restricted.Words) != len(want) || restricted.Words[0] != want[0] || restricted.Words[1] != want[1] {
t.Errorf("words with the rule = %v, want %v", restricted.Words, want)
}
}
// TestNoRepeatFiltersGeneratedMoves keeps the robot and the hint honest: a play the rule forbids
// must never be generated, or the engine would offer a move it would then refuse.
func TestNoRepeatFiltersGeneratedMoves(t *testing.T) {
g, idx := newEruditNoRepeat(t, true)
playKotAtCentre(t, g, idx)
g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")}
moves := g.GenerateMoves()
if len(moves) == 0 {
t.Fatal("no legal replies generated; the position cannot exercise the filter")
}
for _, m := range moves {
if w := g.word(m.Main); w == "кот" {
t.Fatalf("generated a play of кот, which is already on the board")
}
}
// The list stays ranked by the adjusted score, which the hint relies on.
for i := 1; i < len(moves); i++ {
if moves[i-1].Score < moves[i].Score {
t.Fatalf("generated moves not ranked: move %d scores %d, move %d scores %d",
i-1, moves[i-1].Score, i, moves[i].Score)
}
}
}
// TestNoRepeatOffLeavesTheGameUnchanged is the compatibility guard for every game started before
// the rule existed (and for both Scrabble variants, which never take it): nothing is filtered,
// nothing is rescored.
func TestNoRepeatOffLeavesTheGameUnchanged(t *testing.T) {
g, idx := newEruditNoRepeat(t, false)
playKotAtCentre(t, g, idx)
g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")}
generated := g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
if got, want := len(g.GenerateMoves()), len(generated); got != want {
t.Errorf("generated %d moves, want the solver's %d untouched", got, want)
}
}
// applyScaffold stands КО down column 5 above row 12, so a play across row 12 completes КОТ.
func applyScaffold(t *testing.T, g *Game, idx func(string) byte) {
t.Helper()
scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
{Row: 10, Col: 5, Letter: idx("к")},
{Row: 11, Col: 5, Letter: idx("о")},
}})
}
+1
View File
@@ -57,6 +57,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
LastActivityUnix: last.Unix(), LastActivityUnix: last.Unix(),
NoRepeatWords: g.NoRepeatWords,
} }
} }
+1
View File
@@ -60,6 +60,7 @@ func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (Repla
Seed: seed, Seed: seed,
DropoutTiles: pre.DropoutTiles, DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn, MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
NoRepeatWords: pre.NoRepeatWords,
}) })
if err != nil { if err != nil {
return ReplayTimelineView{}, err return ReplayTimelineView{}, err
+11
View File
@@ -284,6 +284,13 @@ func (svc *Service) versionResident(version string) bool {
return false return false
} }
// noRepeatWordsFor reports whether a game of variant v is created with the no-repeat-words rule
// (see engine/norepeat.go): a word already on the board cannot be laid again. It is a rule of
// Russian "Эрудит" alone — both Scrabble variants let a word be played as often as a player can
// manage. The answer is pinned into games.no_repeat_words at creation and read back from there
// afterwards, never re-derived, so games created before the rule existed keep their own answer.
func noRepeatWordsFor(v engine.Variant) bool { return v == engine.VariantErudit }
// Create starts and persists a new game seating the given accounts in turn order // Create starts and persists a new game seating the given accounts in turn order
// (seat 0 first), deals the racks, and warms the live-game cache. It validates // (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (24), the move clock, the hint allowance and that every seat // the player count (24), the move clock, the hint allowance and that every seat
@@ -359,6 +366,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
Seed: seed, Seed: seed,
DropoutTiles: params.DropoutTiles, DropoutTiles: params.DropoutTiles,
MultipleWordsPerTurn: params.MultipleWordsPerTurn, MultipleWordsPerTurn: params.MultipleWordsPerTurn,
NoRepeatWords: noRepeatWordsFor(params.Variant),
}) })
if err != nil { if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) { if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
@@ -382,6 +390,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
hintsPerPlayer: params.HintsPerPlayer, hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(), dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
noRepeatWords: noRepeatWordsFor(params.Variant),
vsAI: params.VsAI, vsAI: params.VsAI,
kind: params.Kind, kind: params.Kind,
} }
@@ -445,6 +454,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
hintsPerPlayer: params.HintsPerPlayer, hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(), dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn, multipleWordsPerTurn: params.MultipleWordsPerTurn,
noRepeatWords: noRepeatWordsFor(params.Variant),
status: StatusOpen, status: StatusOpen,
openDeadline: &deadline, openDeadline: &deadline,
kind: gamelimits.KindRandom, kind: gamelimits.KindRandom,
@@ -1584,6 +1594,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
Seed: seed, Seed: seed,
DropoutTiles: pre.DropoutTiles, DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn, MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
NoRepeatWords: pre.NoRepeatWords,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+6 -1
View File
@@ -44,6 +44,8 @@ type gameInsert struct {
dropoutTiles string dropoutTiles string
// multipleWordsPerTurn false selects the single-word rule for the game. // multipleWordsPerTurn false selects the single-word rule for the game.
multipleWordsPerTurn bool multipleWordsPerTurn bool
// noRepeatWords true forbids laying a word that is already on the board (games.no_repeat_words).
noRepeatWords bool
// vsAI marks an honest-AI game (games.vs_ai). // vsAI marks an honest-AI game (games.vs_ai).
vsAI bool vsAI bool
// kind tags the game's origin (games.game_kind) for the active-game limits. // kind tags the game's origin (games.game_kind) for the active-game limits.
@@ -173,8 +175,10 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind, table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
table.Games.NoRepeatWords,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players, ).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind)) ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind),
ins.noRepeatWords)
if _, err := gi.ExecContext(ctx, tx); err != nil { if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err) return fmt.Errorf("insert game: %w", err)
} }
@@ -1355,6 +1359,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
UpdatedAt: g.UpdatedAt, UpdatedAt: g.UpdatedAt,
} }
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.NoRepeatWords = g.NoRepeatWords
out.VsAI = g.VsAi out.VsAI = g.VsAi
out.Kind = gamelimits.Kind(g.GameKind) out.Kind = gamelimits.Kind(g.GameKind)
if g.EndReason != nil { if g.EndReason != nil {
+4
View File
@@ -144,6 +144,10 @@ type Game struct {
FinishedAt *time.Time FinishedAt *time.Time
// MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule. // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule.
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
// NoRepeatWords true forbids laying a word that is already on the board (the "Эрудит" rule,
// see engine/norepeat.go). It is pinned at creation from the variant rather than derived on
// read, so a game started before the rule existed keeps replaying and scoring unchanged.
NoRepeatWords bool
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the // VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics. // player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
VsAI bool VsAI bool
+86
View File
@@ -0,0 +1,86 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// createTwoSeatGame starts a plain two-human game of the given variant.
func createTwoSeatGame(t *testing.T, svc *game.Service, variant engine.Variant) game.Game {
t.Helper()
g, err := svc.Create(context.Background(), game.CreateParams{
Variant: variant,
Seats: []uuid.UUID{provisionAccount(t), provisionAccount(t)},
HintsAllowed: true,
HintsPerPlayer: 1,
MultipleWordsPerTurn: true,
})
if err != nil {
t.Fatalf("create %s game: %v", variant, err)
}
return g
}
// TestNoRepeatWordsPinnedFromTheVariant checks the rule is written into games.no_repeat_words at
// creation from the variant — on for Erudit, off for both Scrabble variants — and read back with
// the game rather than re-derived.
func TestNoRepeatWordsPinnedFromTheVariant(t *testing.T) {
svc := newGameService()
for _, tc := range []struct {
variant engine.Variant
want bool
}{
{engine.VariantErudit, true},
{engine.VariantRussianScrabble, false},
{engine.VariantEnglish, false},
} {
t.Run(tc.variant.String(), func(t *testing.T) {
created := createTwoSeatGame(t, svc, tc.variant)
if created.NoRepeatWords != tc.want {
t.Errorf("created game NoRepeatWords = %v, want %v", created.NoRepeatWords, tc.want)
}
// Re-read it: the value must come from the row, not from the create call.
reloaded, err := svc.GameByID(context.Background(), created.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if reloaded.NoRepeatWords != tc.want {
t.Errorf("reloaded game NoRepeatWords = %v, want %v", reloaded.NoRepeatWords, tc.want)
}
})
}
}
// TestNoRepeatWordsHonoursThePinNotTheVariant is the compatibility guard for every Erudit game
// that existed before the rule: those rows carry no_repeat_words false (the migration's default),
// and such a game must keep playing unrestricted. Flipping the stored flag stands in for one.
func TestNoRepeatWordsHonoursThePinNotTheVariant(t *testing.T) {
ctx := context.Background()
svc := newGameService()
g := createTwoSeatGame(t, svc, engine.VariantErudit)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.games SET no_repeat_words = false WHERE game_id = $1`, g.ID); err != nil {
t.Fatalf("clear the pin: %v", err)
}
reloaded, err := svc.GameByID(ctx, g.ID)
if err != nil {
t.Fatalf("get game: %v", err)
}
if reloaded.NoRepeatWords {
t.Fatal("an Erudit game whose row has the rule off must report it off")
}
// The rule reaches the engine from the row, so the reconstructed game plays unrestricted:
// its generated moves are the solver's full list, none filtered away.
if _, err := svc.Hint(ctx, reloaded.ID, reloaded.Seats[reloaded.ToMove].AccountID); err != nil {
t.Fatalf("hint on an unrestricted Erudit game: %v", err)
}
}
+1
View File
@@ -42,6 +42,7 @@ func toWireGame(g GameSummary) wire.GameView {
EndReason: g.EndReason, EndReason: g.EndReason,
Seats: seats, Seats: seats,
LastActivityUnix: g.LastActivityUnix, LastActivityUnix: g.LastActivityUnix,
NoRepeatWords: g.NoRepeatWords,
} }
} }
+3
View File
@@ -38,6 +38,9 @@ type GameSummary struct {
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends); // Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
// it rides live events so a lobby patch keeps the per-kind count correct. // it rides live events so a lobby patch keeps the per-kind count correct.
Kind int Kind int
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule, pinned
// per game); it rides live events so the client's local move preview scores like the server.
NoRepeatWords bool
} }
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an // AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
@@ -34,4 +34,5 @@ type Games struct {
MultipleWordsPerTurn bool MultipleWordsPerTurn bool
VsAi bool VsAi bool
GameKind int16 GameKind int16
NoRepeatWords bool
} }
@@ -38,6 +38,7 @@ type gamesTable struct {
MultipleWordsPerTurn postgres.ColumnBool MultipleWordsPerTurn postgres.ColumnBool
VsAi postgres.ColumnBool VsAi postgres.ColumnBool
GameKind postgres.ColumnInteger GameKind postgres.ColumnInteger
NoRepeatWords postgres.ColumnBool
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -100,9 +101,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
VsAiColumn = postgres.BoolColumn("vs_ai") VsAiColumn = postgres.BoolColumn("vs_ai")
GameKindColumn = postgres.IntegerColumn("game_kind") GameKindColumn = postgres.IntegerColumn("game_kind")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn} NoRepeatWordsColumn = postgres.BoolColumn("no_repeat_words")
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn} allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn} mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn, NoRepeatWordsColumn}
) )
return gamesTable{ return gamesTable{
@@ -130,6 +132,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
MultipleWordsPerTurn: MultipleWordsPerTurnColumn, MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
VsAi: VsAiColumn, VsAi: VsAiColumn,
GameKind: GameKindColumn, GameKind: GameKindColumn,
NoRepeatWords: NoRepeatWordsColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -0,0 +1,14 @@
-- The "Эрудит" no-repeat-words rule: a word laid on the board cannot be laid again. It is pinned
-- per game rather than derived from the variant, because a game is replayed from its journal on
-- every open — a rule applied retroactively would make an already-played repeat illegal (voiding
-- the game) and would rescore a play whose cross-word repeats an earlier word. Every existing game
-- therefore keeps the unrestricted rule (DEFAULT false, no backfill) and only new erudit_ru games
-- are created with it on. Additive column — applies forward via goose with no data rewrite (no
-- contour wipe); an image rollback ignores the column.
-- +goose Up
ALTER TABLE backend.games ADD COLUMN no_repeat_words boolean NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE backend.games DROP COLUMN no_repeat_words;
+4
View File
@@ -148,6 +148,9 @@ type gameDTO struct {
ToMove int `json:"to_move"` ToMove int `json:"to_move"`
TurnTimeoutSecs int `json:"turn_timeout_secs"` TurnTimeoutSecs int `json:"turn_timeout_secs"`
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"` MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule, pinned
// per game). The client needs it to score its local move preview the way the server does.
NoRepeatWords bool `json:"no_repeat_words"`
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend // VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
// are suppressed in the client. // are suppressed in the client.
VsAI bool `json:"vs_ai"` VsAI bool `json:"vs_ai"`
@@ -296,6 +299,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
ToMove: g.ToMove, ToMove: g.ToMove,
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn, MultipleWordsPerTurn: g.MultipleWordsPerTurn,
NoRepeatWords: g.NoRepeatWords,
VsAI: g.VsAI, VsAI: g.VsAI,
Kind: int(g.Kind), Kind: int(g.Kind),
MoveCount: g.MoveCount, MoveCount: g.MoveCount,
+18
View File
@@ -561,6 +561,24 @@ Key points:
it on (standard). For auto-match the rule is part of the matchmaking key, so only players it on (standard). For auto-match the rule is part of the matchmaking key, so only players
who chose the same rule are paired (the rule field rides every create/enqueue request, so who chose the same rule are paired (the rule field rides every create/enqueue request, so
matchmaking stays one uniform path). matchmaking stays one uniform path).
- **No repeated words (Erudit).** A word laid on the board cannot be laid again — the "Эрудит"
rule; official Scrabble has no such restriction, so both Scrabble variants never take it. It
applies in two ways: a play whose **main word** is already on the board is illegal, and a play
whose **cross-word** is already there stands (it is incidental to laying the main word) but that
cross-word scores nothing. The set of played words is the game's own **move journal** — the main
word and every cross-word of every play, compared decoded, so a word spelled with a blank is the
same word. The rule lives in the **game layer**, not the solver: `backend/internal/engine`
(`norepeat.go`) and its client port `ui/src/lib/dict/norepeat.ts` apply 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 solver stays stateless and
standard-rules; only a game knows its history.
It is **pinned per game** (`games.no_repeat_words`, set from the variant at creation and read
back thereafter, never re-derived) 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 (§9.1) — and would rescore a play whose cross-word repeats
an earlier word. Games created before the rule therefore keep playing without it. The flag rides
the wire (`GameView.no_repeat_words`) because the client's on-device move preview must score the
way the server does; offline games pin the same answer in their own record.
- **End of game**: the bag is empty **and** a player empties their rack, **or** - **End of game**: the bag is empty **and** a player empties their rack, **or**
**6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or **6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
a missed turn. The **per-game turn timeout** is chosen at creation a missed turn. The **per-game turn timeout** is chosen at creation
+13 -1
View File
@@ -196,7 +196,19 @@ the simplified **single-word rule** — only the word laid along the player's li
real word (and it must still cross or extend letters already on the board along that line), real word (and it must still cross or extend letters already on the board along that line),
and any incidental perpendicular words are ignored and not scored — while on is and any incidental perpendicular words are ignored and not scored — while on is
standard Scrabble. English games are always standard and show no such toggle. In auto-match standard Scrabble. English games are always standard and show no such toggle. In auto-match
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (24) are the choice joins the pairing key, so a player only meets opponents who picked the same rule.
**Erudite forbids repeating a word.** A word laid on the board belongs to the game: it cannot be laid
again, and the AI never plays one either. A play whose main word is already on the board is rejected
like any illegal move — the tiles read invalid on the board and the move cannot be sent, and because
the word itself is a perfectly good one the caption above the scores names it, reading
"WORD: already used" rather than the usual score. That verdict is reached on the device, before the
move is offered to the server, in an online game exactly as in an offline one; a play that only forms an already-played word **across** its main word stands —
that word is incidental to the move — but scores nothing. The rule is not a choice: every Erudite
game takes it, and both Scrabble variants (where the official rules place no such restriction) never
do, so the variant's one-line description on **New Game** reads "no repeated words". Games started
before the rule existed keep playing without it, so a game in progress never changes rules under its
players. Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code, formed by inviting players from the friend list (an invitation, like a friend code,
is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the
Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the
+13 -1
View File
@@ -202,7 +202,19 @@ e-mail) либо ввод фразы. Активные игры форфейтя
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются; этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4) кто выбрал то же правило.
**В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить
его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске,
отклоняется как любой недопустимый: фишки на доске подсвечиваются как недопустимые и ход не отправить,
а поскольку само слово вполне настоящее, подпись над счётом называет его — «СЛОВО: уже использовано»
вместо обычных очков. Это решение принимается на устройстве, до того как ход предложен серверу,
одинаково в онлайн- и в оффлайн-партии; ход, который лишь образует уже выставленное слово **поперёк**
основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не
выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных
правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры**
содержит «слова без повтора». Партии, начатые до появления правила, продолжают играть без него,
так что правила идущей партии не меняются у игроков на ходу. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга, формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
+1
View File
@@ -183,6 +183,7 @@ type GameResp struct {
UnreadChat bool `json:"unread_chat"` UnreadChat bool `json:"unread_chat"`
UnreadMessages bool `json:"unread_messages"` UnreadMessages bool `json:"unread_messages"`
Kind int `json:"kind"` Kind int `json:"kind"`
NoRepeatWords bool `json:"no_repeat_words"`
} }
// MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as // MoveResultResp is the outcome of a committed move. Rack carries the actor's refilled rack as
+1
View File
@@ -638,6 +638,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
UnreadChat: g.UnreadChat, UnreadChat: g.UnreadChat,
UnreadMessages: g.UnreadMessages, UnreadMessages: g.UnreadMessages,
Kind: g.Kind, Kind: g.Kind,
NoRepeatWords: g.NoRepeatWords,
} }
} }
+5
View File
@@ -88,6 +88,11 @@ table GameView {
// caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added // caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added
// trailing — backward-compatible). // trailing — backward-compatible).
kind:int; kind:int;
// no_repeat_words true forbids laying a word that is already on the board — the "Эрудит"
// rule. It is pinned per game at creation rather than derived from the variant, so a game
// started before the rule existed keeps playing without it; the client needs it to score its
// local move preview the same way the server does (added trailing — backward-compatible).
no_repeat_words:bool;
} }
// MoveRecord is one decoded move (a committed play, or a hint preview). // MoveRecord is one decoded move (a committed play, or a hint preview).
+16 -1
View File
@@ -221,8 +221,20 @@ func (rcv *GameView) MutateKind(n int32) bool {
return rcv._tab.MutateInt32Slot(34, n) return rcv._tab.MutateInt32Slot(34, n)
} }
func (rcv *GameView) NoRepeatWords() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *GameView) MutateNoRepeatWords(n bool) bool {
return rcv._tab.MutateBoolSlot(36, n)
}
func GameViewStart(builder *flatbuffers.Builder) { func GameViewStart(builder *flatbuffers.Builder) {
builder.StartObject(16) builder.StartObject(17)
} }
func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0)
@@ -275,6 +287,9 @@ func GameViewAddUnreadMessages(builder *flatbuffers.Builder, unreadMessages bool
func GameViewAddKind(builder *flatbuffers.Builder, kind int32) { func GameViewAddKind(builder *flatbuffers.Builder, kind int32) {
builder.PrependInt32Slot(15, kind, 0) builder.PrependInt32Slot(15, kind, 0)
} }
func GameViewAddNoRepeatWords(builder *flatbuffers.Builder, noRepeatWords bool) {
builder.PrependBoolSlot(16, noRepeatWords, false)
}
func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+4
View File
@@ -55,6 +55,9 @@ type GameView struct {
// Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai, // Kind is the game's origin for the active-game limits: 0 unknown (a pre-existing game), 1 vs_ai,
// 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start. // 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start.
Kind int Kind int
// NoRepeatWords forbids laying a word that is already on the board (the "Эрудит" rule). Pinned
// per game, so a game started before the rule existed keeps playing without it.
NoRepeatWords bool
} }
// TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank // TileRecord is one tile in a decoded MoveRecord (the concrete letter, "?" for a blank
@@ -177,6 +180,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT {
fb.GameViewAddUnreadChat(b, g.UnreadChat) fb.GameViewAddUnreadChat(b, g.UnreadChat)
fb.GameViewAddUnreadMessages(b, g.UnreadMessages) fb.GameViewAddUnreadMessages(b, g.UnreadMessages)
fb.GameViewAddKind(b, int32(g.Kind)) fb.GameViewAddKind(b, int32(g.Kind))
fb.GameViewAddNoRepeatWords(b, g.NoRepeatWords)
return fb.GameViewEnd(b) return fb.GameViewEnd(b)
} }
+6 -2
View File
@@ -41,8 +41,12 @@ const DIST = 'dist';
// + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded // + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The // transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy // heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
// chunks. Scoped CSS lands in the CSS chunk, not this JS budget. // chunks. Raised to 131 for the Erudit no-repeat-words rule: the per-game flag rides the decoded
const BUDGET = { app: 130, shared: 31, landing: 5 }; // GameView (codec + model), and the always-loaded game screen has to name the word its preview
// rejected, since a repeated word is a real word and the board alone cannot say why the play is
// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in
// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 131, shared: 31, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+16 -3
View File
@@ -419,6 +419,11 @@
// opponent_moved and game_over self-heal via the next event's move-count gap check, but // opponent_moved and game_over self-heal via the next event's move-count gap check, but
// opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two // opponent_joined has no follow-up event, so the open-game wait needs its own recovery. Two
// fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby: // fallbacks, mirroring the matchmaking poll PR #51 moved in here from the lobby:
//
// All three recover from the live stream, which a local (offline) game does not use at all — its
// events come from the source itself (see onMount). Reacting to the stream there would refetch
// the state for no reason, so (A) and (C) are gated on the game being a network one; (B) needs no
// gate, as a local game is never 'open' and so never waits for an opponent.
// (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed // (A) On a stream reconnect (alive false -> true) refetch once to catch up on anything missed
// while it was down. The common case is a mobile suspend that drops the stream, the opponent // while it was down. The common case is a mobile suspend that drops the stream, the opponent
@@ -426,7 +431,7 @@
let wasStreamAlive = app.streamAlive; let wasStreamAlive = app.streamAlive;
$effect(() => { $effect(() => {
const alive = app.streamAlive; const alive = app.streamAlive;
if (alive && !wasStreamAlive) void load(); if (alive && !wasStreamAlive && !isLocalGameId(id)) void load();
wasStreamAlive = alive; wasStreamAlive = alive;
}); });
@@ -446,7 +451,7 @@
const r = app.resync; const r = app.resync;
if (r !== lastResync) { if (r !== lastResync) {
lastResync = r; lastResync = r;
void load(); if (!isLocalGameId(id)) void load();
} }
}); });
@@ -828,7 +833,10 @@
const reader = d.peekDawg(v.game.variant, v.game.dictVersion); const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
if (reader && hasAlphabet(v.game.variant)) { if (reader && hasAlphabet(v.game.variant)) {
try { try {
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn); // Under the no-repeat-words rule the evaluator also needs the words this game has
// already formed; both it and that set live behind the same lazy import.
const played = v.game.noRepeatWords ? d.playedWordsFromHistory(moves) : undefined;
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn, played);
notePreviewLocal(); notePreviewLocal();
return; return;
} catch { } catch {
@@ -1563,6 +1571,11 @@
{resultText()} {resultText()}
{:else if preview?.legal && !recallOverRack} {:else if preview?.legal && !recallOverRack}
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score} {preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
{:else if preview?.repeatedWord && !recallOverRack}
<!-- A real word, rejected only because this game has already used it (the Erudit
no-repeat rule): the tiles read invalid, and the caption says which word and why —
otherwise the player is left staring at a word they know is in the dictionary. -->
{preview.repeatedWord.toUpperCase()}{': '}{t('game.wordAlreadyUsed')}
{:else} {:else}
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
{/if} {/if}
+12 -2
View File
@@ -118,8 +118,13 @@ kind():number {
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
} }
noRepeatWords():boolean {
const offset = this.bb!.__offset(this.bb_pos, 36);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startGameView(builder:flatbuffers.Builder) { static startGameView(builder:flatbuffers.Builder) {
builder.startObject(16); builder.startObject(17);
} }
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
@@ -198,12 +203,16 @@ static addKind(builder:flatbuffers.Builder, kind:number) {
builder.addFieldInt32(15, kind, 0); builder.addFieldInt32(15, kind, 0);
} }
static addNoRepeatWords(builder:flatbuffers.Builder, noRepeatWords:boolean) {
builder.addFieldInt8(16, +noRepeatWords, +false);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number):flatbuffers.Offset { static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, multipleWordsPerTurn:boolean, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint, vsAi:boolean, unreadChat:boolean, unreadMessages:boolean, kind:number, noRepeatWords:boolean):flatbuffers.Offset {
GameView.startGameView(builder); GameView.startGameView(builder);
GameView.addId(builder, idOffset); GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset); GameView.addVariant(builder, variantOffset);
@@ -221,6 +230,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset,
GameView.addUnreadChat(builder, unreadChat); GameView.addUnreadChat(builder, unreadChat);
GameView.addUnreadMessages(builder, unreadMessages); GameView.addUnreadMessages(builder, unreadMessages);
GameView.addKind(builder, kind); GameView.addKind(builder, kind);
GameView.addNoRepeatWords(builder, noRepeatWords);
return GameView.endGameView(builder); return GameView.endGameView(builder);
} }
} }
+4
View File
@@ -423,6 +423,7 @@ describe('codec', () => {
fb.GameView.addVsAi(b, true); fb.GameView.addVsAi(b, true);
fb.GameView.addUnreadChat(b, true); fb.GameView.addUnreadChat(b, true);
fb.GameView.addKind(b, 2); fb.GameView.addKind(b, 2);
fb.GameView.addNoRepeatWords(b, true);
const game = fb.GameView.endGameView(b); const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]); const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b); fb.GameList.startGameList(b);
@@ -441,6 +442,9 @@ describe('codec', () => {
expect(gl.games[0].vsAi).toBe(true); expect(gl.games[0].vsAi).toBe(true);
expect(gl.games[0].unreadChat).toBe(true); expect(gl.games[0].unreadChat).toBe(true);
expect(gl.games[0].kind).toBe(2); expect(gl.games[0].kind).toBe(2);
// The Erudit no-repeat rule is pinned per game and rides the view: the on-device move
// preview needs it to score the way the server does.
expect(gl.games[0].noRepeatWords).toBe(true);
expect(gl.atGameLimit).toBe(true); expect(gl.atGameLimit).toBe(true);
}); });
+2
View File
@@ -310,6 +310,7 @@ function decodeGameView(g: fb.GameView): GameView {
toMove: g.toMove(), toMove: g.toMove(),
turnTimeoutSecs: g.turnTimeoutSecs(), turnTimeoutSecs: g.turnTimeoutSecs(),
multipleWordsPerTurn: g.multipleWordsPerTurn(), multipleWordsPerTurn: g.multipleWordsPerTurn(),
noRepeatWords: g.noRepeatWords(),
moveCount: g.moveCount(), moveCount: g.moveCount(),
endReason: s(g.endReason()), endReason: s(g.endReason()),
lastActivityUnix: Number(g.lastActivityUnix()), lastActivityUnix: Number(g.lastActivityUnix()),
@@ -1143,6 +1144,7 @@ function emptyGame(): GameView {
toMove: 0, toMove: 0,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { evaluateLocal } from './eval';
import { playedWordsFromHistory } from './norepeat';
import { seedMockAlphabets } from '../mock/alphabet';
import { emptyBoard, type Board } from '../board';
import type { Dict } from './validate';
import type { PlacedTile } from '../client';
import type { MoveRecord } from '../model';
// The no-repeat-words rule as the game screen sees it: through evaluateLocal, the on-device move
// preview, which decides the rule before a play ever reaches the server. The dictionary is stubbed
// to accept every word, so what these tests pin is the rule and the letter<->index adapter around
// it, not the word list (the Go/JS agreement on plain scoring is pinned by the parity suites).
const anyWord: Dict = { indexOf: () => 0 };
// place fills a board from "row,col,letter" triples.
function place(board: Board, cells: [number, number, string][]): Board {
for (const [row, col, letter] of cells) board[row][col] = { letter, blank: false };
return board;
}
function tile(row: number, col: number, letter: string): PlacedTile {
return { row, col, letter, blank: false };
}
// playRecord is one history entry as the client holds it — note the UPPER-CASE words, which is how
// the codec decodes them for display (lib/codec decodeMove). Building the played set from records
// in this shape is what the game screen actually does, so these tests go through it rather than
// hand-writing the set; comparing the codec's case against the evaluator's would otherwise silently
// never match and the rule would never fire.
function playRecord(...words: string[]): MoveRecord {
return {
player: 0,
action: 'play',
dir: 'H',
mainRow: 0,
mainCol: 0,
tiles: [],
words: words.map((w) => w.toUpperCase()),
count: 0,
score: 0,
total: 0,
};
}
// passRecord is a non-play entry, which contributes no words.
function passRecord(): MoveRecord {
return { player: 0, action: 'pass', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
}
beforeAll(() => seedMockAlphabets());
describe('evaluateLocal under the no-repeat-words rule', () => {
// The position is a real one from the test contour, where the rule rejected the play on the
// server while the composition still read as legal on the board. БРА was laid on move 4 (row 5),
// and this play hooks the Р of ПРИНТ to spell it again downwards in column 3.
function contourBoard(): Board {
return place(emptyBoard(), [
[0, 7, 'о'], [1, 7, 'в'], [2, 7, 'е'],
[2, 5, 'д'], [2, 6, 'ж'], [2, 8, 'б'],
[3, 7, 'н'], [3, 8, 'а'], [3, 9, 'е'], [3, 10, 'м'],
[4, 10, 'а'],
[5, 3, 'п'], [5, 4, 'о'], [5, 5, 'й'], [5, 6, 'к'], [5, 7, 'а'], [5, 9, 'б'], [5, 10, 'р'], [5, 11, 'а'],
[6, 0, 'п'], [6, 3, 'у'], [6, 6, 'о'], [6, 10, 'а'],
[7, 0, 'л'], [7, 1, 'и'], [7, 2, 'с'], [7, 3, 'т'], [7, 6, 'т'], [7, 7, 'р'], [7, 8, 'а'], [7, 9, 'в'], [7, 10, 'л'], [7, 11, 'я'],
[8, 0, 'у'], [8, 2, 'м'], [8, 3, 'ы'], [8, 4, 'с'], [8, 5, 'и'], [8, 6, 'к'],
[9, 0, 'г'], [9, 4, 'у'],
[10, 4, 'ш'],
[11, 2, 'п'], [11, 3, 'р'], [11, 4, 'и'], [11, 5, 'н'], [11, 6, 'т'],
]);
}
// Б above the standing Р and А below it spell БРА downwards.
const bra: PlacedTile[] = [tile(10, 3, 'б'), tile(12, 3, 'а')];
const history: MoveRecord[] = [
playRecord('травля'), playRecord('марал'), playRecord('кот'), playRecord('наем'), playRecord('бра'),
playRecord('пойка'), playRecord('овен'), playRecord('джеб'), playRecord('путы'), playRecord('мысик'),
playRecord('суши'), playRecord('лист'), playRecord('плуг'), playRecord('принт'), passRecord(),
];
const played = playedWordsFromHistory(history);
it('scores the play when the game does not take the rule', () => {
// The control: the same play in a game without the rule is a perfectly ordinary six-pointer.
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false);
expect(res.legal).toBe(true);
expect(res.words).toEqual(['бра']);
expect(res.score).toBe(6);
expect(res.repeatedWord).toBeUndefined();
});
it('rejects the play and names the word when the game has already used it', () => {
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, played);
expect(res.legal).toBe(false);
expect(res.score).toBe(0);
// Named, so the caption can read "БРА: уже использовано" instead of the bare turn label.
expect(res.repeatedWord).toBe('бра');
});
it('leaves the play alone when the rule is in force but the word is new', () => {
const res = evaluateLocal(anyWord, 'erudit_ru', contourBoard(), bra, false, playedWordsFromHistory([playRecord('кот')]));
expect(res.legal).toBe(true);
expect(res.score).toBe(6);
});
});
describe('evaluateLocal under the rule with several words per turn', () => {
// The same position the server engine scores in backend/internal/engine/norepeat_test.go
// (TestNoRepeatDropsTheScoreOfAReplayedCrossWord), pinned to the same two totals: КОТ stands
// across the centre and again down column 5, and РОТ laid across row 12 completes the second
// one as a cross-word. Both engines must agree to the point, or one of the two tests breaks.
function crossBoard(): Board {
return place(emptyBoard(), [
[7, 7, 'к'], [7, 8, 'о'], [7, 9, 'т'], // the opening КОТ, away from the play
[10, 5, 'к'], [11, 5, 'о'], // the standing КО the play completes
]);
}
const rot: PlacedTile[] = [tile(12, 3, 'р'), tile(12, 4, 'о'), tile(12, 5, 'т')];
it('keeps the play legal when only a cross-word repeats, and stops scoring that word', () => {
const free = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true);
expect(free.legal).toBe(true);
expect(free.words).toEqual(['рот', 'кот']);
expect(free.score).toBe(10); // РОТ plus the КОТ cross-word
const restricted = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, playedWordsFromHistory([playRecord('кот')]));
expect(restricted.legal).toBe(true);
expect(restricted.repeatedWord).toBeUndefined();
// The cross-word is still formed and still reported — it just earns nothing.
expect(restricted.words).toEqual(['рот', 'кот']);
expect(restricted.score).toBe(5);
});
it('rejects the play when the main word repeats, whatever its cross-words do', () => {
const res = evaluateLocal(anyWord, 'erudit_ru', crossBoard(), rot, true, playedWordsFromHistory([playRecord('рот', 'кот')]));
expect(res.legal).toBe(false);
expect(res.repeatedWord).toBe('рот');
});
});
+16 -4
View File
@@ -9,6 +9,7 @@
import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate'; import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate';
import { playDirection } from './direction'; import { playDirection } from './direction';
import { noRepeatScore } from './norepeat';
import type { Board as ClientBoard } from '../board'; import type { Board as ClientBoard } from '../board';
import type { PlacedTile } from '../client'; import type { PlacedTile } from '../client';
import type { EvalResult, Variant } from '../model'; import type { EvalResult, Variant } from '../model';
@@ -64,7 +65,7 @@ function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
// decodeWord turns a word's alphabet indices back into a lower-cased string, the // decodeWord turns a word's alphabet indices back into a lower-cased string, the
// form the network EvalResult carries (the caption renders it directly). // form the network EvalResult carries (the caption renders it directly).
function decodeWord(variant: Variant, letters: number[]): string { function decodeWord(variant: Variant, letters: readonly number[]): string {
let s = ''; let s = '';
for (const i of letters) s += letterForIndex(variant, i); for (const i of letters) s += letterForIndex(variant, i);
return s.toLowerCase(); return s.toLowerCase();
@@ -74,8 +75,12 @@ function decodeWord(variant: Variant, letters: number[]): string {
* evaluateLocal computes the move preview for tiles placed on board in the given * evaluateLocal computes the move preview for tiles placed on board in the given
* game, returning the same shape as the network `evaluate`. dict is the loaded * game, returning the same shape as the network `evaluate`. dict is the loaded
* dictionary reader for the game's (variant, version); multipleWords is the game's * dictionary reader for the game's (variant, version); multipleWords is the game's
* cross-word rule. It may throw if the variant's alphabet table is not loaded the * cross-word rule. playedWords is the game's already-played words (decoded, as the
* caller guards with hasAlphabet and falls back to the network on any failure. * history carries them) when the game takes the no-repeat-words rule, and absent
* otherwise with it a play repeating a word is reported illegal and an already-played
* cross-word scores nothing, exactly as the server decides. It may throw if the
* variant's alphabet table is not loaded the caller guards with hasAlphabet and falls
* back to the network on any failure.
*/ */
export function evaluateLocal( export function evaluateLocal(
dict: Dict, dict: Dict,
@@ -83,6 +88,7 @@ export function evaluateLocal(
board: ClientBoard, board: ClientBoard,
tiles: PlacedTile[], tiles: PlacedTile[],
multipleWords: boolean, multipleWords: boolean,
playedWords?: ReadonlySet<string>,
): EvalResult { ): EvalResult {
const vboard = buildBoard(variant, board); const vboard = buildBoard(variant, board);
const rs = buildRuleset(variant, multipleWords); const rs = buildRuleset(variant, multipleWords);
@@ -100,5 +106,11 @@ export function evaluateLocal(
} }
const m = res.move; const m = res.move;
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters)); const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
return { legal: true, score: m.score, words, dir: dir === Horizontal ? 'H' : 'V' }; const score = playedWords ? noRepeatScore(m, playedWords, (l) => decodeWord(variant, l)) : m.score;
if (score === null) {
// A real word, but one this game has already used: illegal, and named so the caption can say
// why rather than leaving the player staring at a word they know is in the dictionary.
return { legal: false, score: 0, words: [], dir: '', repeatedWord: words[0] };
}
return { legal: true, score, words, dir: dir === Horizontal ? 'H' : 'V' };
} }
+1
View File
@@ -3,5 +3,6 @@
// of the initial app bundle — it is a progressive enhancement over the network // of the initial app bundle — it is a progressive enhancement over the network
// preview, which remains the fallback. // preview, which remains the fallback.
export { evaluateLocal } from './eval'; export { evaluateLocal } from './eval';
export { playedWordsFromHistory } from './norepeat';
export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader'; export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader';
export { clearDictCache, listCachedDicts } from './store'; export { clearDictCache, listCachedDicts } from './store';
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { wordKey, playedWordKeys, noRepeatScore } from './norepeat';
import type { Direction, Move, Word } from './validate';
import { Horizontal, Vertical } from './validate';
// word builds a scored Word; only its letters and score matter to the rule.
function word(letters: number[], score: number, dir: Direction = Horizontal): Word {
return { row: 0, col: 0, dir, letters, blanks: letters.map(() => false), score };
}
// move builds a scored play from a main word and its cross-words, with the total the engine
// would have computed (main + cross, no bingo).
function move(main: Word, cross: Word[] = []): Move {
return {
dir: Horizontal,
tiles: [],
main,
cross,
bonus: 0,
score: main.score + cross.reduce((sum, w) => sum + w.score, 0),
};
}
describe('no-repeat-words rule', () => {
it('keys a word by its letters, so a blank spells the same word as a real tile', () => {
// The letters are alphabet indices and the blank flag rides separately, so it never enters
// the key — matching the server, which compares the words decoded.
const withBlank: Word = { ...word([2, 14, 19], 5), blanks: [false, true, false] };
expect(wordKey(withBlank.letters)).toBe(wordKey([2, 14, 19]));
});
it('builds the played set from the journal words, ignoring empty ones', () => {
const played = playedWordKeys([[2, 14, 19], [], [3, 4]]);
expect(played.has(wordKey([2, 14, 19]))).toBe(true);
expect(played.has(wordKey([3, 4]))).toBe(true);
expect(played.size).toBe(2);
});
it('rejects a play whose main word is already on the board', () => {
const played = playedWordKeys([[2, 14, 19]]);
expect(noRepeatScore(move(word([2, 14, 19], 7)), played)).toBeNull();
});
it('accepts a play whose main word is new, at its full score', () => {
const played = playedWordKeys([[2, 14, 19]]);
expect(noRepeatScore(move(word([17, 14, 19], 6)), played)).toBe(6);
});
it('keeps a play whose cross-word repeats, but scores that cross-word at nothing', () => {
const played = playedWordKeys([[2, 14, 19]]);
const m = move(word([17, 14, 19], 6), [word([2, 14, 19], 4, Vertical), word([8, 9], 3, Vertical)]);
expect(m.score).toBe(13);
expect(noRepeatScore(m, played)).toBe(9); // the repeated cross-word's 4 points are dropped
});
it('takes a caller-supplied key, so a decoded history can be compared directly', () => {
// The online preview reads the words the server already decoded, so it keys on the decoded
// form rather than on alphabet indices.
const letters = ['', 'a', 'b', 'c'];
const decode = (l: readonly number[]) => l.map((i) => letters[i]).join('');
const played = new Set(['abc']);
expect(noRepeatScore(move(word([1, 2, 3], 7)), played, decode)).toBeNull();
expect(noRepeatScore(move(word([3, 2, 1], 7)), played, decode)).toBe(7);
});
});
+77
View File
@@ -0,0 +1,77 @@
// The no-repeat-words rule of Russian "Эрудит": a word laid on the board belongs to the game from
// then on and cannot be laid again. This is the client-side port of the server's rule
// (backend/internal/engine/norepeat.go) and must stay in step with it — it decides both what the
// offline engine accepts and what the online move preview scores.
//
// The rule applies in two different ways:
//
// - the play's main word must not already be there — such a play is illegal, and is neither
// accepted nor offered by the move generator;
// - a perpendicular cross-word that is already there is allowed, because it is incidental to
// laying the main word, but it scores nothing.
//
// It is a rule of that variant only, and it is pinned per game rather than derived from the
// variant, so a game started before the rule existed plays on without it.
import type { Move } from './validate';
import type { MoveRecord } from '../model';
/**
* wordKey identifies a word for the rule. Words are alphabet-index letters, so the key ignores
* which tile carried a letter: a word spelled with a blank is the same word as one spelled with
* real tiles, exactly as the server sees it (there the words are compared decoded).
*/
export function wordKey(letters: readonly number[]): string {
return letters.join(',');
}
/**
* playedWordKeys builds the rule's lookup set from a game's move journal pass every play's
* words (the main word and its cross-words). A game with the rule off never needs it.
*/
export function playedWordKeys(words: Iterable<readonly number[]>): Set<string> {
const played = new Set<string>();
for (const w of words) {
if (w.length > 0) played.add(wordKey(w));
}
return played;
}
/**
* playedWordsFromHistory is the rule's lookup set for a game whose history the server decoded for
* us: every word its plays have formed, main words and cross-words alike. It lives here, with the
* rest of the rule and behind the same lazy import as the evaluator, rather than in the game screen.
*
* The history is case-folded to lower, because the codec upper-cases those words for display
* (lib/codec decodeMove) while evaluateLocal decodes a candidate word to lower comparing the two
* raw would never match, and the rule would silently never fire.
*/
export function playedWordsFromHistory(moves: readonly MoveRecord[]): Set<string> {
const played = new Set<string>();
for (const m of moves) {
if (m.action === 'play') for (const w of m.words) played.add(w.toLowerCase());
}
return played;
}
/**
* noRepeatScore applies the rule to an already validated move against the set of words already
* played. It returns null when the move's main word is one of them the play is illegal and
* otherwise the move's score with every already-played cross-word dropped from the total.
*
* keyOf turns a candidate word's letters into the key the played set is built with. The offline
* engine keeps its journal in alphabet-index space and uses the default; the online preview reads
* the history the server sent, whose words are already decoded, and passes a decoding key instead.
*/
export function noRepeatScore(
m: Move,
played: ReadonlySet<string>,
keyOf: (letters: readonly number[]) => string = wordKey,
): number | null {
if (played.has(keyOf(m.main.letters))) return null;
let score = m.score;
for (const cw of m.cross) {
if (played.has(keyOf(cw.letters))) score -= cw.score;
}
return score;
}
+1
View File
@@ -16,6 +16,7 @@ function gameView(id: string): GameView {
toMove: 0, toMove: 0,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+1
View File
@@ -17,6 +17,7 @@ function gameView(moveCount: number, over = false): GameView {
toMove: 1, toMove: 1,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount, moveCount,
endReason: over ? 'standard' : '', endReason: over ? 'standard' : '',
lastActivityUnix: 0, lastActivityUnix: 0,
+1
View File
@@ -20,6 +20,7 @@ function game(seats: Seat[], over = true): GameView {
toMove: 0, toMove: 0,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: false, multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: over ? 'standard' : '', endReason: over ? 'standard' : '',
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC) lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC)
+1
View File
@@ -13,6 +13,7 @@ function game(kind: number, status: string, extra: Partial<GameView> = {}): Game
toMove: 0, toMove: 0,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+5 -5
View File
@@ -24,9 +24,9 @@ function load(): Promise<LocalSource> {
* local-only create() and the robot-reply event subscription events(). * local-only create() and the robot-reply event subscription events().
*/ */
export const localSource = { export const localSource = {
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate // Offline scoring is self-contained, so the local source ignores includeAlphabet and the evaluate
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full // abort signal; the proxy accepts the full gateway signatures but forwards only what the local
// gateway signatures but forwards only what the local source uses. // source uses.
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)), gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
gameHistory: (id) => load().then((s) => s.gameHistory(id)), gameHistory: (id) => load().then((s) => s.gameHistory(id)),
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)), submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
@@ -36,8 +36,8 @@ export const localSource = {
hint: (id) => load().then((s) => s.hint(id)), hint: (id) => load().then((s) => s.hint(id)),
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)), evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)), checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
draftGet: (_id) => load().then((s) => s.draftGet()), draftGet: (id) => load().then((s) => s.draftGet(id)),
draftSave: (_id, _json) => load().then((s) => s.draftSave()), draftSave: (id, json) => load().then((s) => s.draftSave(id, json)),
create: (opts) => load().then((s) => s.create(opts)), create: (opts) => load().then((s) => s.create(opts)),
list: () => load().then((s) => s.list()), list: () => load().then((s) => s.list()),
delete: (id) => load().then((s) => s.delete(id)), delete: (id) => load().then((s) => s.delete(id)),
+2 -1
View File
@@ -79,7 +79,7 @@ export const en = {
'new.searching': 'Looking for an opponent…', 'new.searching': 'Looking for an opponent…',
'new.rulesEnglish': '100 tiles · bingo +50', 'new.rulesEnglish': '100 tiles · bingo +50',
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50', 'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15', 'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15 · no repeated words',
'new.moveLimit': 'Move time: {n} h 00 min', 'new.moveLimit': 'Move time: {n} h 00 min',
'new.searchHint': 'new.searchHint':
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.', 'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
@@ -99,6 +99,7 @@ export const en = {
'game.bagCount': 'In the bag: {n}', 'game.bagCount': 'In the bag: {n}',
'game.bagEmpty': 'Bag is empty', 'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}', 'game.hints': 'Hints {n}',
'game.wordAlreadyUsed': 'already used',
'game.yourTurn': 'Your turn', 'game.yourTurn': 'Your turn',
'game.yourTurnBy': '{name}: Your turn!', 'game.yourTurnBy': '{name}: Your turn!',
'game.opponentsTurn': "Opponent's turn", 'game.opponentsTurn': "Opponent's turn",
+2 -1
View File
@@ -80,7 +80,7 @@ export const ru: Record<MessageKey, string> = {
'new.searching': 'Ищем соперника…', 'new.searching': 'Ищем соперника…',
'new.rulesEnglish': '100 фишек · бинго +50', 'new.rulesEnglish': '100 фишек · бинго +50',
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50', 'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15', 'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15 · слова без повтора',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.', 'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint': 'new.searchHint':
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.', 'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
@@ -99,6 +99,7 @@ export const ru: Record<MessageKey, string> = {
'game.bagCount': 'В мешке: {n}', 'game.bagCount': 'В мешке: {n}',
'game.bagEmpty': 'Мешок пуст', 'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}', 'game.hints': 'Подсказки {n}',
'game.wordAlreadyUsed': 'уже использовано',
'game.yourTurn': 'Ваш ход', 'game.yourTurn': 'Ваш ход',
'game.yourTurnBy': '{name}: Ваш ход!', 'game.yourTurnBy': '{name}: Ваш ход!',
'game.opponentsTurn': 'Ход соперника', 'game.opponentsTurn': 'Ход соперника',
+2 -1
View File
@@ -12,7 +12,7 @@ function invitation(id: string, status: string): Invitation {
hintsAllowed: true, hintsAllowed: true,
hintsPerPlayer: 0, hintsPerPlayer: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
dropoutTiles: 'remove', dropoutTiles: 'remove',
status, status,
gameId: '', gameId: '',
expiresAtUnix: 0, expiresAtUnix: 0,
@@ -33,6 +33,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0)
toMove, toMove,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+1
View File
@@ -29,6 +29,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi
toMove, toMove,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix, lastActivityUnix,
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { Dawg } from '../dict/dawg';
import { RACK_SIZE } from '../premiums';
import { LocalGame } from './engine';
import { wordKey } from '../dict/norepeat';
import { decide } from '../robot/strategy';
import type { Move } from '../dict/validate';
// The no-repeat-words rule driven through the whole offline engine: two robots self-play a game
// with the rule on, and no word may ever be laid twice. This covers both halves of the wiring at
// once — generateMoves must not offer a forbidden play, and play must not accept one — over a long
// run of real positions rather than a hand-built one. The pinned semantics live in
// dict/norepeat.test.ts; the same rule on the server is engine/norepeat_test.go.
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
function bestOpponentScore(game: LocalGame, seat: number): number {
let best = 0;
for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
return best;
}
// selfPlay runs a whole game of robot turns, returning every main word laid in order. The sample
// dictionary emits a one-letter word the validator rejects, so candidates are filtered the way
// the robot's own turn does (see engine.test.ts).
function selfPlay(noRepeatWords: boolean, seed: bigint): string[] {
const game = new LocalGame({
variant: 'scrabble_en',
version: 'sample',
seed,
players: 2,
dawg,
multipleWords: true,
noRepeatWords,
});
const mains: string[] = [];
for (let turn = 0; turn < 500 && !game.isOver; turn++) {
const seat = game.currentPlayer;
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
if (dec.kind === 'play') {
mains.push(wordKey(dec.move.main.letters));
game.play(dec.move.dir, dec.move.tiles);
} else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) {
game.exchange(dec.exchange);
} else {
game.pass();
}
}
return mains;
}
describe('offline engine under the no-repeat-words rule', () => {
it('never lays the same word twice, where the same run without the rule does', () => {
// Both halves of the wiring at once: generateMoves must not offer a forbidden play and play
// must not accept one. Running the same seed with the rule off is the anti-vacuity guard —
// it proves this position sequence really does offer repeats.
const seed = 20260727n;
const withRule = selfPlay(true, seed);
const withoutRule = selfPlay(false, seed);
expect(withoutRule.length).toBeGreaterThan(0);
expect(new Set(withoutRule).size).toBeLessThan(withoutRule.length); // repeats are on offer...
expect(new Set(withRule).size).toBe(withRule.length); // ...and the rule keeps every one out
});
});
+49 -8
View File
@@ -14,6 +14,7 @@ import { Bag } from './bag';
import { RULESETS } from './ruleset'; import { RULESETS } from './ruleset';
import { generateMoves, GenRack, Both } from '../dict/generate'; import { generateMoves, GenRack, Both } from '../dict/generate';
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate'; import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
import { noRepeatScore, playedWordKeys } from '../dict/norepeat';
import { playDirection } from '../dict/direction'; import { playDirection } from '../dict/direction';
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
import { BLANK_INDEX } from '../alphabet'; import { BLANK_INDEX } from '../alphabet';
@@ -154,6 +155,10 @@ export interface LocalGameOptions {
players: number; players: number;
dawg: Dawg; dawg: Dawg;
multipleWords: boolean; multipleWords: boolean;
/** Forbids laying a word that is already on the board (the Erudit rule, see dict/norepeat).
* Pinned per game, so a game started before the rule existed replays and scores unchanged;
* absent reads as off. */
noRepeatWords?: boolean;
dropoutTiles?: DropoutTiles; dropoutTiles?: DropoutTiles;
} }
@@ -175,6 +180,7 @@ export class LocalGame {
private readonly rackSize: number; private readonly rackSize: number;
private readonly dawg: Dawg; private readonly dawg: Dawg;
private readonly multipleWords: boolean; private readonly multipleWords: boolean;
private readonly noRepeatWords: boolean;
private readonly dropoutTiles: DropoutTiles; private readonly dropoutTiles: DropoutTiles;
private readonly board: LocalBoard; private readonly board: LocalBoard;
@@ -197,6 +203,7 @@ export class LocalGame {
this.seed = opts.seed; this.seed = opts.seed;
this.dawg = opts.dawg; this.dawg = opts.dawg;
this.multipleWords = opts.multipleWords; this.multipleWords = opts.multipleWords;
this.noRepeatWords = opts.noRepeatWords ?? false;
this.dropoutTiles = opts.dropoutTiles ?? 'remove'; this.dropoutTiles = opts.dropoutTiles ?? 'remove';
this.vrs = buildRuleset(opts.variant, opts.multipleWords); this.vrs = buildRuleset(opts.variant, opts.multipleWords);
this.values = RULESETS[opts.variant].values; this.values = RULESETS[opts.variant].values;
@@ -250,28 +257,60 @@ export class LocalGame {
} }
/** config returns the rule settings the game was created with the bits, alongside the seed and /** config returns the rule settings the game was created with the bits, alongside the seed and
* journal, needed to reconstruct it (see localgame/serialize). */ * journal, needed to reconstruct it (see localgame/serialize). */
get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } { get config(): { multipleWords: boolean; noRepeatWords: boolean; dropoutTiles: DropoutTiles } {
return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles }; return { multipleWords: this.multipleWords, noRepeatWords: this.noRepeatWords, dropoutTiles: this.dropoutTiles };
} }
/** history returns a copy of the move log. */ /** history returns a copy of the move log. */
get history(): LocalMove[] { get history(): LocalMove[] {
return this.log.map((m) => ({ ...m })); return this.log.map((m) => ({ ...m }));
} }
/** generateMoves returns every legal play for the current player, ranked by descending score. */ /** generateMoves returns every legal play for the current player, ranked by descending score.
* Under the no-repeat-words rule the plays it forbids are dropped and the rest are rescored and
* re-ranked, so neither the robot nor the hint can offer a move the engine would then refuse. */
generateMoves(): Move[] { generateMoves(): Move[] {
return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both); const moves = generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
if (!this.noRepeatWords) return moves;
const played = this.playedKeys();
const out: Move[] = [];
for (const m of moves) {
const score = noRepeatScore(m, played);
if (score === null) continue;
out.push(score === m.score ? m : { ...m, score });
}
// A stable sort, so plays the generator ranked equally keep its order.
return out.sort((a, b) => b.score - a.score);
}
/** playedKeys is the no-repeat-words rule's lookup set: every word this game has already
* formed, main words and cross-words alike (see dict/norepeat). */
private playedKeys(): ReadonlySet<string> {
const words: number[][] = [];
for (const m of this.log) {
if (m.action === 'play' && m.words) words.push(...m.words);
}
return playedWordKeys(words);
} }
/** evaluatePlay scores a candidate placement for the current position without committing it, /** evaluatePlay scores a candidate placement for the current position without committing it,
* returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index * returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
* arrays, main first) and the inferred direction. Backs the local move preview. */ * arrays, main first) and the inferred direction. Backs the local move preview. */
evaluatePlay(tiles: Placement[]): { legal: boolean; score: number; words: number[][]; dir: Direction } { evaluatePlay(tiles: Placement[]): {
legal: boolean;
score: number;
words: number[][];
dir: Direction;
/** The main word when the no-repeat-words rule is what rejects the play: a real word the game
* has already used. Absent on every other outcome. */
repeated?: number[];
} {
const dir = playDirection(this.board, this.vrs, this.dawg, tiles); const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir }; if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
const m = res.move; const m = res.move;
return { legal: true, score: m.score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir }; const score = this.noRepeatWords ? noRepeatScore(m, this.playedKeys()) : m.score;
if (score === null) return { legal: false, score: 0, words: [], dir, repeated: m.main.letters };
return { legal: true, score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
} }
/** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */ /** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
@@ -299,10 +338,12 @@ export class LocalGame {
const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
if (!res.legal || !res.move) throw new GameError('illegal_play'); if (!res.legal || !res.move) throw new GameError('illegal_play');
const move = res.move; const move = res.move;
const score = this.noRepeatWords ? noRepeatScore(move, this.playedKeys()) : move.score;
if (score === null) throw new GameError('illegal_play');
for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank); for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
this.removeFromHand(player, used); this.removeFromHand(player, used);
this.scores[player] += move.score; this.scores[player] += score;
this.refill(player); this.refill(player);
this.scorelessRun = 0; this.scorelessRun = 0;
@@ -314,7 +355,7 @@ export class LocalGame {
mainRow: move.main.row, mainRow: move.main.row,
mainCol: move.main.col, mainCol: move.main.col,
words: [move.main.letters, ...move.cross.map((w) => w.letters)], words: [move.main.letters, ...move.cross.map((w) => w.letters)],
score: move.score, score,
total: this.scores[player], total: this.scores[player],
}; };
this.log.push(rec); this.log.push(rec);
+15
View File
@@ -35,6 +35,10 @@ export interface LocalGameRecord {
seed: string; seed: string;
players: number; players: number;
multipleWords: boolean; multipleWords: boolean;
/** Forbids laying a word that is already on the board (the Erudit rule). Pinned at creation from
* the variant, so a game started before the rule existed (where it is absent, read as false)
* replays and scores exactly as it was played. */
noRepeatWords?: boolean;
dropoutTiles: DropoutTiles; dropoutTiles: DropoutTiles;
seats: Seat[]; seats: Seat[];
/** The dictionary-independent, alphabet-index-space move journal. */ /** The dictionary-independent, alphabet-index-space move journal. */
@@ -53,6 +57,13 @@ export interface LocalGameRecord {
* read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it * read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it
* see lib/hints. */ * see lib/hints. */
hintUnlockAtMs: number; hintUnlockAtMs: number;
/** The seated player's in-progress composition (rack order + pending board tiles) as the draft
* JSON the game screen serialises, or absent when there is none. A local game keeps it here
* there is no server to hold it so a reload of the game state restores the arrangement instead
* of dropping the tiles back into the rack. It belongs to the turn: the source clears it wherever
* the turn advances (a committed move, a resignation, a host skip), so a hotseat seat never
* inherits the previous player's arrangement. */
draft?: string;
} }
/** The record fields the caller owns (the engine supplies the rest). */ /** The record fields the caller owns (the engine supplies the rest). */
@@ -64,6 +75,7 @@ export interface RecordMeta {
createdAtUnix: number; createdAtUnix: number;
updatedAtUnix: number; updatedAtUnix: number;
hintUnlockAtMs: number; hintUnlockAtMs: number;
draft?: string;
} }
/** /**
@@ -79,6 +91,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
seed: game.seed.toString(), seed: game.seed.toString(),
players: game.playerCount, players: game.playerCount,
multipleWords: cfg.multipleWords, multipleWords: cfg.multipleWords,
noRepeatWords: cfg.noRepeatWords || undefined,
dropoutTiles: cfg.dropoutTiles, dropoutTiles: cfg.dropoutTiles,
seats: meta.seats, seats: meta.seats,
journal: game.history, journal: game.history,
@@ -88,6 +101,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
createdAtUnix: meta.createdAtUnix, createdAtUnix: meta.createdAtUnix,
updatedAtUnix: meta.updatedAtUnix, updatedAtUnix: meta.updatedAtUnix,
hintUnlockAtMs: meta.hintUnlockAtMs, hintUnlockAtMs: meta.hintUnlockAtMs,
draft: meta.draft,
}; };
} }
@@ -104,6 +118,7 @@ export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame {
players: record.players, players: record.players,
dawg, dawg,
multipleWords: record.multipleWords, multipleWords: record.multipleWords,
noRepeatWords: record.noRepeatWords,
dropoutTiles: record.dropoutTiles, dropoutTiles: record.dropoutTiles,
}; };
const game = new LocalGame(opts); const game = new LocalGame(opts);
+16
View File
@@ -97,6 +97,22 @@ describe('LocalSource', () => {
} }
}); });
it('persists the draft, so reloading the state keeps the composition', async () => {
// The game screen refetches the state on several triggers (a stream reconnect, a resume). With
// no stored draft every one of those wiped the player's arrangement back into the rack.
const src = await newSource('local:gDraft', 42n);
expect(await src.draftGet('local:gDraft')).toBe('');
await src.draftSave('local:gDraft', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
expect(await src.draftGet('local:gDraft')).toBe('{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
});
it('drops the draft when the turn advances, so a hotseat seat never inherits it', async () => {
const src = await newSource('local:gDraft2', 42n);
await src.draftSave('local:gDraft2', '{"rackOrder":[3,1,0,2,4,5,6],"tiles":[]}');
await src.pass('local:gDraft2');
expect(await src.draftGet('local:gDraft2')).toBe('');
});
it('drives a whole local game to completion', async () => { it('drives a whole local game to completion', async () => {
const src = await newSource('local:g5', 2024n); const src = await newSource('local:g5', 2024n);
src.events('local:g5', () => {}); src.events('local:g5', () => {});
+21 -7
View File
@@ -18,6 +18,7 @@ import { HINT_GATE_MS } from '../hints';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy'; import { decide } from '../robot/strategy';
import { verifyPin, type PinLock } from '../pin'; import { verifyPin, type PinLock } from '../pin';
import { noRepeatWordsFor } from '../variants';
import { GatewayError, type PlacedTile } from '../client'; import { GatewayError, type PlacedTile } from '../client';
import type { import type {
EvalResult, EvalResult,
@@ -122,6 +123,7 @@ export class LocalSource implements GameLoopSource {
players: opts.seats.length, players: opts.seats.length,
dawg, dawg,
multipleWords: opts.multipleWords, multipleWords: opts.multipleWords,
noRepeatWords: noRepeatWordsFor(opts.variant),
}); });
const now = nowUnix(); const now = nowUnix();
const record = serializeGame(game, { const record = serializeGame(game, {
@@ -195,6 +197,7 @@ export class LocalSource implements GameLoopSource {
const seat = entry.game.currentPlayer; const seat = entry.game.currentPlayer;
const move = entry.game.resign(); const move = entry.game.resign();
entry.unlockedSeat = null; entry.unlockedSeat = null;
entry.record.draft = undefined; // the turn is over; the abandoned composition goes with it
await this.persist(entry); await this.persist(entry);
return this.moveResult(entry, move, seat); return this.moveResult(entry, move, seat);
} }
@@ -251,6 +254,7 @@ export class LocalSource implements GameLoopSource {
if (action === 'skip') entry.game.pass(); if (action === 'skip') entry.game.pass();
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer); else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
entry.unlockedSeat = null; entry.unlockedSeat = null;
entry.record.draft = undefined; // the host advanced the turn; the seat's composition is stale
await this.persist(entry); await this.persist(entry);
return this.stateView(entry); return this.stateView(entry);
} }
@@ -268,11 +272,13 @@ export class LocalSource implements GameLoopSource {
async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> { async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
const { game } = await this.load(gameId); const { game } = await this.load(gameId);
const r = game.evaluatePlay(encodePlacements(variant, tiles)); const r = game.evaluatePlay(encodePlacements(variant, tiles));
const decode = (w: readonly number[]) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase();
return { return {
legal: r.legal, legal: r.legal,
score: r.score, score: r.score,
words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()), words: r.words.map(decode),
dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '', dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
repeatedWord: r.repeated ? decode(r.repeated) : undefined,
}; };
} }
@@ -282,13 +288,18 @@ export class LocalSource implements GameLoopSource {
return { word, legal: game.dictionaryHas(idx) }; return { word, legal: game.dictionaryHas(idx) };
} }
// Drafts (the in-progress composition) are not persisted for local games — the arrangement is // Drafts (the in-progress composition: rack order + pending board tiles) live in the game's own
// rebuilt from the rack on reopen. The screen tolerates an empty draft. // record — a local game has no server to hold them. Persisting them keeps every reload of the
async draftGet(): Promise<string> { // state (the game screen refetches on several triggers) from dropping the arrangement back into
return ''; // the rack, and carries it across leaving and reopening the app.
async draftGet(gameId: string): Promise<string> {
const entry = await this.load(gameId);
return entry.record.draft ?? '';
} }
async draftSave(): Promise<void> { async draftSave(gameId: string, json: string): Promise<void> {
/* no-op offline */ const entry = await this.load(gameId);
entry.record.draft = json || undefined;
await this.persist(entry);
} }
/** events subscribes to a local game's push events (the robot's reply, game over). Returns an /** events subscribes to a local game's push events (the robot's reply, game over). Returns an
@@ -325,6 +336,7 @@ export class LocalSource implements GameLoopSource {
const seat = entry.game.currentPlayer; const seat = entry.game.currentPlayer;
const move = act(entry.game); const move = act(entry.game);
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai) entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
entry.record.draft = undefined; // the composition was committed (or swapped away) with the turn
const robotMoves = this.runRobots(entry); const robotMoves = this.runRobots(entry);
await this.persist(entry); await this.persist(entry);
const result = this.moveResult(entry, move, seat); const result = this.moveResult(entry, move, seat);
@@ -378,6 +390,7 @@ export class LocalSource implements GameLoopSource {
createdAtUnix: entry.record.createdAtUnix, createdAtUnix: entry.record.createdAtUnix,
updatedAtUnix: nowUnix(), updatedAtUnix: nowUnix(),
hintUnlockAtMs: entry.record.hintUnlockAtMs, hintUnlockAtMs: entry.record.hintUnlockAtMs,
draft: entry.record.draft,
}); });
await saveLocalGame(entry.record); await saveLocalGame(entry.record);
} }
@@ -474,6 +487,7 @@ export class LocalSource implements GameLoopSource {
toMove: game.currentPlayer, toMove: game.currentPlayer,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: record.multipleWords, multipleWordsPerTurn: record.multipleWords,
noRepeatWords: !!record.noRepeatWords,
moveCount: game.moveCount, moveCount: game.moveCount,
endReason: game.isOver ? game.endReason : '', endReason: game.isOver ? game.endReason : '',
lastActivityUnix: record.updatedAtUnix, lastActivityUnix: record.updatedAtUnix,
+3
View File
@@ -46,6 +46,7 @@ import type {
Catalog, Catalog,
} from '../model'; } from '../model';
import { valueForLetter } from '../alphabet'; import { valueForLetter } from '../alphabet';
import { noRepeatWordsFor } from '../variants';
import { seedMockAlphabets } from './alphabet'; import { seedMockAlphabets } from './alphabet';
import { import {
ME, ME,
@@ -292,6 +293,7 @@ export class MockGateway implements GatewayClient {
toMove: 0, toMove: 0,
turnTimeoutSecs: 604800, turnTimeoutSecs: 604800,
multipleWordsPerTurn: multipleWords, multipleWordsPerTurn: multipleWords,
noRepeatWords: noRepeatWordsFor(variant),
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
@@ -326,6 +328,7 @@ export class MockGateway implements GatewayClient {
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: multipleWords, multipleWordsPerTurn: multipleWords,
noRepeatWords: noRepeatWordsFor(variant),
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000), lastActivityUnix: Math.floor(Date.now() / 1000),
+4 -1
View File
@@ -113,7 +113,7 @@ export function mockInvitations(): Invitation[] {
hintsAllowed: true, hintsAllowed: true,
hintsPerPlayer: 1, hintsPerPlayer: 1,
multipleWordsPerTurn: false, multipleWordsPerTurn: false,
dropoutTiles: 'remove', dropoutTiles: 'remove',
status: 'pending', status: 'pending',
gameId: '', gameId: '',
expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400, expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
@@ -198,6 +198,7 @@ function activeGame(): MockGame {
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: G1_MOVES.length, moveCount: G1_MOVES.length,
endReason: '', endReason: '',
lastActivityUnix: Math.floor(Date.now() / 1000) - 7200, lastActivityUnix: Math.floor(Date.now() / 1000) - 7200,
@@ -237,6 +238,7 @@ function finishedG2(): MockGame {
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 2, moveCount: 2,
endReason: 'normal', endReason: 'normal',
lastActivityUnix: Math.floor(Date.now() / 1000) - 86400, lastActivityUnix: Math.floor(Date.now() / 1000) - 86400,
@@ -277,6 +279,7 @@ function finishedG3(): MockGame {
toMove: 0, toMove: 0,
turnTimeoutSecs: 86400, turnTimeoutSecs: 86400,
multipleWordsPerTurn: false, multipleWordsPerTurn: false,
noRepeatWords: false,
moveCount: 1, moveCount: 1,
endReason: 'resignation', endReason: 'resignation',
lastActivityUnix: Math.floor(Date.now() / 1000) - 172800, lastActivityUnix: Math.floor(Date.now() / 1000) - 172800,
+9
View File
@@ -48,6 +48,10 @@ export interface GameView {
turnTimeoutSecs: number; turnTimeoutSecs: number;
/** true = standard Scrabble; false = the single-word rule (Russian games). */ /** true = standard Scrabble; false = the single-word rule (Russian games). */
multipleWordsPerTurn: boolean; multipleWordsPerTurn: boolean;
/** true = a word already on the board cannot be laid again (the Erudit rule). Pinned per game at
* creation, so a game started before the rule existed reports false. The local move preview needs
* it to score the way the server does. */
noRepeatWords: boolean;
moveCount: number; moveCount: number;
endReason: string; endReason: string;
/** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
@@ -125,6 +129,11 @@ export interface EvalResult {
words: string[]; words: string[];
/** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */ /** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
dir: string; dir: string;
/** The play's main word when it is a real word the game has already used, so the rule (and not
* the dictionary) is what makes the play illegal the composition reads as invalid but the
* caption says so in those words. Set only by the on-device evaluator, which decides the rule
* before the play ever reaches the server; absent on every other outcome. */
repeatedWord?: string;
} }
export interface WordCheckResult { export interface WordCheckResult {
+1
View File
@@ -28,6 +28,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
toMove: 0, toMove: 0,
turnTimeoutSecs: 300, turnTimeoutSecs: 300,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+1
View File
@@ -26,6 +26,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
toMove, toMove,
turnTimeoutSecs: 0, turnTimeoutSecs: 0,
multipleWordsPerTurn: true, multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0, moveCount: 0,
endReason: '', endReason: '',
lastActivityUnix: 0, lastActivityUnix: 0,
+9
View File
@@ -28,6 +28,15 @@ export function variantNameKey(v: Variant): MessageKey {
return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english'; return ALL_VARIANTS.find((o) => o.id === v)?.label ?? 'new.english';
} }
// noRepeatWordsFor reports whether a new game of variant v takes the no-repeat-words rule (see
// lib/dict/norepeat): a word already on the board cannot be laid again. It is a rule of Erudit
// alone — both Scrabble variants let a word be played as often as a player can manage. It mirrors
// the server's noRepeatWordsFor and is only ever consulted when a game is created; an existing
// game carries its own pinned answer (GameView.noRepeatWords / LocalGameRecord.noRepeatWords).
export function noRepeatWordsFor(v: Variant): boolean {
return v === 'erudit_ru';
}
// VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game // VARIANT_RULES is the i18n key for each variant's one-line rules summary on the New Game
// buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets. // buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets.
export const VARIANT_RULES: Record<Variant, MessageKey> = { export const VARIANT_RULES: Record<Variant, MessageKey> = {