Files
docs/website/SPAWN_ATLAS.md
wtclaude 3fb3f63f25 docs(website): record the spawn atlas pipeline and what real data changed
Protocol 3.0 order 3 (Part C), docs half of website #112. Part C is website-only
— no plugin, no sidecar, no new kinds, no wire change.

## New: website/SPAWN_ATLAS.md

The operator-facing reference: the build/import split and why it exists (build
needs a ServUO tree, import does not, and the container has the artifact but not
the tree), the re-run story, the artifact format, the placement transform, and
the three quirks in the source data that are silent when unhandled.

Also documents the artwork policy explicitly: **the project ships no creature
art and no extraction tooling.** Sprites live in the operator's own client
.mul/.uop files and are theirs, not ours to redistribute. `art` is nullable and
NULL on every fresh import; an operator who wants art extracts it themselves into
the gitignored uploads/atlas/ and maps slugs in a gitignored art map. Text-only
is the normal, supported state — not a degraded one.

## New: v3.md §6.1 — what the build against real data changed

Six corrections, kept as a diff rather than edited into §6 in place, because
each is a trap the next person would otherwise re-enter:

1. **Six facets, not thirteen.** Eodon.xml and the other named-area files carry
   TerMur/Trammel points; the facet comes from each record's `<Map>`.
2. **The XML dependency call resolved: hand-rolled, zero deps.** §6 left
   fast-xml-parser vs a tokenizer open.
3. **Facet names disagree between sources** — Locations says `Ter Mur`, `<Map>`
   says `TerMur`. Unreconciled the landmark fallback never fires there and every
   unregioned Ter Mur/Tokuno spawn silently reads "Wilderness".
4. **Spawn type tokens carry XmlSpawner directives** (`Fairy,{RND,4,8}`,
   `alchemist/z/-50`). Taken literally they invent creatures that do not exist
   and split real ones in two. 71 of 845 affected; 800 remain after stripping.
5. **The artifact is 1.41 MB, not "well under 1 MB"** — down from 4.40 MB via
   three encodings. Getting under 1 MB would mean dropping the spawner name.
6. **DELETE, not TRUNCATE** — TRUNCATE is DDL in MariaDB and implicitly commits,
   which would defeat the all-or-nothing reload the design asked for.

§6 also now records that Part C ships as two website PRs: the parsing half is
where the correctness risk lives and should not be reviewed inside a 10k-line
diff alongside routes and React.

## BACKEND_DESIGN.md

The seven atlas tables, the import-owned contract, the four column choices that
are traps (`spawn_range`/`grp` reserved words, DELETE vs TRUNCATE, explicit point
ids, plain INDEX not FULLTEXT), and the distinction between the configured
champion roster and the live champ.update feed.

PROJECT_TREE.md is left alone — it is auto-generated by the sync-project-tree
workflow.

---

- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 16:13:30 -05:00

10 KiB
Raw Blame History

Spawn atlas

