diff --git a/link/v3.md b/link/v3.md index 79e9939..f62c426 100644 --- a/link/v3.md +++ b/link/v3.md @@ -335,22 +335,25 @@ frame during verification. > lives, and burying it under routes and React would have meant reviewing it in a 10k-line diff. > Full operator documentation: [`docs/website/SPAWN_ATLAS.md`](../website/SPAWN_ATLAS.md). > -> §6.1 below records where the shipped implementation differs from this design. The differences are -> all things the real ServUO data forced, not changes of mind. +> **§6 below is the original design and is partly superseded.** §6.1 records two decisions that were +> rejected in review and replaced (the committed artifact, and the fixed facet list); §6.2 records +> the corrections the real ServUO data forced. Read both before trusting §6. -**Decision: committed generated artifact + idempotent DB import**, split in two because the build -needs the ServUO tree (which the website container does not have) and the import does not. Not -runtime import (10.5 MB of XML per boot), not a browser-served blob. +**Decision (revised at implementation time): the shard's ServUO tree is the single source of truth, +re-derived on every server boot.** The original plan here was a committed generated artifact plus an +idempotent import. That was rejected in review for two reasons, recorded in §6.1: a snapshot in the +repo goes stale as a shard's maps change, and the design leaned on a fixed facet list that no shard +is obliged to keep. Still not a browser-served blob; still parsed server-side only. New in `website/server/`: - `src/utils/spawnAtlasParse.js` — **pure functions, no fs**, so they are unit-testable in CI without a ServUO tree: `parseObjects2()`, `parsePoints()`, `parseRegions()`, `parseLocations()`, `resolveRegion()`. -- `scripts/buildSpawnAtlas.js` (`--servuo --out db/data/`) and `scripts/importSpawnAtlas.js` - (TRUNCATE + batched INSERT in one transaction); `package.json` scripts `atlas:build`, `atlas:import`. -- `db/data/spawnAtlas..json` ×13 + `spawnAtlas.index.json` (creatures, champions, regions, - landmarks, meta with per-source-file hashes). +- ~~`scripts/buildSpawnAtlas.js` and a committed `db/data/spawnAtlas.*.json` artifact~~ — dropped, + see §6.1 R1. Replaced by `src/utils/spawnAtlasSource.js` (the only thing that reads a ServUO tree, + shared by the boot path and the CLI) and a `scripts/importSpawnAtlas.js` that is a thin CLI over + the model. `package.json` gains `atlas:import` only. - `src/model/shardAtlas/{shardAtlas.db.js,shardAtlas.model.js}` following the `shardState` split. - `src/router/v1/public/atlas.{router,controller}.js`; `test/spawnAtlas.parse.test.js`. @@ -381,18 +384,48 @@ stays CLI-only.** Client: `routes/public/Atlas.jsx` (`/site/atlas`) and `AtlasCreature.jsx` (`/site/atlas/:slug`). -**Payload risk** — a monolithic artifact would be 2–3 MB of committed JSON. Shard per facet and drop -every `` field the site cannot use (`UniqueId`, all trigger/refractory/proximity/sequential -fields, sound ids), keeping Name/Map/X/Y/W/H/Range/MaxCount/MinDelay/MaxDelay/TOD*/types — well under -1 MB. The artifact never reaches the browser; the browser sees only paginated API responses. +**Payload risk** — *superseded by §6.1 R1; nothing is committed.* The field selection it describes +still applies at parse time: every `` field the site cannot use (`UniqueId`, all +trigger/refractory/proximity/sequential fields, sound ids) is dropped, keeping +Name/Map/X/Y/W/H/Range/MaxCount/MinDelay/MaxDelay/TOD*/types. Parsed data never reaches the browser; +the browser sees only paginated API responses. -**Operator re-run story** — spawns changed → `npm run atlas:build -- --servuo ` on a machine -with the tree → commit the regenerated `db/data/spawnAtlas.*.json` → deploy → `npm run atlas:import` -(or `POST /admin/shard/atlas/import`). `shard_atlas_meta.source` holds per-file hashes, so -`GET /admin/shard/atlas/status` reports when the DB is behind the artifact. Full detail in -`docs/website/SPAWN_ATLAS.md`. +**Operator re-run story** — *revised by §6.1 R1.* Spawns changed → restart, or +`npm run atlas:import` / `POST /admin/shard/atlas/import` to apply without one. `shard_atlas_meta` +holds a sha256 per source file, so the server can tell on boot whether anything changed, and +`GET /admin/shard/atlas/status` reports drift. If the change would remove a facet it is staged for +approval rather than applied (§6.1 R3). Full detail in `docs/website/SPAWN_ATLAS.md`. -### 6.1 What the build against real data changed +### 6.1 What implementation changed + +Two design decisions in §6 were rejected in review and replaced; the rest are corrections the real +ServUO data forced. Kept as a diff rather than edited in place, because each is a trap the next +person would otherwise re-enter. + +**R1. The committed artifact is gone — the tree is re-parsed on every boot.** §6 proposed building a +generated artifact, committing it, and importing it. Two problems. A shard's maps change over its +life, so a snapshot in the repo silently drifts from the world players actually see; and the build/ +import split existed only to work around the website container not having a tree, which is a +deployment question (mount it) rather than a reason to freeze data. The server now hashes the source +files on boot and re-derives the atlas when they differ. `scripts/buildSpawnAtlas.js`, the 1.41 MB +artifact, and the whole encode/decode seam it needed are deleted. + +**R2. Nothing may name a facet.** The first implementation carried a lookup table of the six stock +UO facets to reconcile the spelling drift between sources. A shard may add facets, replace them +outright, or rename them when its maps are updated, and a built-in list mishandles all three +silently. Reconciliation is now by *matching* against the facet set discovered from the shard's own +spawn and region data — exact key, then prefix in either direction — with an unmatched name keeping +its own rather than being forced into a wrong bucket. + +**R3. Two contracts on the boot path.** It never blocks startup: no path, an unreadable mount, a +malformed file or a database error is caught and logged, and the site comes up serving whatever +atlas it had. And a refresh that would REMOVE a facet is never applied automatically — facet loss +is indistinguishable at boot from a half-copied or mid-update tree, so it is staged in +`shard_atlas_pending` for an admin to approve or reject. Only the decision is stored (source hashes ++ the facet diff, a few KB); approving re-parses, so what lands matches the tree at approval time. +A rejection is remembered against those hashes so it does not re-prompt every restart. + +### 6.2 What the build against real data changed Six corrections to the design above, from running it against stock ServUO 57.4. Kept as a diff rather than edited in place, because each one is a trap the next person would otherwise re-enter. @@ -419,12 +452,11 @@ they invent creatures that do not exist *and* split real ones in two, since `Fai leaves **800** real creatures. (The design's "~1,500 creature rows" estimate was high; 800 only reinforces the plain-`INDEX`-not-`FULLTEXT` call.) -**5. The artifact is 1.41 MB, not "well under 1 MB".** Dropping the unused `` fields as the -design directed still left 4.40 MB. Three further encodings — `facet` dropped per record, -default-valued fields omitted rather than written as `0`, and `types` as `[name, max]` tuples -(~24,000 entries × 15 bytes of repeated key names) — brought it to 1.41 MB. Getting under 1 MB would -mean dropping the spawner `name`, which is the only human handle on a specific spawner and worth -keeping. `encodePoint()` and `readPoint()` are exact inverses and are round-tripped in tests. +**5. The artifact would have been 1.41 MB, not "well under 1 MB" — and is now moot.** Dropping the +unused `` fields as the design directed still left 4.40 MB; three further encodings brought +it to 1.41 MB, and getting under 1 MB would have meant dropping the spawner `name`. The size budget +in §6 was simply optimistic for 6,455 points. Superseded by §6.1 R1: there is no artifact, so there +is no payload to budget and no encode/decode seam to keep in sync. **6. `DELETE`, not `TRUNCATE`.** The design said "TRUNCATE + batched INSERT in one transaction", which does not hold: `TRUNCATE` is DDL in MariaDB and implicitly commits, so a mid-import failure diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index cdffd8c..cb7743e 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -391,9 +391,15 @@ discarded. See §6.5. ### shard_spawn_* / shard_regions / shard_landmarks / shard_champion_spawns / shard_atlas_meta — the spawn atlas (Protocol 3.0) Static shard **content**, not live shard state. Nothing here comes from the sidecar: the atlas is -built from a ServUO tree by a CLI script, committed as JSON under `server/db/data/`, and loaded with -`npm run atlas:import`. These tables stay populated whether the shard is up or not. Full operator -detail in [`SPAWN_ATLAS.md`](SPAWN_ATLAS.md); the design is `docs/link/v3.md` §6. +derived from the shard's own ServUO tree, re-read on **every server boot** and hash-gated so an +unchanged tree costs one read pass and no write. Nothing is precomputed and committed — a shard's +maps change over its life, and a snapshot in the repo would silently drift from the world players +actually see. These tables stay populated whether the shard is up or not. Full operator detail in +[`SPAWN_ATLAS.md`](SPAWN_ATLAS.md); the design is `docs/link/v3.md` §6. + +**No facet name appears anywhere in the code.** A shard may add facets, replace them, or rename them +when its maps are updated; the facet set is discovered from the tree, and the loose spellings in +`Data/Locations` are matched against it rather than looked up in a table. | Table | Key columns | |---|---| @@ -403,13 +409,27 @@ detail in [`SPAWN_ATLAS.md`](SPAWN_ATLAS.md); the design is `docs/link/v3.md` § | `shard_regions` | `facet`, `name`, `type`, `priority`, `parent`, `rects` JSON | | `shard_landmarks` | `facet`, `name`, `grp`, `x`, `y`, `z` | | `shard_champion_spawns` | `slug` PK, `name`, `grp`, `type`, `random_type`, `facet`, `x`, `y`, `z`, `radius`, `label` | -| `shard_atlas_meta` | Singleton (`id = 1`), `payload` JSON, `imported_at` | +| `shard_atlas_meta` | Singleton (`id = 1`), `payload` JSON (counts + a sha256 per source file), `imported_at` | +| `shard_atlas_pending` | Singleton (`id = 1`), `status` (`pending`/`rejected`), `payload` JSON, `detected_at` | -All of them are **import-owned**: `atlas:import` empties and reloads every one inside a single -transaction, so a failed import leaves the previous atlas intact rather than a half-loaded world. +The first seven are **import-owned**: a refresh empties and reloads every one inside a single +transaction, so a failed reload leaves the previous atlas intact rather than a half-loaded world. Nothing else writes to them, and nothing holds a foreign key to them — no FKs at all, consistent with every other `shard_*` table. +**`shard_atlas_pending` is the security-relevant one.** A refresh that would REMOVE a facet is never +applied automatically: facet loss is indistinguishable at boot from a half-copied or mid-update tree, +so it is staged here for an admin to approve or reject, and **startup is never blocked by it**. Only +the decision is stored — source hashes plus the facet diff, a few KB — and approving re-parses the +tree, so a multi-megabyte blob never lands in the database and what gets applied matches the tree at +approval time. A rejection is remembered against those exact hashes so a declined refresh does not +re-prompt on every restart. Everything else (new facets, renamed regions, changed spawns) applies +immediately, since none of it can destroy data an operator would miss. + +The boot refresh is **best-effort by contract**: no configured path, an unreadable mount, a malformed +file or a database error is caught and logged, and the site comes up serving whatever atlas it had. +The tree path comes from the `spawn_atlas_servuo_path` setting, falling back to `SERVUO_PATH`. + Four column choices worth stating, because each one is a trap: - **`spawn_range`, not `range`**, and **`grp`, not `group`** — both are reserved words. diff --git a/website/SPAWN_ATLAS.md b/website/SPAWN_ATLAS.md index 57e735e..2e9591b 100644 --- a/website/SPAWN_ATLAS.md +++ b/website/SPAWN_ATLAS.md @@ -8,6 +8,19 @@ 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. +## Two things that shape the whole design + +**The shard's ServUO tree is the single source of truth.** Nothing is +precomputed and committed to the repository. A shard's maps change over its +lifetime — facets get added, replaced, or renamed — and a snapshot in the repo +would silently drift from the world players actually see. The atlas is therefore +re-derived from the tree **on every server boot**. + +**Facets are not a fixed list.** Nothing in the codebase names Felucca, Trammel, +or any other stock facet. The facet set is whatever the shard's own files +declare, discovered at parse time. A shard running entirely custom maps gets +exactly the same treatment as a stock one, with no code change. + ## What it is not The atlas is **static shard content, not live shard state.** @@ -24,44 +37,83 @@ The atlas is **static shard content, not live shard state.** Routes live at `/api/v1/public/atlas`, deliberately **not** under `/shard`, because `/shard/*` means sidecar-dependent. -## The two commands +## Configuring the tree -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. +The website needs to be able to *read* the ServUO tree — same host, a bind mount, +or a shared volume. Two ways to point at it, the setting winning over the +environment: -```bash -# 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 +| Source | Notes | +|---|---| +| `spawn_atlas_servuo_path` setting | Admin-editable; changes take effect on the next refresh without a redeploy | +| `SERVUO_PATH` env var | The deploy-time default, since the path usually describes a mount the deployment sets up | -# Anywhere, including the deployed container -npm run atlas:import +With neither set the atlas is simply skipped — the site runs normally without +one. + +## The boot path + +On every start the server hashes the source files and compares them against what +is loaded. Unchanged (the normal case on a restart) costs one read pass, ~120 ms, +and no database write. A real change costs a ~400 ms parse and a reload. + +Two contracts govern it: + +**1. It never blocks startup.** No configured 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. + +**2. A facet disappearing is never applied automatically.** Losing a facet looks +exactly like a half-copied or mid-update tree, and boot cannot tell that apart +from a real map change. That refresh is *staged* for a human instead. Everything +else — new facets, renamed regions, changed spawns — applies immediately, since +none of it can destroy something an operator would miss. + +``` +boot + └─ path configured? no ──▶ skip + └─ tree readable? no ──▶ warn, carry on + └─ hashes changed? no ──▶ done (nothing parsed) + └─ parse + └─ a facet would be removed? + no ──▶ import + yes ──▶ stage for admin review; atlas unchanged ``` -`atlas:build` flags: +### Approving or rejecting a staged refresh -| 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 | +Only the *decision* is stored, never the parsed world — a few KB of source hashes +plus the facet diff. Approving **re-parses** the tree, so what lands matches the +tree at approval time rather than at boot, and a multi-megabyte blob never sits +in the database. -`atlas:import` takes `--dir` (default `server/db/data`). +A rejection is remembered against those exact source hashes, so a declined +refresh does not re-prompt on every restart. Change the tree and the hashes +differ, which asks again. -## Operator re-run story +From the admin panel (second PR), or from the CLI: -Spawns changed → rebuild → commit → deploy → import. +```bash +cd website/server +npm run atlas:import -- --status # what is loaded, and what is pending +npm run atlas:import -- --approve # apply the staged refresh +npm run atlas:import -- --reject # keep the current atlas, dismiss it +``` -1. `npm run atlas:build -- --servuo ` 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`. +## The CLI -`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. +The server refreshes itself on boot, so this is for applying a map change +*without* a restart, and for the approve/reject flow above. + +```bash +npm run atlas:import # import if the tree differs +npm run atlas:import -- --servuo # override the path for this run +npm run atlas:import -- --force # reimport even if unchanged +``` + +`--servuo` is a per-run override and deliberately does **not** persist — changing +where the atlas permanently reads from is an admin action, not a side effect of a +one-off import. ## Sources @@ -72,11 +124,10 @@ reads a ServUO tree. | `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`, +**A stock tree has 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 ``, -never from the file name, and the artifact shards by facet — Felucca, Trammel, -Ilshenar, Malas, Tokuno, TerMur. +never from the file name. ## How a coordinate becomes a place name @@ -85,16 +136,17 @@ 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"). +2. Otherwise the nearest landmark within the landmark radius (200 tiles by + default), 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. +Against stock ServUO this resolves **83.2%** of points (5,369 of 6,455): 3,681 by +region, 1,688 by landmark, 1,086 Wilderness. ## Three quirks in the source data @@ -102,11 +154,17 @@ Each of these is silent if unhandled — the atlas still builds, it is just wron **Facet names disagree between sources.** `Data/Locations/*.xml` spells them `Ter Mur` and `Tokuno Islands`, while `` and `` 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. +`Tokuno`. Unreconciled, the landmark bucket is keyed differently from the points +looking it up, so the fallback never fires and every unregioned spawn on those +facets reads "Wilderness". + +This is reconciled **by matching, not by a lookup table** — there is no list of +facet names anywhere. `facetKey()` collapses spelling differences (lowercase, +alphanumerics only), and `resolveFacetName()` matches a loose spelling against +the canonical set discovered from the shard's own spawn and region data, by exact +key then by prefix in either direction. A name matching nothing keeps its own +name: forcing a wrong match would file a real custom facet's landmarks under the +wrong facet, which is worse than leaving it alone. **Spawn type tokens carry XmlSpawner directives.** The `` type is not always a bare class name: @@ -125,38 +183,35 @@ real creatures. 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. +depend on file read order and change on an unrelated restart. -## Artifact format +## Tables -`server/db/data/`, all committed: +All are **import-owned**: a refresh empties and reloads them in one transaction, +so a failed reload leaves the previous atlas intact rather than a half-loaded +world. 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`](BACKEND_DESIGN.md). -| File | Contents | -|---|---| -| `spawnAtlas.meta.json` | Indented. Build time, counts, sha256 per source file | -| `spawnAtlas.index.json` | Compact. Facets, creatures, regions, landmarks, champions | -| `spawnAtlas..json` × 6 | Compact. That facet's spawn points | +| 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; source hashes, for the change check | +| `shard_atlas_pending` | 0–1 | Singleton; a staged refresh awaiting admin review | -**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: +`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". -- `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. +The reload uses `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and would +implicitly commit, defeating the all-or-nothing guarantee. Point ids are assigned +explicitly rather than left to `AUTO_INCREMENT`, because the join rows need them +and `conn.batch()` reports no usable `insertId`. ## Artwork — operator-supplied, never shipped @@ -175,41 +230,22 @@ An operator who wants art: 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`. +4. Restarts, or runs `npm run atlas:import -- --force`. Both `spawnAtlas.art.json` and `server/uploads/` are gitignored, so neither the map nor the images can be committed by accident. -## Tables +## Code layout -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`](BACKEND_DESIGN.md). +| File | Role | +|---|---| +| `src/utils/spawnAtlasParse.js` | **Pure and fs-free** parsers, so CI covers them with no ServUO tree. Zero dependencies. | +| `src/utils/spawnAtlasSource.js` | The only thing that reads a ServUO tree; shared by the boot path and the CLI | +| `src/model/shardAtlas/shardAtlas.db.js` | The one-transaction replace | +| `src/model/shardAtlas/shardAtlas.model.js` | The refresh decision, staging, approve/reject | +| `scripts/importSpawnAtlas.js` | Thin CLI over the model | -| 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 `TRUNCATE` — `TRUNCATE` 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**. +Parsing notes: - `Regions.xml`, `Locations/*.xml` and `ChampionSpawns.xml` genuinely nest, and get a small hand-rolled **subset** tokenizer — elements, attributes,