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,