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 }