feat(admin): online dictionary update — upload archive, preview word diff, install & activate
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
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.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user