feat(atlas): serve the spawn atlas and give operators a panel for it #113

Merged
whitlocktech merged 1 commits from feat/spawn-atlas-api into edge 2026-07-29 00:53:23 +00:00
Member

Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data pipeline; this makes it reachable. Still website-only: no plugin, no sidecar, no new event kinds, no wire change.

Six public routes, five admin ones, two public pages, and an admin panel.

The API is not under /shard, and that is the point

Nothing here touches the sidecar — the atlas is parsed from the shard's own ServUO tree — so /api/v1/public/atlas/* stays complete while the shard is down, and a /shard prefix would advertise a dependency it does not have. Unlike /shard/* it is siteMode-gated, like /posts and /wiki: a bestiary is site content and follows site content's rules.

Route Answers
GET /atlas/creatures?q=&facet=&limit=&offset= the bestiary, paginated, with an unpaginated total
GET /atlas/creatures/:slug places, spawners, alsoHere
GET /atlas/regions · /atlas/landmarks named regions and points of interest
GET /atlas/champions the configured altar roster (not /shard/champs, the live board)
GET /atlas/meta facets, counts, when it was parsed

Every route carries requireFeature('atlas') — 404 disabled, 403 out of rung — and projects its response. atlas declares no sensitive fields, so the projection is a no-op today; the call is there because §3.6.1's rule is that the first field needing a gate should be covered by construction rather than by a retrofit nobody remembers.

GET /atlas/meta reports the game world only. The ServUO path, the source hashes and any pending refresh describe the operator's filesystem and live on the admin route.

A facet is never validated against a list — nothing in this codebase names one. ?facet= is length-bounded and matched exactly, and the filter is an EXISTS over the points rather than a JSON path or JSON_SEARCH built from caller input: that function treats % and _ as wildcards, so ?facet=% would have matched everything.

Two bugs the UI surfaced

Respawn delays were being read in the wrong unit — sometimes. XmlSpawner writes MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not divide into whole minutes, flagging that per record with DelayInSec (XmlSpawner2.cs:7462-7480, read back at :6345-6358). A 5 therefore means five minutes on one spawner and five seconds on the next — both plausible respawn times, which is exactly what makes it silent. 170 of 6,455 stock spawners are second-flagged. The parser now normalises to seconds and the API and UI carry seconds throughout.

That exposed the hash gate as a trap. "Has the tree changed?" is the wrong question on its own: an install whose maps never change would have kept serving the old readings forever, because the only thing compared was the tree. PARSER_VERSION now lives in shard_atlas_meta beside the source hashes and a mismatch counts as drift, so this fix — and any future parse correction — lands on the next boot without an operator knowing it happened.

Also renamed the detail route's spawn-point array to spawners. It was points, which is the count on the search route, so one key meant a number in one place and an array in the other.

The admin panel

Admin → Spawn Atlas (adminOnly — it reads a server filesystem path and replaces every atlas table, which is closer to a deploy action than to moderation): status, path, drift, counts, re-import, and the approve/reject decision for a refresh staged because it would remove a facet.

Three deliberate behaviours:

  • An unreadable tree is a 200 carrying status: "unavailable", not a 500. refresh() reports outcomes rather than throwing so boot is never blocked by a bad tree, and that contract is preserved at the API — the admin gets told what is wrong with their path, where a 500 would say only that something broke.
  • Setting the path does not import. Moving the mount and reloading the world are separate decisions; an operator fixing a typo should not have a multi-thousand-row replace happen under them. The response carries fresh status so the panel offers the import as the next step.
  • Every action logs to activity_log (shard.atlas.import / .approve / .reject / .path).

Verification

590 server tests pass (13 new in atlasController.test.js, plus delay-unit and parser-version cases in the existing suites), 47 client tests (4 new asserting the atlas lives at /public/atlas, not /public/shard).

End-to-end against the local MariaDB and the ServUO tree at C:\Users\colby\Desktop\ServUO:

Check Result
/atlas/meta 6 facets, 6,455 spawners, 800 creatures
"Where does a lizardman spawn?" Shrines, Isamu-Jima, Yew, Despise across 4 facets
Unknown creature 404
?limit=9999 400
atlas audience → staff, anonymous 403 on every route
atlas disabled, anonymous 404, and atlas drops out of /shard/features so the nav hides it
Admin routes unauthenticated 401

