package engine import ( "fmt" "os" "path/filepath" "sort" "sync" dawg "github.com/iliadenisov/dafsa" "gitea.iliadenisov.ru/developer/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 } // OpenWithVersions builds a Registry by loading the boot version from the flat // dir (every variant, as Open) and then every additional version held in an // immediate subdirectory of dir: a subdirectory named V contributes, under // version V, the variants whose committed DAWG it carries. This is the // restart-side of the admin dictionary reload — a version reloaded into dir// // at runtime is resident again after a restart. A subdirectory named like the // boot version is skipped (the flat dir already is the boot version). A partially // loaded registry is closed before any error is returned. func OpenWithVersions(dir, bootVersion string) (*Registry, error) { r, err := Open(dir, bootVersion) if err != nil { return nil, err } entries, err := os.ReadDir(dir) if err != nil { _ = r.Close() return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err) } for _, e := range entries { if !e.IsDir() || e.Name() == bootVersion { continue } if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); 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 } // LoadAvailable loads, under version, every variant whose committed DAWG is // present in dir, skipping a variant whose file is absent. It backs the admin // dictionary reload (a version subdirectory may carry only the variants that were // rebuilt) and OpenWithVersions' boot-time scan. It returns the variants it // loaded, in catalogue order, or the first load error. func (r *Registry) LoadAvailable(dir, version string) ([]Variant, error) { var loaded []Variant for _, v := range Variants() { path := filepath.Join(dir, dictFiles[v]) if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { continue } return loaded, fmt.Errorf("engine: stat %s dictionary %q in %s: %w", v, version, dir, err) } if err := r.Load(v, version, dir); err != nil { return loaded, err } loaded = append(loaded, v) } return loaded, 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 } // Lookup reports whether word is present in the (variant, version) dictionary, // backing the unlimited word-check tool. It returns ErrUnknownVariant or // ErrUnknownVersion when that dictionary is not resident, and an error when word // contains a character outside the variant's alphabet. The word is matched as // given; callers normalise case to the variant's alphabet first. func (r *Registry) Lookup(v Variant, version, word string) (bool, error) { r.mu.RLock() defer r.mu.RUnlock() versions, ok := r.entries[v] if !ok { return false, fmt.Errorf("%w: %s", ErrUnknownVariant, v) } e, ok := versions[version] if !ok { return false, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version) } idx, err := e.finder.IndexOf(word) if err != nil { return false, fmt.Errorf("engine: lookup %q in %s/%s: %w", word, v, version, err) } return idx >= 0, nil } // 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 }