ec435c0e7f
- backend/go.mod pins gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0; the engine's imports use the published module path; go.work drops the solver replace (GOPRIVATE fetches it directly from Gitea). The solver's wordlist/dictdawg are now public packages. - CI (go-unit, integration): drop the solver sibling-clone, set GOPRIVATE, and download the dictionary DAWG release artifact (scrabble-dawg-<DICT_VERSION>.tar.gz from the new scrabble-dictionary repo) for BACKEND_DICT_DIR. - Docs: ARCHITECTURE §5/§11/§13/§14 + backend/README updated to the published-module + release-artifact model. PLAN.md re-scoped Stage 14 to the split and added Stages 15 (deploy infra & test contour), 16 (prod contour), 17 (dual Telegram bots); TODO-1/TODO-2 marked done.
173 lines
5.8 KiB
Go
173 lines
5.8 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 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(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 returns the decoded move (placed tiles, the words it forms and its score)
|
|
// or ErrIllegalPlay when the solver rejects it. The board, racks, bag and turn
|
|
// are left untouched.
|
|
func (g *Game) EvaluatePlay(dir Direction, 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.ValidatePlay(g.board, dir.scrabbleDir(), placements)
|
|
if err != nil {
|
|
return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, 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
|
|
}
|
|
|
|
// 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
|
|
}
|