Facet-loss flow through the API, against a tree copy with malas.xml removed:

Step Result
Import needsReview, removed ["Malas"]; atlas untouched, all 293 Malas points still served
Status pending row surfaced with the facet diff
Reject → import again unchanged (refresh previously rejected)
Approve Applied — 6,162 points, Malas gone
Repoint at the real tree, import 6,455 points, Malas back

Parser-version gate on a restart with an unchanged tree: spawn atlas refreshed from ServUO tree, and the second-flagged spawners now read 30–59s where they would have read 30–59m.

Pages checked in the browser: /site/atlas (search, facet chips, tabs, paging), /site/atlas/:slug (places, spawner table, "shares a spawner with"), and the admin panel end to end.

OpenAPI spec and route manifest regenerated (204 → 215 routes). Docs: RunicGateway/docs#68.


  • AI-assisted: written with Claude Code (Claude Opus 5), reviewed before opening.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP

Protocol 3.0 order 3 (Part C), second of two website PRs. [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) built the data pipeline; this makes it reachable. Still website-only: no plugin, no sidecar, no new event kinds, no wire change. Six public routes, five admin ones, two public pages, and an admin panel. ## The API is not under `/shard`, and that is the point Nothing here touches the sidecar — the atlas is parsed from the shard's own ServUO tree — so `/api/v1/public/atlas/*` stays complete while the shard is down, and a `/shard` prefix would advertise a dependency it does not have. Unlike `/shard/*` it **is** `siteMode`-gated, like `/posts` and `/wiki`: a bestiary is site content and follows site content's rules. | Route | Answers | |---|---| | `GET /atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, paginated, with an unpaginated `total` | | `GET /atlas/creatures/:slug` | `places`, `spawners`, `alsoHere` | | `GET /atlas/regions` · `/atlas/landmarks` | named regions and points of interest | | `GET /atlas/champions` | the **configured** altar roster (not `/shard/champs`, the live board) | | `GET /atlas/meta` | facets, counts, when it was parsed | Every route carries `requireFeature('atlas')` — 404 disabled, 403 out of rung — and projects its response. `atlas` declares no sensitive fields, so the projection is a **no-op today**; the call is there because §3.6.1's rule is that the *first* field needing a gate should be covered by construction rather than by a retrofit nobody remembers. `GET /atlas/meta` reports the game world only. The ServUO path, the source hashes and any pending refresh describe the operator's filesystem and live on the admin route. A facet is never validated against a list — nothing in this codebase names one. `?facet=` is length-bounded and matched exactly, and the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input: that function treats `%` and `_` as wildcards, so `?facet=%` would have matched everything. ## Two bugs the UI surfaced **Respawn delays were being read in the wrong unit — sometimes.** XmlSpawner writes `MinDelay`/`MaxDelay` in minutes and switches to seconds only when a delay does not divide into whole minutes, flagging that per record with `DelayInSec` (`XmlSpawner2.cs:7462-7480`, read back at `:6345-6358`). A `5` therefore means five *minutes* on one spawner and five *seconds* on the next — both plausible respawn times, which is exactly what makes it silent. **170 of 6,455** stock spawners are second-flagged. The parser now normalises to seconds and the API and UI carry seconds throughout. **That exposed the hash gate as a trap.** "Has the tree changed?" is the wrong question on its own: an install whose maps never change would have kept serving the old readings forever, because the only thing compared was the tree. `PARSER_VERSION` now lives in `shard_atlas_meta` beside the source hashes and a mismatch counts as drift, so this fix — and any future parse correction — lands on the next boot without an operator knowing it happened. Also renamed the detail route's spawn-point array to `spawners`. It was `points`, which is the *count* on the search route, so one key meant a number in one place and an array in the other. ## The admin panel **Admin → Spawn Atlas** (`adminOnly` — it reads a server filesystem path and replaces every atlas table, which is closer to a deploy action than to moderation): status, path, drift, counts, re-import, and the approve/reject decision for a refresh staged because it would remove a facet. Three deliberate behaviours: - **An unreadable tree is a 200 carrying `status: "unavailable"`, not a 500.** `refresh()` reports outcomes rather than throwing so boot is never blocked by a bad tree, and that contract is preserved at the API — the admin gets told what is wrong with their path, where a 500 would say only that something broke. - **Setting the path does not import.** Moving the mount and reloading the world are separate decisions; an operator fixing a typo should not have a multi-thousand-row replace happen under them. The response carries fresh status so the panel offers the import as the next step. - Every action logs to `activity_log` (`shard.atlas.import` / `.approve` / `.reject` / `.path`). ## Verification **590 server tests pass** (13 new in `atlasController.test.js`, plus delay-unit and parser-version cases in the existing suites), **47 client tests** (4 new asserting the atlas lives at `/public/atlas`, not `/public/shard`). End-to-end against the local MariaDB and the ServUO tree at `C:\Users\colby\Desktop\ServUO`: | Check | Result | |---|---| | `/atlas/meta` | 6 facets, 6,455 spawners, 800 creatures | | "Where does a lizardman spawn?" | Shrines, Isamu-Jima, Yew, Despise across 4 facets | | Unknown creature | 404 | | `?limit=9999` | 400 | | `atlas` audience → `staff`, anonymous | **403** on every route | | `atlas` disabled, anonymous | **404**, and `atlas` drops out of `/shard/features` so the nav hides it | | Admin routes unauthenticated | 401 | Facet-loss flow through the API, against a tree copy with `malas.xml` removed: | Step | Result | |---|---| | Import | `needsReview`, removed `["Malas"]`; atlas untouched, all 293 Malas points still served | | Status | pending row surfaced with the facet diff | | Reject → import again | `unchanged (refresh previously rejected)` | | Approve | Applied — 6,162 points, Malas gone | | Repoint at the real tree, import | 6,455 points, Malas back | Parser-version gate on a **restart with an unchanged tree**: `spawn atlas refreshed from ServUO tree`, and the second-flagged spawners now read 30–59s where they would have read 30–59m. Pages checked in the browser: `/site/atlas` (search, facet chips, tabs, paging), `/site/atlas/:slug` (places, spawner table, "shares a spawner with"), and the admin panel end to end. OpenAPI spec and route manifest regenerated (204 → 215 routes). Docs: RunicGateway/docs#68. --- - [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
wtclaude added 1 commit 2026-07-29 00:52:32 +00:00
Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data
pipeline; this makes it reachable — six public routes, five admin ones, two
public pages and an admin panel. Still website-only: no plugin, no sidecar, no
new event kinds, no wire change.

