d7337d24ea
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.
94 lines
3.4 KiB
Go
94 lines
3.4 KiB
Go
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
|
|
}
|