Files
scrabble-game/backend/internal/engine/diff.go
T
Ilia Denisov 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
feat(admin): online dictionary update — upload archive, preview word diff, install & activate
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.
2026-06-13 23:29:06 +02:00

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
}