refactor(atlas): derive the atlas from the shard's tree on every boot

Replaces the committed-artifact design from the first commit. Two problems with
it, both raised in review:

**Facets are not a fixed list.** The first pass carried a hardcoded table of the
six stock UO facets to reconcile the spelling drift between sources. That is
wrong: a shard may add facets, replace them outright, or rename them when its
maps are updated, and a built-in list quietly mishandles all three. Nothing in
the atlas names a facet any more. The facet set is discovered from the tree —
spawn records and region definitions are the authority — and the loose spellings
in Data/Locations are matched against it by key and prefix. Custom facets get
identical treatment; the tests use `Sosaria` and `Underdark` precisely so a
stock-facet assumption cannot creep back in.

**A snapshot goes stale.** Maps change over a server's life, so a build-once
artifact silently drifts from the world players actually see. The tree is now
the single source of truth and the atlas is re-derived on every boot.

## What that changed

- **The committed artifact is gone** — 1.41 MB of generated JSON removed, along
  with `scripts/buildSpawnAtlas.js` and the whole encode/decode seam it needed
  (`encodePoint`/`readPoint`, the tuple encoding, the omitted-defaults scheme and
  their round-trip tests). Nothing to keep in sync, nothing to go stale.
- **NEW `src/utils/spawnAtlasSource.js`** — the only thing that touches a ServUO
  tree; shared by the boot path and the CLI. Parsers stay pure and fs-free.
- **NEW `src/model/shardAtlas/`** — `.db.js` (the one-transaction replace) and
  `.model.js` (the refresh decision).
- **`scripts/importSpawnAtlas.js`** is now a thin CLI over the model:
  `--servuo`, `--force`, `--approve`, `--reject`, `--status`. `atlas:build` is
  gone; `atlas:import` remains.
- Path comes from the `spawn_atlas_servuo_path` admin setting, falling back to
  `SERVUO_PATH`. The setting wins, matching how the rest of the shard
  integration is admin-managed rather than env-configured.

## Two contracts on the boot path

**It never blocks startup.** No path, an unreadable mount, a malformed file, a
database error — every one is caught and logged, and the site comes up serving
whatever atlas it already had. Verified by booting the real server with no path,
a broken path, and a good path.

**A facet disappearing is never applied automatically.** Losing a facet is the
signature of a half-copied or mid-update tree as much as of a real map change,
and boot cannot tell them apart. The refresh is staged in `shard_atlas_pending`
for an admin to approve or reject, and startup continues regardless. Additions
and every other change apply immediately, since none of them can destroy
something an operator would miss.

Only the decision is stored, not the parsed world: a few KB of source hashes and
the facet diff. Approving re-parses, so what gets applied matches the tree at
approval time rather than at boot. A rejection is remembered against those exact
hashes, so a declined refresh does not re-prompt on every restart — changing the
tree changes the hashes and asks again.

Hash-gated, so the common case (restart, maps unchanged) reads and hashes the
tree (~120 ms) and writes nothing. A real change costs a ~400 ms parse.

The admin approve/reject UI is part of the second PR, with the rest of the
routes and pages. Until then the CLI covers it.

## Verification

- **564 server tests pass**, 28 new in `spawnAtlas.source.test.js` covering the
  custom-facet build, the spelling reconciliation, hash gating, and every branch
  of the refresh decision — including that `refreshOnBoot` survives a database
  that throws on every call.
- End-to-end against the local MariaDB and the real ServUO tree: 6,455 points,
  800 creatures, 23,927 point/type rows, 387 regions, 558 landmarks, 25 altars,
  83.2% of points resolved to a place name.
- The facet gate exercised against a real tree copy with `malas.xml` removed:
  staged rather than applied, atlas untouched with all 293 Malas points intact,
  reject then stays quiet on re-run, approve applies and drops the facet.
- Booted the real server under all three source conditions; none blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
This commit is contained in:
2026-07-28 16:41:33 -05:00
parent 353cce9f26
commit 2801ec8f4d
22 changed files with 1528 additions and 1018 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,24 @@
{
"_comment": [
"OPTIONAL operator-supplied creature art for the spawn atlas. Copy this file to",
"spawnAtlas.art.json (same directory) and edit it, then restart the server or run",
"`npm run atlas:import` — the art map is read on every atlas refresh.",
"",
"This project ships NO creature artwork and never will. UO sprites live in your",
"own client's .mul/.uop files and are yours to extract, not ours to redistribute.",
"If you want art on the atlas pages, export it yourself (UOFiddler, ClassicUO's",
"tooling, or any art extractor), drop the images under server/uploads/atlas/, and",
"map each creature slug to its file name here.",
"",
"Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map",
"nor the images can be committed by accident.",
"",
"Keys are creature slugs, as reported by the atlas API and derived from the type",
"names in your own shard's Spawns/*.xml. Values are file names relative to",
"server/uploads/atlas/. Any creature with no entry here simply renders without",
"art — that is the default and fully supported state, not a degraded one."
],
"lizardman": "lizardman.png",
"orc": "orc.png",
"dragon": "dragon.png"
}

