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.
221 lines
8.1 KiB
Go
221 lines
8.1 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
|
)
|
|
|
|
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
|
|
// in a rack it is an undesignated blank, and in an exchange it is a blank being
|
|
// swapped. A blank placed on the board is a TileRecord with Blank set and Letter
|
|
// holding the concrete letter it stands for.
|
|
const blankLetter = "?"
|
|
|
|
// Direction is the orientation of a play as seen by the game domain. It decouples
|
|
// the domain from the solver's own Direction type: internal/engine is the only
|
|
// backend package that imports scrabble-solver (see docs/ARCHITECTURE.md §5), so
|
|
// the engine accepts and returns decoded, solver-free values.
|
|
type Direction uint8
|
|
|
|
const (
|
|
// Horizontal lays a word left to right along a row.
|
|
Horizontal Direction = iota
|
|
// Vertical lays a word top to bottom down a column.
|
|
Vertical
|
|
)
|
|
|
|
// String renders the direction as "H" or "V", the form the move journal and GCG
|
|
// export use.
|
|
func (d Direction) String() string {
|
|
if d == Vertical {
|
|
return "V"
|
|
}
|
|
return "H"
|
|
}
|
|
|
|
// scrabbleDir maps the domain Direction to the solver's Direction.
|
|
func (d Direction) scrabbleDir() scrabble.Direction {
|
|
if d == Vertical {
|
|
return scrabble.Vertical
|
|
}
|
|
return scrabble.Horizontal
|
|
}
|
|
|
|
// fromScrabbleDir maps the solver's Direction to the domain Direction.
|
|
func fromScrabbleDir(d scrabble.Direction) Direction {
|
|
if d == scrabble.Vertical {
|
|
return Vertical
|
|
}
|
|
return Horizontal
|
|
}
|
|
|
|
// SubmitPlay validates and applies the current player's play described in decoded
|
|
// terms: each TileRecord carries a concrete letter (the letter a blank stands for
|
|
// when Blank is set) and a board coordinate. It infers the play's orientation
|
|
// from the tiles and the board (resolveDirection), encodes the tiles through the
|
|
// ruleset alphabet and delegates to Play, so it returns the same errors
|
|
// (ErrTilesNotOnRack, ErrIllegalPlay, ErrGameOver) plus ErrIllegalPlay when a
|
|
// letter is outside the variant's alphabet.
|
|
func (g *Game) SubmitPlay(tiles []TileRecord) (MoveRecord, error) {
|
|
placements, err := g.placements(tiles)
|
|
if err != nil {
|
|
return MoveRecord{}, err
|
|
}
|
|
return g.Play(g.playDirection(placements), placements)
|
|
}
|
|
|
|
// SubmitPlayDir is SubmitPlay with the orientation supplied rather than inferred.
|
|
// It exists for journal replay, which reproduces a committed game exactly from
|
|
// the stored "H"/"V" rather than re-deriving it (docs/ARCHITECTURE.md §9.1):
|
|
// re-derivation would tie historical reconstruction to the current resolver, so
|
|
// replay trusts the recorded direction. Live play uses SubmitPlay.
|
|
func (g *Game) SubmitPlayDir(dir Direction, tiles []TileRecord) (MoveRecord, error) {
|
|
placements, err := g.placements(tiles)
|
|
if err != nil {
|
|
return MoveRecord{}, err
|
|
}
|
|
return g.Play(dir.scrabbleDir(), placements)
|
|
}
|
|
|
|
// SubmitExchange swaps the current player's tiles, named in decoded terms: a
|
|
// concrete letter per tile, or "?" for a blank. It encodes them and delegates to
|
|
// Exchange, returning the same errors plus ErrTilesNotOnRack when a letter is
|
|
// outside the variant's alphabet.
|
|
func (g *Game) SubmitExchange(tiles []string) (MoveRecord, error) {
|
|
raw, err := g.encodeTiles(tiles)
|
|
if err != nil {
|
|
return MoveRecord{}, err
|
|
}
|
|
return g.Exchange(raw)
|
|
}
|
|
|
|
// EvaluatePlay scores and validates a tentative play without committing it,
|
|
// backing the unlimited "what would my next move score, and is it legal?" tool.
|
|
// It infers the play's orientation from the tiles and the board and applies the
|
|
// game's play options exactly as SubmitPlay does, so under the single-word rule
|
|
// perpendicular cross-words are ignored: the preview's legality and score then
|
|
// match what submitting the play would yield. It returns the decoded move (placed
|
|
// tiles, the words it forms, its orientation and its score) or ErrIllegalPlay when
|
|
// the solver rejects it. The board, racks, bag and turn are left untouched.
|
|
func (g *Game) EvaluatePlay(tiles []TileRecord) (MoveRecord, error) {
|
|
if g.over {
|
|
return MoveRecord{}, ErrGameOver
|
|
}
|
|
placements, err := g.placements(tiles)
|
|
if err != nil {
|
|
return MoveRecord{}, err
|
|
}
|
|
move, err := g.solver.ValidatePlayOpts(g.board, g.playDirection(placements), placements, g.playOpts())
|
|
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
|
|
}
|
|
|
|
// HintView returns the highest-scoring legal play for the current player as a
|
|
// decoded MoveRecord and true, or a zero record and false when there is none. It
|
|
// is the one-per-game hint's top-1 move in domain-facing form.
|
|
func (g *Game) HintView() (MoveRecord, bool) {
|
|
move, ok := g.Hint()
|
|
if !ok {
|
|
return MoveRecord{}, false
|
|
}
|
|
return g.decodeMove(move), true
|
|
}
|
|
|
|
// Candidates returns every legal play for the current player as decoded
|
|
// MoveRecords, ranked by descending score (so the first entry equals HintView's
|
|
// move). It is empty when the player has no legal play. The robot opponent picks
|
|
// from these by margin without importing the solver; each record carries the
|
|
// move's score, so a caller can choose by resulting score difference rather than
|
|
// always taking the maximum.
|
|
func (g *Game) Candidates() []MoveRecord {
|
|
moves := g.GenerateMoves()
|
|
out := make([]MoveRecord, len(moves))
|
|
for i, m := range moves {
|
|
out[i] = g.decodeMove(m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Hand returns the player's current rack decoded to concrete letters, with "?"
|
|
// for each undesignated blank. The order mirrors the internal hand. It supplies
|
|
// the GCG rack field and the per-player game-state view.
|
|
func (g *Game) Hand(player int) []string {
|
|
hand := g.hands[player]
|
|
out := make([]string, len(hand))
|
|
for i, t := range hand {
|
|
if t == blankTile {
|
|
out[i] = blankLetter
|
|
continue
|
|
}
|
|
out[i] = g.letter(t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// placements encodes decoded tiles into solver placements via the ruleset
|
|
// alphabet, wrapping a bad letter as ErrIllegalPlay.
|
|
func (g *Game) placements(tiles []TileRecord) ([]scrabble.Placement, error) {
|
|
out := make([]scrabble.Placement, len(tiles))
|
|
for i, t := range tiles {
|
|
idx, err := g.rules.Alphabet.Index(t.Letter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: letter %q at (%d,%d): %v", ErrIllegalPlay, t.Letter, t.Row, t.Col, err)
|
|
}
|
|
out[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// playDirection resolves the orientation for a live play. resolveDirection infers it from
|
|
// geometry alone, preferring the longer word when a single tile abuts the board on both
|
|
// axes; under the single-word rule that can pick an orientation whose word is not in the
|
|
// dictionary while the other orientation's is. So for a single tile under that rule the
|
|
// engine tries both orientations through the solver and keeps the higher-scoring legal one
|
|
// (horizontal breaks a tie). Multi-tile plays, and every play under the standard rule, keep
|
|
// the geometric resolution: a multi-tile play's orientation is fixed by the line its tiles
|
|
// share, and under the standard rule every word the play forms must be valid regardless of
|
|
// which one is named the main word.
|
|
func (g *Game) playDirection(placements []scrabble.Placement) scrabble.Direction {
|
|
geo := resolveDirection(g.board, placements)
|
|
if len(placements) != 1 || g.multipleWords {
|
|
return geo
|
|
}
|
|
best, found, bestScore := geo, false, 0
|
|
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
|
|
m, err := g.solver.ValidatePlayOpts(g.board, dir, placements, g.playOpts())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !found || m.Score > bestScore {
|
|
best, found, bestScore = dir, true, m.Score
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// encodeTiles encodes decoded exchange tiles ("?" for a blank, otherwise a
|
|
// concrete letter) into the internal byte form, wrapping a bad letter as
|
|
// ErrTilesNotOnRack (the caller cannot hold a tile it cannot name).
|
|
func (g *Game) encodeTiles(tiles []string) ([]byte, error) {
|
|
raw := make([]byte, len(tiles))
|
|
for i, t := range tiles {
|
|
if t == blankLetter {
|
|
raw[i] = blankTile
|
|
continue
|
|
}
|
|
idx, err := g.rules.Alphabet.Index(t)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: tile %q: %v", ErrTilesNotOnRack, t, err)
|
|
}
|
|
raw[i] = idx
|
|
}
|
|
return raw, nil
|
|
}
|