Stage 2: engine package over scrabble-solver (registry, bag, Game, replay)
backend/internal/engine wraps the sibling scrabble-solver library in-process: - Registry: versioned DAWG load via dafsa.Load, keyed by (variant, dict_version), latest-per-variant; English / Russian / Эрудит handled uniformly. - Bag: own deterministic, seeded tile bag with Draw + Return (for exchanges), since the solver's self-play bag cannot return tiles. - Game: pure rules engine — deal, play/pass/exchange/resign, refill, per-move scoring, turn order, and end-condition detection (empty bag + empty rack, six scoreless turns, resignation) with end-game rack adjustment. - decode/ReplayBoard: dictionary-independent MoveRecords and board replay via scrabble.Apply (no internal/encoding), realising ARCHITECTURE §9.1. Wiring: go.work gains "replace scrabble-solver => ../scrabble-solver"; backend requires scrabble-solver (placeholder) and github.com/iliadenisov/dafsa directly. Both Go CI workflows clone the public solver sibling (master HEAD, no token) and set BACKEND_DICT_DIR. Docs: ARCHITECTURE §5/§14, TESTING engine layer, backend README, and PLAN refinements + deferred TODOs (publish/version solver; split engine vs dictionary generator).
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Package engine is the backend's in-process bridge to the scrabble-solver
|
||||
// library. It catalogues the playable variants, loads versioned dictionaries
|
||||
// into a registry of solvers, and exposes a pure rules engine (the in-memory
|
||||
// Game) that drives a match through legal plays, passes, exchanges and
|
||||
// resignations while detecting the end of the game.
|
||||
//
|
||||
// Two invariants shape the package. First, the solver speaks alphabet-index
|
||||
// bytes that are meaningful only alongside the matching ruleset; every value
|
||||
// that leaves the engine for persistence or display is decoded to concrete
|
||||
// characters (see decode.go and docs/ARCHITECTURE.md §9.1), so archived games
|
||||
// replay independently of any dictionary. Second, the engine owns rules and
|
||||
// scoring only: turn scheduling, the 24-hour timeout, persistence and transport
|
||||
// belong to the game domain in a later stage.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// Variant identifies a Scrabble variant the backend offers. Each maps to a
|
||||
// scrabble-solver ruleset and a committed dictionary.
|
||||
type Variant uint8
|
||||
|
||||
const (
|
||||
// VariantEnglish is standard English Scrabble (the SOWPODS dictionary).
|
||||
VariantEnglish Variant = iota
|
||||
// VariantRussianScrabble is Russian Scrabble.
|
||||
VariantRussianScrabble
|
||||
// VariantErudit is the Russian "Эрудит" variant.
|
||||
VariantErudit
|
||||
)
|
||||
|
||||
// String returns the variant's stable identifier, used in logs and as a metadata
|
||||
// label on persisted games.
|
||||
func (v Variant) String() string {
|
||||
switch v {
|
||||
case VariantEnglish:
|
||||
return "english"
|
||||
case VariantRussianScrabble:
|
||||
return "russian_scrabble"
|
||||
case VariantErudit:
|
||||
return "erudit"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// ruleset returns the scrabble-solver ruleset backing the variant and true, or
|
||||
// (nil, false) for an unrecognised variant.
|
||||
func (v Variant) ruleset() (*rules.Ruleset, bool) {
|
||||
switch v {
|
||||
case VariantEnglish:
|
||||
return rules.English(), true
|
||||
case VariantRussianScrabble:
|
||||
return rules.RussianScrabble(), true
|
||||
case VariantErudit:
|
||||
return rules.Erudit(), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Variants returns the variants the backend offers, in catalogue order.
|
||||
func Variants() []Variant {
|
||||
return []Variant{VariantEnglish, VariantRussianScrabble, VariantErudit}
|
||||
}
|
||||
|
||||
// Ruleset returns the scrabble-solver ruleset for variant. It needs no
|
||||
// dictionary, so it supports dictionary-independent board replay (see
|
||||
// ReplayBoard) from a finished game's variant metadata alone.
|
||||
func Ruleset(v Variant) (*rules.Ruleset, error) {
|
||||
rs, ok := v.ruleset()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
// Sentinel errors returned across the engine. Callers match them with
|
||||
// errors.Is; the wrapped detail carries the offending value.
|
||||
var (
|
||||
// ErrUnknownVariant is returned for a variant the engine does not recognise.
|
||||
ErrUnknownVariant = errors.New("engine: unknown variant")
|
||||
// ErrUnknownVersion is returned when no dictionary is registered for a
|
||||
// (variant, version) pair.
|
||||
ErrUnknownVersion = errors.New("engine: unknown dictionary version")
|
||||
// ErrIllegalPlay wraps a solver validation failure: off-board geometry, a
|
||||
// word absent from the dictionary, or a play that does not connect.
|
||||
ErrIllegalPlay = errors.New("engine: illegal play")
|
||||
// ErrTilesNotOnRack is returned when a play or exchange references tiles the
|
||||
// acting player does not hold.
|
||||
ErrTilesNotOnRack = errors.New("engine: tiles not on the player's rack")
|
||||
// ErrNotEnoughTilesToExchange is returned when an exchange is attempted while
|
||||
// the bag holds fewer tiles than a full rack.
|
||||
ErrNotEnoughTilesToExchange = errors.New("engine: not enough tiles in the bag to exchange")
|
||||
// ErrNothingToExchange is returned for an exchange of zero tiles.
|
||||
ErrNothingToExchange = errors.New("engine: exchange requires at least one tile")
|
||||
// ErrGameOver is returned when a transition is attempted on a finished game.
|
||||
ErrGameOver = errors.New("engine: game is over")
|
||||
)
|
||||
Reference in New Issue
Block a user