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

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:
Ilia Denisov
2026-06-13 23:29:06 +02:00
parent 5ff07da025
commit 0f3671f42d
31 changed files with 1590 additions and 78 deletions
+27 -12
View File
@@ -233,17 +233,32 @@ Key points:
The registry holds dictionaries in memory addressed by `(variant,
dict_version)`, tracking the latest version per variant, and answers the
word-check tool through `Registry.Lookup`.
- **Dictionary versioning — pin per game.** A game records the `dict_version` it
started on and finishes on that version; new games use the latest. Multiple
versions may be resident at once. The boot version loads from the flat
`BACKEND_DICT_DIR`; the admin console **hot-reloads** a new version from a
per-version subdirectory `BACKEND_DICT_DIR/<version>/` through
`Registry.LoadAvailable` (only the variants whose DAWG is present there), and a
restart re-loads every resident version via `engine.OpenWithVersions` (the flat
boot version plus each subdirectory). In-flight games keep their pinned version;
new games use the latest. (The solver is published as a versioned module and the
dictionaries ship as a separate versioned **release artifact** from the
`scrabble-dictionary` repo; the runtime contract above is unchanged.)
- **Dictionary versioning — pin per game, update through the console.** A game
records the `dict_version` it started on and finishes on it; new games pin the
**active version**, the single source of truth persisted in the
`dictionary_state` singleton and restored on boot, so an operator's choice
survives a restart. Multiple versions are resident at once. `BACKEND_DICT_DIR` is
a writable named volume seeded from the image on first boot (its nonroot
ownership is inherited from the image, so the runtime can write it); the flat
directory is the seed version, labelled `BACKEND_DICT_VERSION` — set from the
build's `DICT_VERSION`, so the resident label equals the release tag — and each
uploaded version lives in a `BACKEND_DICT_DIR/<version>/` subdirectory. The admin
console **updates** a dictionary online (`internal/dictadmin`): an operator
uploads the `scrabble-dawg-vX.Y.Z.tar.gz` release archive, previews the
per-variant words added/removed against the active dictionary (`engine.DiffWords`
enumerates both DAWGs and decodes only the differences), and confirms — the
backend extracts the archive into the version subdirectory (hardened against
path-traversal, symlink and decompression-bomb attacks), loads it via
`Registry.LoadAvailable`, and makes it the active version. Versions are immutable
(re-uploading a resident tag is rejected), so in-flight games keep their pinned
version while new games use the new one. A restart re-loads every resident
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
skipping the `.staging/` upload area) and restores the active pointer from
`dictionary_state`. The volume preserves uploaded versions across redeploys;
once seeded it is not re-seeded, so after bootstrap dictionary changes go through
the console rather than a rebuild. (The dictionaries ship as a versioned
**release artifact** from the `scrabble-dictionary` repo; the build's
`DICT_VERSION` selects only the seed.)
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
@@ -330,7 +345,7 @@ Key points:
review queue. An operator resolves it (`open → resolved`) with a **disposition**
reject, accept-add or accept-remove; the accepted ones form a derived
**pending-changes** list that feeds the offline dictionary rebuild and is marked
applied once the rebuilt version is hot-reloaded (§5, §12).
applied once the rebuilt version is installed through the console (§5, §12).
## 7. Robot opponent