From d7337d24eaba053354fa0e96168c97801082562c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 27 Jul 2026 18:33:56 +0200 Subject: [PATCH] feat(rules): forbid repeating a word already on the board in Erudit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record. --- backend/internal/engine/domain.go | 4 + backend/internal/engine/game.go | 13 +- backend/internal/engine/norepeat.go | 93 ++++++++++ backend/internal/engine/norepeat_test.go | 175 ++++++++++++++++++ backend/internal/game/eventwire.go | 1 + backend/internal/game/replay_timeline.go | 1 + backend/internal/game/service.go | 11 ++ backend/internal/game/store.go | 7 +- backend/internal/game/types.go | 4 + backend/internal/inttest/norepeat_test.go | 86 +++++++++ backend/internal/notify/encode.go | 1 + backend/internal/notify/payload.go | 3 + .../postgres/jet/backend/model/games.go | 1 + .../postgres/jet/backend/table/games.go | 9 +- .../migrations/00017_no_repeat_words.sql | 14 ++ backend/internal/server/dto.go | 4 + docs/ARCHITECTURE.md | 18 ++ docs/FUNCTIONAL.md | 11 +- docs/FUNCTIONAL_ru.md | 11 +- gateway/internal/backendclient/api.go | 1 + gateway/internal/transcode/encode.go | 1 + pkg/fbs/scrabble.fbs | 5 + pkg/fbs/scrabblefb/GameView.go | 17 +- pkg/wire/build.go | 4 + ui/src/game/Game.svelte | 14 +- ui/src/gen/fbs/scrabblefb/game-view.ts | 14 +- ui/src/lib/codec.test.ts | 4 + ui/src/lib/codec.ts | 2 + ui/src/lib/dict/eval.ts | 18 +- ui/src/lib/dict/norepeat.test.ts | 65 +++++++ ui/src/lib/dict/norepeat.ts | 59 ++++++ ui/src/lib/gamecache.test.ts | 1 + ui/src/lib/gamedelta.test.ts | 1 + ui/src/lib/gameimage.test.ts | 1 + ui/src/lib/gamelimits.test.ts | 1 + ui/src/lib/i18n/en.ts | 2 +- ui/src/lib/i18n/ru.ts | 2 +- ui/src/lib/lobbycache.test.ts | 3 +- ui/src/lib/lobbysort.test.ts | 1 + ui/src/lib/localgame/engine.norepeat.test.ts | 66 +++++++ ui/src/lib/localgame/engine.ts | 47 ++++- ui/src/lib/localgame/serialize.ts | 6 + ui/src/lib/localgame/source.ts | 3 + ui/src/lib/mock/client.ts | 3 + ui/src/lib/mock/data.ts | 5 +- ui/src/lib/model.ts | 4 + ui/src/lib/preload.test.ts | 1 + ui/src/lib/result.test.ts | 1 + ui/src/lib/variants.ts | 9 + 49 files changed, 802 insertions(+), 26 deletions(-) create mode 100644 backend/internal/engine/norepeat.go create mode 100644 backend/internal/engine/norepeat_test.go create mode 100644 backend/internal/inttest/norepeat_test.go create mode 100644 backend/internal/postgres/migrations/00017_no_repeat_words.sql create mode 100644 ui/src/lib/dict/norepeat.test.ts create mode 100644 ui/src/lib/dict/norepeat.ts create mode 100644 ui/src/lib/localgame/engine.norepeat.test.ts diff --git a/backend/internal/engine/domain.go b/backend/internal/engine/domain.go index 71f973c..ea487ae 100644 --- a/backend/internal/engine/domain.go +++ b/backend/internal/engine/domain.go @@ -110,6 +110,10 @@ func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) { if err != nil { 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 } diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go index 171c2f8..4f2a9de 100644 --- a/backend/internal/engine/game.go +++ b/backend/internal/engine/game.go @@ -104,6 +104,11 @@ type Options struct { // perpendicular cross-words are ignored. Callers always set this explicitly; the // zero value (false) is the single-word rule. 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 @@ -127,6 +132,7 @@ type Game struct { resigned []bool // per seat; a resigned seat is skipped and cannot win dropoutTiles DropoutTiles // disposition of a resigned seat's tiles 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 } @@ -164,6 +170,7 @@ func New(reg *Registry, opts Options) (*Game, error) { resigned: make([]bool, opts.Players), dropoutTiles: opts.DropoutTiles, multipleWords: opts.MultipleWordsPerTurn, + noRepeatWords: opts.NoRepeatWords, } for i := range g.hands { 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 { 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) 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 // by descending score. It is empty when the player has no legal play. 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, diff --git a/backend/internal/engine/norepeat.go b/backend/internal/engine/norepeat.go new file mode 100644 index 0000000..3f1c5e2 --- /dev/null +++ b/backend/internal/engine/norepeat.go @@ -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 +} diff --git a/backend/internal/engine/norepeat_test.go b/backend/internal/engine/norepeat_test.go new file mode 100644 index 0000000..e4d50f6 --- /dev/null +++ b/backend/internal/engine/norepeat_test.go @@ -0,0 +1,175 @@ +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) + scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{ + {Row: 10, Col: 5, Letter: idx("к")}, + {Row: 11, Col: 5, Letter: 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) + if restricted.Score >= unrestricted.Score { + t.Errorf("score with the rule = %d, want less than %d without it", restricted.Score, unrestricted.Score) + } + // The word is still formed and still reported — it simply earns nothing. + if len(restricted.Words) != len(unrestricted.Words) { + t.Errorf("words with the rule = %v, want the same words as without it (%v)", restricted.Words, unrestricted.Words) + } +} + +// 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) + } +} diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index 54262d2..5f421e2 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -57,6 +57,7 @@ func gameSummary(g Game, names []string) notify.GameSummary { EndReason: g.EndReason, Seats: seats, LastActivityUnix: last.Unix(), + NoRepeatWords: g.NoRepeatWords, } } diff --git a/backend/internal/game/replay_timeline.go b/backend/internal/game/replay_timeline.go index a7f2da8..f278fff 100644 --- a/backend/internal/game/replay_timeline.go +++ b/backend/internal/game/replay_timeline.go @@ -60,6 +60,7 @@ func (svc *Service) ReplayTimeline(ctx context.Context, gameID uuid.UUID) (Repla Seed: seed, DropoutTiles: pre.DropoutTiles, MultipleWordsPerTurn: pre.MultipleWordsPerTurn, + NoRepeatWords: pre.NoRepeatWords, }) if err != nil { return ReplayTimelineView{}, err diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index f235b2d..02af6ee 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -284,6 +284,13 @@ func (svc *Service) versionResident(version string) bool { 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 // (seat 0 first), deals the racks, and warms the live-game cache. It validates // the player count (2–4), 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, DropoutTiles: params.DropoutTiles, MultipleWordsPerTurn: params.MultipleWordsPerTurn, + NoRepeatWords: noRepeatWordsFor(params.Variant), }) if err != nil { 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, dropoutTiles: params.DropoutTiles.String(), multipleWordsPerTurn: params.MultipleWordsPerTurn, + noRepeatWords: noRepeatWordsFor(params.Variant), vsAI: params.VsAI, kind: params.Kind, } @@ -445,6 +454,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params hintsPerPlayer: params.HintsPerPlayer, dropoutTiles: params.DropoutTiles.String(), multipleWordsPerTurn: params.MultipleWordsPerTurn, + noRepeatWords: noRepeatWordsFor(params.Variant), status: StatusOpen, openDeadline: &deadline, kind: gamelimits.KindRandom, @@ -1584,6 +1594,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) Seed: seed, DropoutTiles: pre.DropoutTiles, MultipleWordsPerTurn: pre.MultipleWordsPerTurn, + NoRepeatWords: pre.NoRepeatWords, }) if err != nil { return nil, err diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index ced6349..50c5a85 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -44,6 +44,8 @@ type gameInsert struct { dropoutTiles string // multipleWordsPerTurn false selects the single-word rule for the game. 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 bool // 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.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt, 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, - 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 { return fmt.Errorf("insert game: %w", err) } @@ -1355,6 +1359,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) { UpdatedAt: g.UpdatedAt, } out.MultipleWordsPerTurn = g.MultipleWordsPerTurn + out.NoRepeatWords = g.NoRepeatWords out.VsAI = g.VsAi out.Kind = gamelimits.Kind(g.GameKind) if g.EndReason != nil { diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 51ed7ac..3609fd5 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -144,6 +144,10 @@ type Game struct { FinishedAt *time.Time // MultipleWordsPerTurn true selects standard Scrabble; false the single-word rule. 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 // player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics. VsAI bool diff --git a/backend/internal/inttest/norepeat_test.go b/backend/internal/inttest/norepeat_test.go new file mode 100644 index 0000000..52d6f6a --- /dev/null +++ b/backend/internal/inttest/norepeat_test.go @@ -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) + } +} diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index eb9109f..1e4b943 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -42,6 +42,7 @@ func toWireGame(g GameSummary) wire.GameView { EndReason: g.EndReason, Seats: seats, LastActivityUnix: g.LastActivityUnix, + NoRepeatWords: g.NoRepeatWords, } } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index 37641e5..de64cbf 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -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); // it rides live events so a lobby patch keeps the per-kind count correct. 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 diff --git a/backend/internal/postgres/jet/backend/model/games.go b/backend/internal/postgres/jet/backend/model/games.go index 30268e1..2621ecd 100644 --- a/backend/internal/postgres/jet/backend/model/games.go +++ b/backend/internal/postgres/jet/backend/model/games.go @@ -34,4 +34,5 @@ type Games struct { MultipleWordsPerTurn bool VsAi bool GameKind int16 + NoRepeatWords bool } diff --git a/backend/internal/postgres/jet/backend/table/games.go b/backend/internal/postgres/jet/backend/table/games.go index db22d21..9c398ca 100644 --- a/backend/internal/postgres/jet/backend/table/games.go +++ b/backend/internal/postgres/jet/backend/table/games.go @@ -38,6 +38,7 @@ type gamesTable struct { MultipleWordsPerTurn postgres.ColumnBool VsAi postgres.ColumnBool GameKind postgres.ColumnInteger + NoRepeatWords postgres.ColumnBool AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -100,9 +101,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable { MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn") VsAiColumn = postgres.BoolColumn("vs_ai") 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} - mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn} - defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn} + NoRepeatWordsColumn = postgres.BoolColumn("no_repeat_words") + 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} + 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{ @@ -130,6 +132,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable { MultipleWordsPerTurn: MultipleWordsPerTurnColumn, VsAi: VsAiColumn, GameKind: GameKindColumn, + NoRepeatWords: NoRepeatWordsColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00017_no_repeat_words.sql b/backend/internal/postgres/migrations/00017_no_repeat_words.sql new file mode 100644 index 0000000..ad0ed00 --- /dev/null +++ b/backend/internal/postgres/migrations/00017_no_repeat_words.sql @@ -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; diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 23b7ce2..d4f2fc6 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -148,6 +148,9 @@ type gameDTO struct { ToMove int `json:"to_move"` TurnTimeoutSecs int `json:"turn_timeout_secs"` 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 // are suppressed in the client. VsAI bool `json:"vs_ai"` @@ -296,6 +299,7 @@ func gameDTOFromGame(g game.Game) gameDTO { ToMove: g.ToMove, TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), MultipleWordsPerTurn: g.MultipleWordsPerTurn, + NoRepeatWords: g.NoRepeatWords, VsAI: g.VsAI, Kind: int(g.Kind), MoveCount: g.MoveCount, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4f95ad3..99edbe5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -561,6 +561,24 @@ Key points: 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 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** **6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or a missed turn. The **per-game turn timeout** is chosen at creation diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f82a2d8..298e9ac 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -196,7 +196,16 @@ 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), 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 -the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) 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; 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 (2–4) are 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 Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ee3e542..aa526e1 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -202,7 +202,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются; включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми, -кто выбрал то же правило. Игры с друзьями (2–4) +кто выбрал то же правило. + +**В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить +его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске, +отклоняется как любой недопустимый; ход, который лишь образует уже выставленное слово **поперёк** +основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не +выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных +правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры** +содержит «слова без повтора». Партии, начатые до появления правила, продолжают играть без него, +так что правила идущей партии не меняются у игроков на ходу. Игры с друзьями (2–4) формируются приглашением игроков из списка друзей (приглашение, как и код друга, можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index a41b6ab..9bbb2c9 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -183,6 +183,7 @@ type GameResp struct { UnreadChat bool `json:"unread_chat"` UnreadMessages bool `json:"unread_messages"` 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 diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index c219408..b53618d 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -638,6 +638,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView { UnreadChat: g.UnreadChat, UnreadMessages: g.UnreadMessages, Kind: g.Kind, + NoRepeatWords: g.NoRepeatWords, } } diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 5f2c553..4c54a59 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -88,6 +88,11 @@ table GameView { // caller's per-kind limits (Profile.game_limits) to lock a capped New-Game start (added // trailing — backward-compatible). 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). diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index 31c8ce3..4f82e37 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -221,8 +221,20 @@ func (rcv *GameView) MutateKind(n int32) bool { 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) { - builder.StartObject(16) + builder.StartObject(17) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { 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) { builder.PrependInt32Slot(15, kind, 0) } +func GameViewAddNoRepeatWords(builder *flatbuffers.Builder, noRepeatWords bool) { + builder.PrependBoolSlot(16, noRepeatWords, false) +} func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 3ab30d8..018a001 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -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, // 2 random, 3 friends. The lobby counts active games per kind to lock a capped New-Game start. 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 @@ -177,6 +180,7 @@ func BuildGameView(b *flatbuffers.Builder, g GameView) flatbuffers.UOffsetT { fb.GameViewAddUnreadChat(b, g.UnreadChat) fb.GameViewAddUnreadMessages(b, g.UnreadMessages) fb.GameViewAddKind(b, int32(g.Kind)) + fb.GameViewAddNoRepeatWords(b, g.NoRepeatWords) return fb.GameViewEnd(b) } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 56ab932..9c631cf 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -276,6 +276,18 @@ handleError(e); } } + // playedWords is the no-repeat-words rule's lookup set for the on-device preview: every word this + // game has already formed, as the history carries them (decoded, lower-case). Only a game that + // takes the rule needs it; for any other the preview scores unrestricted, so it returns undefined. + function playedWords(): Set | undefined { + if (!view?.game.noRepeatWords) return undefined; + const played = new Set(); + for (const m of moves) { + if (m.action === 'play') for (const w of m.words) played.add(w); + } + return played; + } + let draftSaveTimer: ReturnType | null = null; // scheduleDraftSave persists the composition (rack order + pending tiles) after a short // debounce; best-effort, so a failed save never interrupts play. The cache is updated at once @@ -833,7 +845,7 @@ const reader = d.peekDawg(v.game.variant, v.game.dictVersion); if (reader && hasAlphabet(v.game.variant)) { try { - preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn); + preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn, playedWords()); notePreviewLocal(); return; } catch { diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index e56cad1..4192197 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -118,8 +118,13 @@ kind():number { 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) { - builder.startObject(16); + builder.startObject(17); } static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { @@ -198,12 +203,16 @@ static addKind(builder:flatbuffers.Builder, kind:number) { builder.addFieldInt32(15, kind, 0); } +static addNoRepeatWords(builder:flatbuffers.Builder, noRepeatWords:boolean) { + builder.addFieldInt8(16, +noRepeatWords, +false); +} + static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); 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.addId(builder, idOffset); GameView.addVariant(builder, variantOffset); @@ -221,6 +230,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, GameView.addUnreadChat(builder, unreadChat); GameView.addUnreadMessages(builder, unreadMessages); GameView.addKind(builder, kind); + GameView.addNoRepeatWords(builder, noRepeatWords); return GameView.endGameView(builder); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index fc7fdd3..eec03b6 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -423,6 +423,7 @@ describe('codec', () => { fb.GameView.addVsAi(b, true); fb.GameView.addUnreadChat(b, true); fb.GameView.addKind(b, 2); + fb.GameView.addNoRepeatWords(b, true); const game = fb.GameView.endGameView(b); const games = fb.GameList.createGamesVector(b, [game]); fb.GameList.startGameList(b); @@ -441,6 +442,9 @@ describe('codec', () => { expect(gl.games[0].vsAi).toBe(true); expect(gl.games[0].unreadChat).toBe(true); 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); }); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 1a3500a..949797a 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -310,6 +310,7 @@ function decodeGameView(g: fb.GameView): GameView { toMove: g.toMove(), turnTimeoutSecs: g.turnTimeoutSecs(), multipleWordsPerTurn: g.multipleWordsPerTurn(), + noRepeatWords: g.noRepeatWords(), moveCount: g.moveCount(), endReason: s(g.endReason()), lastActivityUnix: Number(g.lastActivityUnix()), @@ -1143,6 +1144,7 @@ function emptyGame(): GameView { toMove: 0, turnTimeoutSecs: 0, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/lib/dict/eval.ts b/ui/src/lib/dict/eval.ts index 2db8b2a..72a762b 100644 --- a/ui/src/lib/dict/eval.ts +++ b/ui/src/lib/dict/eval.ts @@ -9,6 +9,7 @@ import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate'; import { playDirection } from './direction'; +import { noRepeatScore } from './norepeat'; import type { Board as ClientBoard } from '../board'; import type { PlacedTile } from '../client'; 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 // 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 = ''; for (const i of letters) s += letterForIndex(variant, i); 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 * 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 - * cross-word rule. 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. + * cross-word rule. playedWords is the game's already-played words (decoded, as the + * 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( dict: Dict, @@ -83,6 +88,7 @@ export function evaluateLocal( board: ClientBoard, tiles: PlacedTile[], multipleWords: boolean, + playedWords?: ReadonlySet, ): EvalResult { const vboard = buildBoard(variant, board); const rs = buildRuleset(variant, multipleWords); @@ -100,5 +106,9 @@ export function evaluateLocal( } const m = res.move; 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) { + return { legal: false, score: 0, words: [], dir: '' }; + } + return { legal: true, score, words, dir: dir === Horizontal ? 'H' : 'V' }; } diff --git a/ui/src/lib/dict/norepeat.test.ts b/ui/src/lib/dict/norepeat.test.ts new file mode 100644 index 0000000..2840c08 --- /dev/null +++ b/ui/src/lib/dict/norepeat.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/dict/norepeat.ts b/ui/src/lib/dict/norepeat.ts new file mode 100644 index 0000000..38e2884 --- /dev/null +++ b/ui/src/lib/dict/norepeat.ts @@ -0,0 +1,59 @@ +// 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'; + +/** + * 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): Set { + const played = new Set(); + for (const w of words) { + if (w.length > 0) played.add(wordKey(w)); + } + 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, + 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; +} diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index 58631a0..b942cbb 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -16,6 +16,7 @@ function gameView(id: string): GameView { toMove: 0, turnTimeoutSecs: 300, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 5349f8c..ae77f76 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -17,6 +17,7 @@ function gameView(moveCount: number, over = false): GameView { toMove: 1, turnTimeoutSecs: 300, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount, endReason: over ? 'standard' : '', lastActivityUnix: 0, diff --git a/ui/src/lib/gameimage.test.ts b/ui/src/lib/gameimage.test.ts index 904178d..b741f31 100644 --- a/ui/src/lib/gameimage.test.ts +++ b/ui/src/lib/gameimage.test.ts @@ -20,6 +20,7 @@ function game(seats: Seat[], over = true): GameView { toMove: 0, turnTimeoutSecs: 300, multipleWordsPerTurn: false, + noRepeatWords: false, moveCount: 0, endReason: over ? 'standard' : '', lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC) diff --git a/ui/src/lib/gamelimits.test.ts b/ui/src/lib/gamelimits.test.ts index 12aa3c2..e0ee3ae 100644 --- a/ui/src/lib/gamelimits.test.ts +++ b/ui/src/lib/gamelimits.test.ts @@ -13,6 +13,7 @@ function game(kind: number, status: string, extra: Partial = {}): Game toMove: 0, turnTimeoutSecs: 0, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 55fe818..d431b15 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -79,7 +79,7 @@ export const en = { 'new.searching': 'Looking for an opponent…', 'new.rulesEnglish': '100 tiles · 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.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.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 1af501d..f10e86e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -80,7 +80,7 @@ export const ru: Record = { 'new.searching': 'Ищем соперника…', 'new.rulesEnglish': '100 фишек · бинго +50', 'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50', - 'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15', + 'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15 · слова без повтора', 'new.moveLimit': 'Время на ход: {n} ч. 00 мин.', 'new.searchHint': 'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.', diff --git a/ui/src/lib/lobbycache.test.ts b/ui/src/lib/lobbycache.test.ts index 9e21401..0016d40 100644 --- a/ui/src/lib/lobbycache.test.ts +++ b/ui/src/lib/lobbycache.test.ts @@ -12,7 +12,7 @@ function invitation(id: string, status: string): Invitation { hintsAllowed: true, hintsPerPlayer: 0, multipleWordsPerTurn: true, - dropoutTiles: 'remove', + dropoutTiles: 'remove', status, gameId: '', expiresAtUnix: 0, @@ -33,6 +33,7 @@ function gameView(id: string, status: GameView['status'] = 'active', toMove = 0) toMove, turnTimeoutSecs: 300, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index c8f758f..7e288c1 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -29,6 +29,7 @@ function game(id: string, status: GameView['status'], toMove: number, lastActivi toMove, turnTimeoutSecs: 0, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: 0, endReason: '', lastActivityUnix, diff --git a/ui/src/lib/localgame/engine.norepeat.test.ts b/ui/src/lib/localgame/engine.norepeat.test.ts new file mode 100644 index 0000000..e0d602f --- /dev/null +++ b/ui/src/lib/localgame/engine.norepeat.test.ts @@ -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 + }); +}); diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts index eaf50b7..eab5d1c 100644 --- a/ui/src/lib/localgame/engine.ts +++ b/ui/src/lib/localgame/engine.ts @@ -14,6 +14,7 @@ import { Bag } from './bag'; import { RULESETS } from './ruleset'; import { generateMoves, GenRack, Both } from '../dict/generate'; 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 { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; import { BLANK_INDEX } from '../alphabet'; @@ -154,6 +155,10 @@ export interface LocalGameOptions { players: number; dawg: Dawg; 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; } @@ -175,6 +180,7 @@ export class LocalGame { private readonly rackSize: number; private readonly dawg: Dawg; private readonly multipleWords: boolean; + private readonly noRepeatWords: boolean; private readonly dropoutTiles: DropoutTiles; private readonly board: LocalBoard; @@ -197,6 +203,7 @@ export class LocalGame { this.seed = opts.seed; this.dawg = opts.dawg; this.multipleWords = opts.multipleWords; + this.noRepeatWords = opts.noRepeatWords ?? false; this.dropoutTiles = opts.dropoutTiles ?? 'remove'; this.vrs = buildRuleset(opts.variant, opts.multipleWords); this.values = RULESETS[opts.variant].values; @@ -250,17 +257,39 @@ export class LocalGame { } /** config returns the rule settings the game was created with — the bits, alongside the seed and * journal, needed to reconstruct it (see localgame/serialize). */ - get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } { - return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles }; + get config(): { multipleWords: boolean; noRepeatWords: boolean; dropoutTiles: DropoutTiles } { + return { multipleWords: this.multipleWords, noRepeatWords: this.noRepeatWords, dropoutTiles: this.dropoutTiles }; } /** history returns a copy of the move log. */ get history(): LocalMove[] { 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[] { - 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 { + 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, @@ -271,7 +300,9 @@ export class LocalGame { const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir }; 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 }; + 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. */ @@ -299,10 +330,12 @@ export class LocalGame { const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); if (!res.legal || !res.move) throw new GameError('illegal_play'); 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); this.removeFromHand(player, used); - this.scores[player] += move.score; + this.scores[player] += score; this.refill(player); this.scorelessRun = 0; @@ -314,7 +347,7 @@ export class LocalGame { mainRow: move.main.row, mainCol: move.main.col, words: [move.main.letters, ...move.cross.map((w) => w.letters)], - score: move.score, + score, total: this.scores[player], }; this.log.push(rec); diff --git a/ui/src/lib/localgame/serialize.ts b/ui/src/lib/localgame/serialize.ts index f8b923e..526c164 100644 --- a/ui/src/lib/localgame/serialize.ts +++ b/ui/src/lib/localgame/serialize.ts @@ -35,6 +35,10 @@ export interface LocalGameRecord { seed: string; players: number; 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; seats: Seat[]; /** The dictionary-independent, alphabet-index-space move journal. */ @@ -87,6 +91,7 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor seed: game.seed.toString(), players: game.playerCount, multipleWords: cfg.multipleWords, + noRepeatWords: cfg.noRepeatWords || undefined, dropoutTiles: cfg.dropoutTiles, seats: meta.seats, journal: game.history, @@ -113,6 +118,7 @@ export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame { players: record.players, dawg, multipleWords: record.multipleWords, + noRepeatWords: record.noRepeatWords, dropoutTiles: record.dropoutTiles, }; const game = new LocalGame(opts); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 1ee39fd..45696b9 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -18,6 +18,7 @@ import { HINT_GATE_MS } from '../hints'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { decide } from '../robot/strategy'; import { verifyPin, type PinLock } from '../pin'; +import { noRepeatWordsFor } from '../variants'; import { GatewayError, type PlacedTile } from '../client'; import type { EvalResult, @@ -122,6 +123,7 @@ export class LocalSource implements GameLoopSource { players: opts.seats.length, dawg, multipleWords: opts.multipleWords, + noRepeatWords: noRepeatWordsFor(opts.variant), }); const now = nowUnix(); const record = serializeGame(game, { @@ -483,6 +485,7 @@ export class LocalSource implements GameLoopSource { toMove: game.currentPlayer, turnTimeoutSecs: 0, multipleWordsPerTurn: record.multipleWords, + noRepeatWords: !!record.noRepeatWords, moveCount: game.moveCount, endReason: game.isOver ? game.endReason : '', lastActivityUnix: record.updatedAtUnix, diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 4658a3c..1d763c8 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -46,6 +46,7 @@ import type { Catalog, } from '../model'; import { valueForLetter } from '../alphabet'; +import { noRepeatWordsFor } from '../variants'; import { seedMockAlphabets } from './alphabet'; import { ME, @@ -292,6 +293,7 @@ export class MockGateway implements GatewayClient { toMove: 0, turnTimeoutSecs: 604800, multipleWordsPerTurn: multipleWords, + noRepeatWords: noRepeatWordsFor(variant), moveCount: 0, endReason: '', lastActivityUnix: Math.floor(Date.now() / 1000), @@ -326,6 +328,7 @@ export class MockGateway implements GatewayClient { toMove: 0, turnTimeoutSecs: 86400, multipleWordsPerTurn: multipleWords, + noRepeatWords: noRepeatWordsFor(variant), moveCount: 0, endReason: '', lastActivityUnix: Math.floor(Date.now() / 1000), diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index b4a60f8..5f80ce7 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -113,7 +113,7 @@ export function mockInvitations(): Invitation[] { hintsAllowed: true, hintsPerPlayer: 1, multipleWordsPerTurn: false, - dropoutTiles: 'remove', + dropoutTiles: 'remove', status: 'pending', gameId: '', expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400, @@ -198,6 +198,7 @@ function activeGame(): MockGame { toMove: 0, turnTimeoutSecs: 86400, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: G1_MOVES.length, endReason: '', lastActivityUnix: Math.floor(Date.now() / 1000) - 7200, @@ -237,6 +238,7 @@ function finishedG2(): MockGame { toMove: 0, turnTimeoutSecs: 86400, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: 2, endReason: 'normal', lastActivityUnix: Math.floor(Date.now() / 1000) - 86400, @@ -277,6 +279,7 @@ function finishedG3(): MockGame { toMove: 0, turnTimeoutSecs: 86400, multipleWordsPerTurn: false, + noRepeatWords: false, moveCount: 1, endReason: 'resignation', lastActivityUnix: Math.floor(Date.now() / 1000) - 172800, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index a5d90df..e948266 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -48,6 +48,10 @@ export interface GameView { turnTimeoutSecs: number; /** true = standard Scrabble; false = the single-word rule (Russian games). */ 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; endReason: string; /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index 4694313..12b39ec 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -28,6 +28,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView { toMove: 0, turnTimeoutSecs: 300, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 8fe89b9..5a89102 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -26,6 +26,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView { toMove, turnTimeoutSecs: 0, multipleWordsPerTurn: true, + noRepeatWords: false, moveCount: 0, endReason: '', lastActivityUnix: 0, diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts index b520082..a1c79b1 100644 --- a/ui/src/lib/variants.ts +++ b/ui/src/lib/variants.ts @@ -28,6 +28,15 @@ export function variantNameKey(v: Variant): MessageKey { 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 // buttons (bag size, the ё rule, bonus differences), sourced from the engine rulesets. export const VARIANT_RULES: Record = {