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
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:
@@ -57,6 +57,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
LastActivityUnix: last.Unix(),
|
||||
NoRepeatWords: g.NoRepeatWords,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -284,6 +284,13 @@ func (svc *Service) versionResident(version string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// noRepeatWordsFor reports whether a game of variant v is created with the no-repeat-words rule
|
||||
// (see engine/norepeat.go): a word already on the board cannot be laid again. It is a rule of
|
||||
// Russian "Эрудит" alone — both Scrabble variants let a word be played as often as a player can
|
||||
// manage. The answer is pinned into games.no_repeat_words at creation and read back from there
|
||||
// afterwards, never re-derived, so games created before the rule existed keep their own answer.
|
||||
func noRepeatWordsFor(v engine.Variant) bool { return v == engine.VariantErudit }
|
||||
|
||||
// Create starts and persists a new game seating the given accounts in turn order
|
||||
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
|
||||
// the player count (2–4), the move clock, the hint allowance and that every seat
|
||||
@@ -359,6 +366,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
Seed: seed,
|
||||
DropoutTiles: params.DropoutTiles,
|
||||
MultipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
NoRepeatWords: noRepeatWordsFor(params.Variant),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
|
||||
@@ -382,6 +390,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
hintsPerPlayer: params.HintsPerPlayer,
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
noRepeatWords: noRepeatWordsFor(params.Variant),
|
||||
vsAI: params.VsAI,
|
||||
kind: params.Kind,
|
||||
}
|
||||
@@ -445,6 +454,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
|
||||
hintsPerPlayer: params.HintsPerPlayer,
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
noRepeatWords: noRepeatWordsFor(params.Variant),
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
kind: gamelimits.KindRandom,
|
||||
@@ -1584,6 +1594,7 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
|
||||
Seed: seed,
|
||||
DropoutTiles: pre.DropoutTiles,
|
||||
MultipleWordsPerTurn: pre.MultipleWordsPerTurn,
|
||||
NoRepeatWords: pre.NoRepeatWords,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user