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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user