0f3671f42d
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
Replace the dictionary hot-reload with an online update flow in the GM console (/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the per-variant words added/removed against the active dictionary, and confirms to install + activate it. Versions are immutable; in-progress games keep their pinned version while new games use the new one. - engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder, Registry.Finder, DictFiles; OpenWithVersions skips the .staging area. - dictadmin: hardened release-archive validation + extraction (path-traversal, symlink, oversize, entry-count rejection) and staging -> install (atomic rename). - game: active dictionary version persisted in the dictionary_state singleton (single source of truth, restored on boot), concurrency-safe accessor. - storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned), so uploaded versions persist across redeploys; the build's DICT_VERSION labels the seed and equals the resident tag (BACKEND_DICT_VERSION). - docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE. Tests: engine + dictadmin unit; integration upload->preview->install->activate-> restart->pin->immutability->CSRF.
135 lines
4.2 KiB
Go
135 lines
4.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"maps"
|
|
"path/filepath"
|
|
"sort"
|
|
|
|
dawg "github.com/iliadenisov/dafsa"
|
|
)
|
|
|
|
// WordDiff is the set difference between two dictionaries of one variant: the
|
|
// words present only in the new dictionary (Added) and only in the old one
|
|
// (Removed), decoded to characters and each sorted by the variant's alphabet
|
|
// order. It drives the admin dictionary-update preview.
|
|
type WordDiff struct {
|
|
Added []string
|
|
Removed []string
|
|
}
|
|
|
|
// DictFiles returns a copy of the variant-to-committed-DAWG-filename map. It lets
|
|
// callers outside the package (the dictionary-admin upload validation) check an
|
|
// archive against the expected file set without sharing the engine's own map.
|
|
func DictFiles() map[Variant]string {
|
|
out := make(map[Variant]string, len(dictFiles))
|
|
maps.Copy(out, dictFiles)
|
|
return out
|
|
}
|
|
|
|
// OpenFinder loads the committed DAWG of variant v from dir and returns its
|
|
// finder. The caller owns the finder and must Close it. It backs enumeration of
|
|
// a staged, not-yet-registered dictionary version.
|
|
func OpenFinder(dir string, v Variant) (dawg.Finder, error) {
|
|
name, ok := dictFiles[v]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
|
}
|
|
path := filepath.Join(dir, name)
|
|
finder, err := dawg.Load(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("engine: load %s dictionary from %s: %w", v, path, err)
|
|
}
|
|
return finder, nil
|
|
}
|
|
|
|
// Finder returns the loaded finder for the (variant, version) pair so its words
|
|
// can be enumerated, or ErrUnknownVariant / ErrUnknownVersion when that
|
|
// dictionary is not resident. The finder remains owned by the registry; callers
|
|
// must not Close it.
|
|
func (r *Registry) Finder(v Variant, version string) (dawg.Finder, 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.finder, nil
|
|
}
|
|
|
|
// DiffWords compares the old and new finders of variant v and returns the words
|
|
// added and removed, decoded through v's alphabet. Both dictionaries are
|
|
// enumerated as alphabet-index bytes and merged; only the differing words are
|
|
// decoded, so a full-dictionary comparison does not materialise two large string
|
|
// sets.
|
|
func DiffWords(v Variant, old, updated dawg.Finder) (WordDiff, error) {
|
|
rs, ok := v.ruleset()
|
|
if !ok {
|
|
return WordDiff{}, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
|
|
}
|
|
oldWords := collectWords(old)
|
|
newWords := collectWords(updated)
|
|
|
|
var diff WordDiff
|
|
i, j := 0, 0
|
|
for i < len(oldWords) && j < len(newWords) {
|
|
switch cmp := bytes.Compare(oldWords[i], newWords[j]); {
|
|
case cmp == 0:
|
|
i++
|
|
j++
|
|
case cmp < 0:
|
|
s, err := rs.Alphabet.Decode(oldWords[i])
|
|
if err != nil {
|
|
return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err)
|
|
}
|
|
diff.Removed = append(diff.Removed, s)
|
|
i++
|
|
default:
|
|
s, err := rs.Alphabet.Decode(newWords[j])
|
|
if err != nil {
|
|
return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err)
|
|
}
|
|
diff.Added = append(diff.Added, s)
|
|
j++
|
|
}
|
|
}
|
|
for ; i < len(oldWords); i++ {
|
|
s, err := rs.Alphabet.Decode(oldWords[i])
|
|
if err != nil {
|
|
return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err)
|
|
}
|
|
diff.Removed = append(diff.Removed, s)
|
|
}
|
|
for ; j < len(newWords); j++ {
|
|
s, err := rs.Alphabet.Decode(newWords[j])
|
|
if err != nil {
|
|
return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err)
|
|
}
|
|
diff.Added = append(diff.Added, s)
|
|
}
|
|
return diff, nil
|
|
}
|
|
|
|
// collectWords enumerates every complete word of f as a copy of its
|
|
// alphabet-index bytes, sorted ascending. The dawg already enumerates in index
|
|
// order; the explicit sort makes the merge in DiffWords robust to that being an
|
|
// implementation detail.
|
|
func collectWords(f dawg.Finder) [][]byte {
|
|
out := make([][]byte, 0, f.NumAdded())
|
|
f.EnumerateB(func(_ int, word []byte, final bool) dawg.EnumerationResult {
|
|
if final && len(word) > 0 {
|
|
cp := make([]byte, len(word))
|
|
copy(cp, word)
|
|
out = append(out, cp)
|
|
}
|
|
return dawg.Continue
|
|
})
|
|
sort.Slice(out, func(a, b int) bool { return bytes.Compare(out[a], out[b]) < 0 })
|
|
return out
|
|
}
|