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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func toWireGame(g GameSummary) wire.GameView {
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
LastActivityUnix: g.LastActivityUnix,
|
||||
NoRepeatWords: g.NoRepeatWords,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user