File diff suppressed because one or more lines are too long

View File

@@ -1,100 +0,0 @@
{
"version": 1,
"generatedAt": "2026-07-28T21:05:07.987Z",
"counts": {
"facets": 6,
"points": 6455,
"pointsDisabled": 10,
"creatures": 800,
"regions": 387,
"landmarks": 558,
"champions": 25,
"unresolvedPoints": 1086
},
"source": {
"Data/Regions.xml": {
"bytes": 129008,
"sha256": "05c505b3806a3ee617ce503f6cb9ccf1d0ebadca0821d8d9346587d36a790ed2"
},
"Data/Locations/felucca.xml": {
"bytes": 12481,
"sha256": "cfd4c49a808c2bf1f904722740fe8dd3e10311ec9e832bd74fc17dcb171ad108"
},
"Data/Locations/ilshenar.xml": {
"bytes": 4279,
"sha256": "92a85e2ad894d4c428b0ba24c1d4547b585e47086aeb4b8119d24bc1a74f7fa0"
},
"Data/Locations/malas.xml": {
"bytes": 3903,
"sha256": "c7dafa6a958c8c7b7f0e7a448c679d90cb2f736a878c60b58057a4d1105b6d69"
},
"Data/Locations/termur.xml": {
"bytes": 3568,
"sha256": "065c8f4077eb77d6c65c089a8ef9d422a7f197cb6002859cb4296960c3619da5"
},
"Data/Locations/tokuno.xml": {
"bytes": 1546,
"sha256": "7c70950555fc6f983b6db6c85a569621ba02cbbdc42ab3f76bbaeff795cc1bb8"
},
"Data/Locations/trammel.xml": {
"bytes": 11772,
"sha256": "fd5cfc941bf6bf5dc685d6753d71fee869df3063688d060ded59cd4567531e57"
},
"Spawns/Eodon.xml": {
"bytes": 100654,
"sha256": "632053fe7ed1d2fbcb03c5b18d9bd13a700d1f66300574d12603202c56c8e4c3"
},
"Spawns/GravewaterLake.xml": {
"bytes": 50657,
"sha256": "4b9f48cc18c176382026e04eb8dbcf2a080afd1386fee47fb3fea469cad4fdac"
},
"Spawns/TheExodusEncounterQuest.xml": {
"bytes": 25802,
"sha256": "167498c8f5c8f3804a74046c0e1e6ebb7db709c5bd1cdac5623042abf9e694ff"
},
"Spawns/TreasuresOfKotl.xml": {
"bytes": 8665,
"sha256": "8e0ed17ef8942db7275719ce82989018d74caf8a382e86c4711c123ef6a913db"
},
"Spawns/felucca.xml": {
"bytes": 3749158,
"sha256": "eb2ed7de2623d3b95262eba9fffaddbc2dca24f53680e970e4e5f809def32891"
},
"Spawns/ilshenar.xml": {
"bytes": 705359,
"sha256": "e6b7cf796af2b72221ed2df7d718111f38055c170cdd463b222afb747048df5d"
},
"Spawns/malas.xml": {
"bytes": 445464,
"sha256": "2eb69d0006f507e027f8afc3e0e02b70a4caf32ca6e3ecdfef788c41a0907a3b"
},
"Spawns/solenhives.xml": {
"bytes": 101524,
"sha256": "0b73ae59c2058cd3354d5b11bec133d6f374eed43f9b818353640240af159ed9"
},
"Spawns/termur.xml": {
"bytes": 187184,
"sha256": "d6351e7879845f412d460433dad7438ae0795f1c38f371c78ec3dd4829b42eac"
},
"Spawns/tokuno.xml": {
"bytes": 732784,
"sha256": "f03fd70c301b97457b3e9732d6fef58c009eb6c498d15109ae0334eed133e860"
},
"Spawns/trammel.xml": {
"bytes": 4226171,
"sha256": "29d1e9d2fa5ce64ac0cc6396195ff3bdb464a140f6fb4ac61f1459174a2b9995"
},
"Spawns/twistedweald.xml": {
"bytes": 35456,
"sha256": "927e8f7177e1269231ea43a595fc156d9d94e4c3f2bfc1cc7574366d3aa495f8"
},
"Spawns/underworld.xml": {
"bytes": 76729,
"sha256": "e0c813331a52808b9bd93fd252cfcc8c967397ab8b0ead0e2c4e768db2f5baf6"
},
"Config/ChampionSpawns.xml": {
"bytes": 4838,
"sha256": "4e6798ae552d707e8f8a07ec611ebb7aca9046b8385017cad01ad0d397ca1171"
}
}
}