The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here
touches the sidecar, so the pages stay complete while the shard is down, and a
/shard prefix would imply a dependency the atlas does not have. Unlike /shard/*
it IS site-mode gated, like /posts and /wiki: a bestiary is site content.

Every route carries requireFeature('atlas') and projects its response. The atlas
feature declares no sensitive fields, so the projection is a no-op today — the
call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate
should be covered by construction rather than by a retrofit.

Two bugs the UI surfaced, both fixed here:

Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes
MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not
divide into whole minutes, flagging it per record with DelayInSec. A `5` means
five minutes on one spawner and five seconds on the next, both plausible, and
the pipeline stored the raw number. 170 of 6,455 stock spawners are second
flagged. The parser normalises to seconds; the API and UI carry seconds.

That exposed the hash gate as a trap. "Has the tree changed?" is the wrong
question on its own: an install whose maps never change would have kept serving
the old readings forever, because the only thing compared was the tree.
PARSER_VERSION is now stored beside the source hashes and a mismatch counts as
drift, so any future parse correction lands on the next boot.

Also renamed the detail route's spawn-point array to `spawners` — it was
`points`, which is the COUNT on the search route, so one key meant a number in
one place and an array in the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
whitlocktech approved these changes 2026-07-29 00:53:17 +00:00
whitlocktech merged commit bfa1db58c4 into edge 2026-07-29 00:53:23 +00:00
whitlocktech deleted branch feat/spawn-atlas-api 2026-07-29 00:53:24 +00:00
Sign in to join this conversation.
No description provided.