Files
scrabble-game/backend/internal/engine/registry.go
T
Ilia Denisov 6d0dd4fb14
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 7s
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).
2026-06-02 15:10:08 +02:00

156 lines
4.6 KiB
Go

package engine
import (
"fmt"
"path/filepath"
"sort"
"sync"
dawg "github.com/iliadenisov/dafsa"
"scrabble-solver/scrabble"
)
// dictFiles maps each variant to its committed DAWG filename, as built by
// scrabble-solver and delivered in the dictionary directory.
var dictFiles = map[Variant]string{
VariantEnglish: "en_sowpods.dawg",
VariantRussianScrabble: "ru_scrabble.dawg",
VariantErudit: "ru_erudit.dawg",
}
// entry is one resident dictionary: the loaded finder and the solver built over
// it. The finder is retained so Close can release it.
type entry struct {
finder dawg.Finder
solver *scrabble.Solver
}
// Registry holds the dictionaries resident in memory, addressed by variant and
// dictionary version, and the solvers built over them. Several versions of a
// variant may be resident at once; a game pins the version it started on. The
// admin reload flow (a later stage) registers a new version through Load.
// Registry is safe for concurrent use.
type Registry struct {
mu sync.RWMutex
entries map[Variant]map[string]entry
latest map[Variant]string
}
// NewRegistry constructs an empty Registry. Use Load or Open to populate it.
func NewRegistry() *Registry {
return &Registry{
entries: make(map[Variant]map[string]entry),
latest: make(map[Variant]string),
}
}
// Open builds a Registry by loading, at dictionary version, the committed DAWG of
// every requested variant (or all variants when none are named) from dir. It
// fails if a variant is unknown or its file is missing or unreadable; a
// partially loaded registry is closed before the error is returned.
func Open(dir, version string, variants ...Variant) (*Registry, error) {
if len(variants) == 0 {
variants = Variants()
}
r := NewRegistry()
for _, v := range variants {
if err := r.Load(v, version, dir); err != nil {
_ = r.Close()
return nil, err
}
}
return r, nil
}
// Load reads the committed DAWG of variant from dir, builds a solver over it and
// registers it under version. Reloading the same (variant, version) replaces the
// previous entry, closing its finder. The most recently loaded version of a
// variant becomes its latest.
func (r *Registry) Load(v Variant, version, dir string) error {
rs, ok := v.ruleset()
if !ok {
return fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
path := filepath.Join(dir, dictFiles[v])
finder, err := dawg.Load(path)
if err != nil {
return fmt.Errorf("engine: load %s dictionary %q from %s: %w", v, version, path, err)
}
r.mu.Lock()
defer r.mu.Unlock()
if r.entries[v] == nil {
r.entries[v] = make(map[string]entry)
}
if old, ok := r.entries[v][version]; ok {
_ = old.finder.Close()
}
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
r.latest[v] = version
return nil
}
// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant
// when the variant is absent and ErrUnknownVersion when only the version is.
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
r.mu.RLock()
defer r.mu.RUnlock()
versions, ok := r.entries[v]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
}
e, ok := versions[version]
if !ok {
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
}
return e.solver, nil
}
// Latest returns the most recently loaded version of variant and its solver, or
// ErrUnknownVariant when none is resident.
func (r *Registry) Latest(v Variant) (string, *scrabble.Solver, error) {
r.mu.RLock()
defer r.mu.RUnlock()
version, ok := r.latest[v]
if !ok {
return "", nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
}
return version, r.entries[v][version].solver, nil
}
// Versions returns the dictionary versions resident for variant, sorted, or nil
// when none are.
func (r *Registry) Versions(v Variant) []string {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.entries[v]) == 0 {
return nil
}
versions := make([]string, 0, len(r.entries[v]))
for ver := range r.entries[v] {
versions = append(versions, ver)
}
sort.Strings(versions)
return versions
}
// Close releases every resident dictionary and empties the registry. It is safe
// to call more than once; the first close error is returned after all finders
// have been closed.
func (r *Registry) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
var firstErr error
for v, versions := range r.entries {
for ver, e := range versions {
if err := e.finder.Close(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("engine: close %s/%s dictionary: %w", v, ver, err)
}
}
}
r.entries = make(map[Variant]map[string]entry)
r.latest = make(map[Variant]string)
return firstErr
}