Status: Data pipeline landed on edge (website #112); API and client pages follow in a second PR. Design: docs/link/v3.md §6 — Protocol 3.0 Part C.

The spawn atlas is a browsable catalogue of what the shard contains: which creatures spawn, where, how many, and which champion altars are configured. It answers "where do I find a lizardman?" with "Shrines, Yew, Isamu-Jima" rather than with a list of raw coordinates.

What it is not

The atlas is static shard content, not live shard state.

  • It does not come from the sidecar. Nothing here touches the bridge, and there is no event kind, no wire change and no PROTOCOL_VERSION bump for it. Part C is website-only.
  • It stays fully populated while the shard is down.
  • Its champion table (shard_champion_spawns) is the configured roster — "there is an Unholy Terror altar in Deceit". The live champ.update feed in shard_champs is the separate, sidecar-fed answer to "it is on level 3 right now". Both exist; do not conflate them.

Routes live at /api/v1/public/atlas, deliberately not under /shard, because /shard/* means sidecar-dependent.

The two commands

Building needs a ServUO tree. Importing does not. That split is the whole design: the website container ships the committed artifact but has no ServUO tree, so it can import but never build.

# On a machine that has the ServUO tree (writes server/db/data/spawnAtlas.*.json)
cd website/server
npm run atlas:build -- --servuo /path/to/ServUO

# Anywhere, including the deployed container
npm run atlas:import

atlas:build flags:

Flag Default Meaning
--servuo (required) ServUO server root — the directory holding Spawns/, Data/, Config/
--out server/db/data Where to write the artifact
--landmark-radius 200 Max tile distance for the landmark fallback

atlas:import takes --dir (default server/db/data).

Operator re-run story

Spawns changed → rebuild → commit → deploy → import.

  1. npm run atlas:build -- --servuo <path> on a machine with the tree.
  2. Commit the regenerated server/db/data/spawnAtlas.*.json.
  3. Deploy.
  4. npm run atlas:import, or POST /api/v1/admin/shard/atlas/import.

shard_atlas_meta stores a sha256 per source file, so GET /api/v1/admin/shard/atlas/status reports when the database is behind the committed artifact. Build stays CLI-only — there is no admin button that reads a ServUO tree.

Sources

File Count (stock ServUO 57.4) Used for
Spawns/*.xml 13 files, ~10.5 MB Every spawner: location, size, delays, time-of-day, creature types
Data/Regions.xml 129 KB, nested Named regions and their rectangles
Data/Locations/*.xml 6 files Landmarks (dungeon levels, town markers)
Config/ChampionSpawns.xml 4.8 KB Configured champion altars

There are 13 spawn files but only 6 facets. Eodon.xml, GravewaterLake.xml, TreasuresOfKotl.xml and the other named-area files hold TerMur/Trammel points. The facet always comes from each record's own <Map>, never from the file name, and the artifact shards by facet — Felucca, Trammel, Ilshenar, Malas, Tokuno, TerMur.

How a coordinate becomes a place name

This is the transform the atlas exists for, in resolveRegion():

  1. The highest-priority named region whose rectangle contains the point. Ties break toward the smallest rect, so a specific room wins over the dungeon-wide rect enclosing it.
  2. Otherwise the nearest landmark within --landmark-radius tiles, labelled by its group ("Covetous"), not its individual marker ("Level 1").
  3. Otherwise "Wilderness".

The radius cap in step 2 is what keeps step 3 reachable. Without it the nearest landmark is always some landmark however far away, and open countryside gets labelled with a dungeon on the far side of the map.

Against stock ServUO this resolves 83.2% of points (5,369 of 6,455): 3,689 by region, 1,690 by landmark, 1,086 Wilderness.

Three quirks in the source data

Each of these is silent if unhandled — the atlas still builds, it is just wrong.

Facet names disagree between sources. Data/Locations/*.xml spells them Ter Mur and Tokuno Islands, while <Map> and <Facet name> say TerMur and Tokuno. Unreconciled, the landmark bucket for those two facets is keyed differently from the points looking it up, so the fallback never fires and every unregioned spawn in Ter Mur and Tokuno reads "Wilderness". All facet names are canonicalised through normalizeFacet(); unknown facets pass through unchanged so a custom shard facet still gets an atlas.

Spawn type tokens carry XmlSpawner directives. The <Objects2> type is not always a bare class name:

Fairy,{RND,4,8}      alchemist/z/-50      Agralem/Name/Agralem
GargishRouser,1      greatape,true        GargishRefugee/hue/34532

Taken literally these invent creatures that do not exist and split real ones in two, because Fairy and Fairy,{RND,4,8} slug apart into separate entries. 71 of 845 were affected. Everything from the first / or , is stripped, leaving 800 real creatures.

Case is inconsistent across files. The same creature is Lizardman in one file and lizardman in another. Slugging collapses them correctly, but the display name is chosen deterministically — most common spelling wins, ties break to the more capitalised form, then alphabetically — because otherwise it would depend on file read order and produce a spurious artifact diff on every unrelated rebuild.

Artifact format

server/db/data/, all committed:

File Contents
spawnAtlas.meta.json Indented. Build time, counts, sha256 per source file
spawnAtlas.index.json Compact. Facets, creatures, regions, landmarks, champions
spawnAtlas.<facet>.json × 6 Compact. That facet's spawn points

1.41 MB total, down from 4.40 MB. The design budgeted "well under 1 MB", which turned out optimistic for 6,455 points; three encodings closed most of the gap:

  • facet is dropped per record — the shard file names it once at the top.
  • Fields at their default are omitted rather than written as 0. Most spawners are a single point with no time-of-day gating, so width, height, range and the three tod* fields are zero on the large majority of records.
  • types are [name, max] tuples. There are ~24,000 type entries and {"type":"Orc","max":1} spends 15 bytes apiece restating two key names that never vary.

label is not stored at all — it is exactly region || landmark || "Wilderness" and the importer recomputes it.

buildSpawnAtlas.encodePoint() and importSpawnAtlas.readPoint() are exact inverses, round-tripped in test/spawnAtlas.build.test.js. Change one, change both. The artifact never reaches the browser; the browser sees only paginated API responses.

Artwork — operator-supplied, never shipped

This project ships no creature art and no extraction tooling, and never will. UO sprites live in the operator's own client .mul/.uop files. They are the operator's, not ours to redistribute.

The atlas is fully functional as text. shard_spawn_creatures.art is nullable and is NULL on every fresh import; pages render without images, which is the normal and supported state, not a degraded one.

An operator who wants art:

  1. Extracts it from their own client files (UOFiddler, ClassicUO tooling, or any art extractor).
  2. Drops the images under server/uploads/atlas/.
  3. Copies server/db/data/spawnAtlas.art.example.json to spawnAtlas.art.json and maps creature slugs to file names.
  4. Re-runs npm run atlas:import.

Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map nor the images can be committed by accident.

Tables

All six are import-owned: atlas:import empties and reloads them in one transaction. Nothing else writes to them and nothing holds a foreign key to them — no FKs at all, consistent with every other shard_* table. Full column listings in BACKEND_DESIGN.md.

Table Rows (stock) Notes
shard_spawn_creatures 800 slug PK; total = sum of each type's own max; nullable art
shard_spawn_points 6,455 spawn_range, since range is reserved in MariaDB
shard_spawn_point_types 23,927 The many-to-many; one spawner commonly carries six types
shard_regions 387 Flattened out of the nesting; rects JSON
shard_landmarks 558 grp, since group is reserved in SQL
shard_champion_spawns 25 Configured altars, not the live feed
shard_atlas_meta 1 Singleton (id = 1); source hashes for the drift check

shard_spawn_creatures.name carries a plain INDEX, deliberately not FULLTEXT: ~800 rows makes a LIKE scan free, and FULLTEXT's minimum token length would break searches for names like "orc".

The importer uses DELETE, not TRUNCATETRUNCATE is DDL in MariaDB and would implicitly commit, defeating the all-or-nothing reload. Point ids are assigned explicitly rather than left to AUTO_INCREMENT, because the join rows need to know them and conn.batch() reports no usable insertId.

Parsing notes

server/src/utils/spawnAtlasParse.js is pure and fs-free, so CI covers it with no ServUO tree. It adds zero dependencies.

  • Regions.xml, Locations/*.xml and ChampionSpawns.xml genuinely nest, and get a small hand-rolled subset tokenizer — elements, attributes, self-closing tags, comments, the XML declaration, CDATA, and the five predefined entities plus numeric refs. It is not a general-purpose XML parser and must not be reused as one.
  • The ~10.5 MB of Spawns/*.xml never touches that tokenizer. Those records are flat, so they get a streaming regex sweep instead; a DOM would allocate a node per element across ~40 fields on every record to keep 14 of them. Do not put the Points files through a DOM parser.
  • <Objects2> is Type:MX=n:SB=… segments joined by :OBJ=. Split on :OBJ= first — a naive split(':') shreds it. A single Trammel point carries six types.