feat(rules): forbid repeating a word already on the board in Erudit
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s

Russian "Эрудит" treats a word laid on the board as belonging to the
game: it cannot be laid again. Neither the solver, the backend nor the
offline JS port knew the rule, so a player (and the robot) could replay a
word freely. Official Scrabble places no such restriction, so both
Scrabble variants keep playing unrestricted.

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

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

The rule is pinned per game (games.no_repeat_words, set from the variant
at creation) rather than keyed on the variant, because a game is replayed
from its journal on every open. Applied retroactively it would make an
already-played repeat illegal — closing that game as a draw — and would
rescore a play whose cross-word repeats an earlier word, shifting a live
game's totals. Games created before the rule keep playing without it. The
flag rides the wire as a trailing field because the client's preview must
score the way the server does, and offline games pin the same answer in
their own record.
This commit is contained in:
Ilia Denisov
2026-07-27 18:33:56 +02:00
parent ebd1f05da8
commit d7337d24ea
49 changed files with 802 additions and 26 deletions
+4
View File
@@ -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
}
+12 -1
View File
@@ -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,
+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
}
+175
View File
@@ -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)
}
}
+1
View File
@@ -57,6 +57,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
EndReason: g.EndReason,
Seats: seats,
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,
DropoutTiles: pre.DropoutTiles,
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
NoRepeatWords: pre.NoRepeatWords,
})
if err != nil {
return ReplayTimelineView{}, err
+11
View File
@@ -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 (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,
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
+6 -1
View File
@@ -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 {
+4
View File
@@ -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
+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,
Seats: seats,
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);
// 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
@@ -34,4 +34,5 @@ type Games struct {
MultipleWordsPerTurn bool
VsAi bool
GameKind int16
NoRepeatWords bool
}
@@ -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,
@@ -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"`
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,
+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
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
+10 -1
View File
@@ -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 (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; 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,
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
+10 -1
View File
@@ -202,7 +202,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
этой линии), а случайные перпендикулярные слова игнорируются и не засчитываются;
включена — обычный скрэббл. Английские игры всегда по стандартным правилам и тоггл не
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4)
кто выбрал то же правило.
**В «Эрудите» слово нельзя повторять.** Выставленное на доску слово принадлежит партии: выставить
его ещё раз нельзя, и робот такого хода тоже не делает. Ход, чьё основное слово уже стоит на доске,
отклоняется как любой недопустимый; ход, который лишь образует уже выставленное слово **поперёк**
основного, засчитывается — такое слово побочно для хода, — но очков не приносит. Правило не
выбирается: его берёт каждая партия «Эрудита», и никогда — оба варианта скрэббла (в официальных
правилах такого ограничения нет), поэтому однострочное описание варианта на экране **новой игры**
содержит «слова без повтора». Партии, начатые до появления правила, продолжают играть без него,
так что правила идущей партии не меняются у игроков на ходу. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
+1
View File
@@ -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
+1
View File
@@ -638,6 +638,7 @@ func toWireGame(g backendclient.GameResp) wire.GameView {
UnreadChat: g.UnreadChat,
UnreadMessages: g.UnreadMessages,
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
// 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).
+16 -1
View File
@@ -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()
}
+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,
// 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)
}
+13 -1
View File
@@ -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<string> | undefined {
if (!view?.game.noRepeatWords) return undefined;
const played = new Set<string>();
for (const m of moves) {
if (m.action === 'play') for (const w of m.words) played.add(w);
}
return played;
}
let draftSaveTimer: ReturnType<typeof setTimeout> | 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 {
+12 -2
View File
@@ -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);
}
}
+4
View File
@@ -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);
});
+2
View File
@@ -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,
+14 -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 { 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<string>,
): 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' };
}
+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);
});
});
+59
View File
@@ -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<readonly number[]>): Set<string> {
const played = new Set<string>();
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<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,
turnTimeoutSecs: 300,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+1
View File
@@ -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,
+1
View File
@@ -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)
+1
View File
@@ -13,6 +13,7 @@ function game(kind: number, status: string, extra: Partial<GameView> = {}): Game
toMove: 0,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
noRepeatWords: false,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
+1 -1
View File
@@ -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.',
+1 -1
View File
@@ -80,7 +80,7 @@ export const ru: Record<MessageKey, string> = {
'new.searching': 'Ищем соперника…',
'new.rulesEnglish': '100 фишек · бинго +50',
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15 · слова без повтора',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
+2 -1
View File
@@ -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,
+1
View File
@@ -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,
@@ -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
});
});
+40 -7
View File
@@ -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<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,
@@ -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);
+6
View File
@@ -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);
+3
View File
@@ -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,
+3
View File
@@ -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),
+4 -1
View File
@@ -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,
+4
View File
@@ -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. */
+1
View File
@@ -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,
+1
View File
@@ -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,
+9
View File
@@ -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<Variant, MessageKey> = {