Merge pull request 'docs(link): points.board, the leaderboards API, and what a real shard changed' (#69) from feat/points-board into edge

Reviewed-on: #69
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-29 07:52:25 +00:00
4 changed files with 173 additions and 7 deletions

View File

@@ -381,6 +381,74 @@ Absent entirely if the shard runs `Bridge.RulesetEnabled=false` or an older plug
This **supersedes the `world.systems` frame** sketched in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.4 and This **supersedes the `world.systems` frame** sketched in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.4 and
never implemented; the `systems` block above is what that asked for. never implemented; the `systems` block above is what that asked for.
#### Points / loyalty leaderboards (Protocol 3.0)
ServUO carries ~25 separate point currencies — Queen's Loyalty, Void Pool, Casino, Clean Up Britannia,
the nine city loyalties, Blackthorn, the Doom / Khaldun / Kotl treasure systems — every one a standing
players accumulate over months, and none of them visible outside an in-game gump before 3.0.
A diff sweep (default 300 s), **one frame per system** rather than one large frame for all of them,
matching `champ.update` / `guild.update`. A system is emitted only when its top N or its participant
count actually changes.
| kind | fields | notes |
|------|--------|-------|
| `points.board` | `system`, `nameString`, `nameNumber`, `maxPoints`, `showOnGump`, `players`, `top[]` | One system's complete board — **never a delta**. The latest frame for a `system` replaces the previous one outright. `top[]` entries are `{rank, serial, name, points}`. |
`system` is the shard's own `PointsType` enum name (`QueensLoyalty`, `CleanUpBritannia`, …) and is the
board's stable key. There is deliberately **no `points.remove`**: the set of systems is fixed at startup
by `PointsSystem.Configure`, so a system cannot disappear at runtime — the same argument `city.update`
makes for cities.
```json
{"kind":"points.board","system":"QueensLoyalty",
"nameString":"Queen's Loyalty","nameNumber":1114938,
"maxPoints":15000,"showOnGump":true,"players":842,
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500},
{"rank":2,"serial":"0x1A2C","name":"Mireille","points":21000}],
"t":1752489280000}
```
**Four things consumers get wrong.**
1. **`maxPoints` of `0` means UNCAPPED, not "zero points allowed".** ServUO's idiom for an uncapped
system is `double.MaxValue` (`DespiseCrystals`, `ShameCrystals` and `VoidPool` all use it), which
the plugin normalises to `0` rather than emitting a nonsense integer. On a real shard **most
systems are uncapped**, so a UI that renders `points / maxPoints` must special-case this or it will
divide by zero on the common path.
2. **`nameString` is usually `null`.** The shard's `Name` is a `TextDefinition`, which may carry a
literal *or* a cliloc id, and in practice most systems use the cliloc — so `nameNumber` is set and
`nameString` is `null`. Resolve clilocs consumer-side; failing that, humanising the `system` key
("CleanUpBritannia" → "Clean Up Britannia") reads better than showing a bare number. This is the
same contract `titles.reward` already documents.
3. **`players` counts players who actually hold points**, not the size of the system's table. Ten of
the ~25 systems have `AutoAdd = true` and therefore keep a zero-point row for every character that
has ever logged in, so the raw table size would report the shard's entire character census as that
system's participants.
4. **Entries carry `serial` and `name` only — never `acct` or `webId`.** A board is the widest-audience
surface the bridge has, so the account name of every ranked player deliberately does not cross the
wire; resolve serial → site user from your own link mirror if you need it.
Absent entirely if the shard runs `Bridge.PointsLeaderboardEnabled=false` or an older plugin. Render
from `GET /points` (§6) on connect, then keep live with this event.
##### `char.profile` gains a `points` block
Read-model enrichment on the existing kind — there is **no** request kind for one character's points,
the same precedent `titles` set in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.3:
```json
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty","nameNumber":1114938,
"points":29500,"maxPoints":15000}]
```
Systems where the character has no entry, or an entry at zero, are **omitted** — otherwise every sheet
would carry ~25 zeroes. `maxPoints` follows the same `0 == uncapped` rule as the board.
`rank` is **absent by default** and appears only when the shard runs `Bridge.PointsProfileRank=true`:
a points lookup stops at the character's own row, but a rank must count every row that beats them, in
every system, on every profile build. Derive rank from `points.board` instead for anyone in the top N.
--- ---
## 5. REST — read queries ## 5. REST — read queries
@@ -740,6 +808,27 @@ worse than one that is briefly stale. Keep it current with the `world.ruleset` s
`Bridge.RulesetEnabled=false`. That is a real answer distinct from a published ruleset, and worth `Bridge.RulesetEnabled=false`. That is a real answer distinct from a published ruleset, and worth
rendering differently ("not published yet") rather than as an empty ruleset. rendering differently ("not published yet") rather than as an empty ruleset.
### Points / loyalty leaderboards (Protocol 3.0)
```
GET /points
→ { "boards": [ {"kind":"points.board","system":"QueensLoyalty","nameString":"Queen's Loyalty",
"nameNumber":1114938,"maxPoints":15000,"showOnGump":true,"players":842,
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500}, ...],"t":...}, ... ] }
GET /points/{system} # e.g. /points/QueensLoyalty
→ {"kind":"points.board","system":"QueensLoyalty", ... }
```
Every system's latest board, or one by its `PointsType` name (§4 for the frame and its four gotchas).
Served from the sidecar's projection, kept current by the `points.board` stream, ordered by display
name. Survives a sidecar restart — which matters more here than for live state, since these are
standings built over months and blanking them during a restart reads as data loss.
`GET /points/{system}` returns **404** for a system the shard has never published (an unknown name, or
one excluded by `Bridge.PointsSystems`). That is distinct from a published board nobody has scored in
yet, which is **200** with an empty `top[]` — and the two are worth rendering differently.
--- ---
## 7. Status codes ## 7. Status codes

View File

@@ -318,10 +318,14 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
**Beyond 1.0.** Phases above are the 1.0 read/event plane. Protocol 2.0's phasing (provisioning + **Beyond 1.0.** Phases above are the 1.0 read/event plane. Protocol 2.0's phasing (provisioning +
world-state boards) is [`PROTOCOL_2.md`](PROTOCOL_2.md) §13; Protocol 3.0's (visibility framework, world-state boards) is [`PROTOCOL_2.md`](PROTOCOL_2.md) §13; Protocol 3.0's (visibility framework,
shard content and standings) is [`v3.md`](v3.md) §9, which also tracks what has landed. Shipped from shard content and standings) is [`v3.md`](v3.md) §9, which also tracks what has landed. Shipped from
3.0 so far: **Part A** — the visibility framework — and **`world.ruleset`** ([`v3.md`](v3.md) §5), 3.0 so far: **Part A** — the visibility framework — **`world.ruleset`** ([`v3.md`](v3.md) §5),
`BridgeRuleset.cs`, the first bridge stream that is neither an event subscription nor a sweep: it is `BridgeRuleset.cs`, the first bridge stream that is neither an event subscription nor a sweep (it is
emitted once per connect, like `server.hello`, because shard config changes only when an operator emitted once per connect, like `server.hello`, because shard config changes only when an operator
edits a file. edits a file) — the **spawn atlas** ([`v3.md`](v3.md) §6), which is website-only and touches no wire
at all — and **`points.board`** ([`v3.md`](v3.md) §7), `BridgePoints.cs`, the loyalty/points
leaderboards. `BridgePoints` is the widest read the bridge performs: ten of ServUO's ~25 point systems
keep a row for every character ever created, so it selects the top N in a single bounded pass rather
than sorting, and runs on a deliberately slow 300 s interval.
### Config keys (`Config/Bridge.cfg`) ### Config keys (`Config/Bridge.cfg`)
@@ -338,7 +342,8 @@ Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is
The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals The set above is the 1.0 sample, not the current one — every later phase added keys (sweep intervals
for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's for each board, the town-crier/news caps, the admin write plane, account provisioning, and 3.0's
`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`). **`servuo-plugins/overlay/Config/Bridge.cfg` `RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`, and the `Points*` block).
**`servuo-plugins/overlay/Config/Bridge.cfg`
is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults. is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults.
--- ---

View File

@@ -14,7 +14,7 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
| 1 | **A** — visibility framework + actor-leak fix (§3) | ✅ **Done** | website [#109](https://gitea.whitlocktech.com/RunicGateway/website/pulls/109) + [#110](https://gitea.whitlocktech.com/RunicGateway/website/pulls/110), docs [#64](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/64) + [#65](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/65) | | 1 | **A** — visibility framework + actor-leak fix (§3) | ✅ **Done** | website [#109](https://gitea.whitlocktech.com/RunicGateway/website/pulls/109) + [#110](https://gitea.whitlocktech.com/RunicGateway/website/pulls/110), docs [#64](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/64) + [#65](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/65) |
| 2 | **B/1**`world.ruleset` (§5) | ✅ **Done** | servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3), link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17), website [#111](https://gitea.whitlocktech.com/RunicGateway/website/pulls/111), docs [#66](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/66) | | 2 | **B/1**`world.ruleset` (§5) | ✅ **Done** | servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3), link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17), website [#111](https://gitea.whitlocktech.com/RunicGateway/website/pulls/111), docs [#66](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/66) |
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) | | 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
| 4 | **B/2**`points.board` (§7) | ⬜ Not started | — | | 4 | **B/2**`points.board` (§7) | **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
| 5 | **B/3**`vendor.listing` (§8) | ⬜ Not started | — | | 5 | **B/3**`vendor.listing` (§8) | ⬜ Not started | — |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — | | 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — |
@@ -507,7 +507,12 @@ retrofit nobody remembers to do.
--- ---
## 7. Part B/2 — `points.board` ## 7. Part B/2 — `points.board` ✅ Done
*Landed on `edge`: servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4),
link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114),
docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69). Verified against the real ServUO tree
per §11 — see §7.5 for what that run changed.*
Two deliverables: a diff sweep for the boards, and a `points` block folded into `char.profile` Two deliverables: a diff sweep for the boards, and a `points` block folded into `char.profile`
the `PROTOCOL_2.md` §10.3 `titles` precedent (read-model enrichment, no new request kind). the `PROTOCOL_2.md` §10.3 `titles` precedent (read-model enrichment, no new request kind).
@@ -590,6 +595,43 @@ Client — NEW `routes/public/Leaderboards.jsx` at `/site/leaderboards`; a "Loya
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
`AdminCharacter.jsx`. `AdminCharacter.jsx`.
### 7.5 What the run against a real shard changed
The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a
43,011-mobile world) and letting one sweep run corrected four things — all of them invisible to a
fake-shard test, because a fake shard emits whatever the spec says it should.
1. **`maxPoints` overflowed to `long.MinValue`.** `MaxPoints` is a `double`, and ServUO's idiom for an
uncapped system is `double.MaxValue` — which `DespiseCrystals`, `ShameCrystals` and `VoidPool` all
use. `(long)double.MaxValue` in C# is an **unchecked** conversion: it does not throw, it yields
`long.MinValue`, and the first real sweep published
`"maxPoints": -9223372036854775808` for three of the five live boards. Fixed with `Cap()` /
`Score()` converters that normalise anything unrepresentable to `0`, which is now the wire's
documented **"uncapped"** value. Worth stating plainly because it inverts the obvious reading:
**on a real shard, `maxPoints: 0` is the common case, not an edge case**, so any UI dividing by it
must special-case it.
2. **`nameString` is usually `null`.** Most systems define their `Name` as a cliloc rather than a
literal: four of the five boards on the live shard came back `nameString: null` with only
`nameNumber` set. The humanise-the-`system`-key fallback is therefore the *primary* display path,
not a defensive nicety, and both the leaderboards page and the character sheet lead with it.
3. **`GetEntry`/`GetPoints` cannot be used in the read model.** `GetEntry(from, create: false)` still
calls `AddEntry` when the system has `AutoAdd` (`PointsSystem.cs:207`) — it **mutates the world**.
Ten of the ~25 systems have `AutoAdd = true`, so a profile built with the obvious accessor would
have appended up to ten rows to the points save file every time anyone viewed a character sheet.
`BridgeProfile.WritePoints` hand-rolls a read-only scan instead, and says so loudly.
4. **`players` had to be redefined.** §7.2 called for "the entry count", but those same ten `AutoAdd`
systems hold a zero-point row per character ever created — so the raw count reports the shard's
whole census as one system's participants. It is now the number of players actually holding points,
which is both the honest number and a strictly better diff signal (it moves when someone scores,
not when someone logs in for the first time).
One deviation from the plan as written, for the same class of reason: §7.4 named the per-field
visibility rule `characterName`, but `projectValue` matches on the **literal JSON key**, and the wire
key is `name`. A rule under the descriptive name would have been silently inert — an admin tightening
character names would have got no enforcement and no error, exactly the failure §3.6.1 records for the
flattened `ownerAcct`. `FEATURES.leaderboards.fields` therefore keys on `name`, with a test that fails
if it is renamed back.
--- ---
## 8. Part B/3 — `vendor.listing` ## 8. Part B/3 — `vendor.listing`
@@ -700,7 +742,7 @@ inherently up to one full cycle old, and the UI must say so.
| 1 | **A** — visibility framework + actor-leak fix | website, docs | none | ✅ Done | | 1 | **A** — visibility framework + actor-leak fix | website, docs | none | ✅ Done |
| 2 | **B/1**`world.ruleset` (§5) | all four | new kind | ✅ Done | | 2 | **B/1**`world.ruleset` (§5) | all four | new kind | ✅ Done |
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done | | 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | | | 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
| 5 | **B/3**`vendor.listing` (§8) | all four | new kinds | ⬜ | | 5 | **B/3**`vendor.listing` (§8) | all four | new kinds | ⬜ |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | ⬜ | | 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | ⬜ |

View File

@@ -379,6 +379,34 @@ they are cheap to display — the same payload-plus-hoisted-columns shape `shard
served as `null` rather than `{}`: "not published yet" and "published, everything off" are different served as `null` rather than `{}`: "not published yet" and "published, everything off" are different
answers and the page renders them differently. answers and the page renders them differently.
### shard_points_boards — points / loyalty leaderboards (Protocol 3.0)
One row per point system, keyed by the shard's own `PointsType` name (`QueensLoyalty`,
`CleanUpBritannia`, …). The shard carries ~25 of these, each a standing players build over months.
Columns: `system` (PK), `name`, `name_cliloc`, `max_points`, `players`, `show_on_gump`, `payload` JSON
(the whole `points.board` frame), `t`, `updated_at`.
**The top-N list stays inside `payload`** rather than being normalized into a `shard_points_entries`
table. It is a fixed-size list (10 by default) that is only ever read whole — exactly like
`shard_governors.candidates` — so normalizing buys nothing until something needs a per-character
reverse lookup, and a character's own standings already ride inside `char.profile` instead.
Board state, not events: `points.board` is **not** in `LOGGED_KINDS`, for the same reason
`guild.update` isn't. The shard emits a frame every time anyone's score moves a top ten, so logging
would grow `shard_events` without bound for something whose only interesting value is its latest
version. There is also **no delete path** — the shard's set of systems is fixed at startup, so there is
no `points.remove` to mirror.
Two values carry non-obvious meanings, both set by the plugin and both documented in
[`link/INTEGRATION.md`](../link/INTEGRATION.md) §4:
- **`max_points = 0` means uncapped**, and on a real shard that is the *common* case (ServUO's
uncapped idiom is `double.MaxValue`, which the plugin normalises to 0). Anything rendering
`points / max_points` must special-case it.
- **`name` is usually NULL**, with `name_cliloc` set instead — most systems name themselves with a
cliloc rather than a literal. Listing therefore orders by `COALESCE(name, system)`, so boards
awaiting cliloc resolution sort by their own key rather than clumping together under NULL.
### shard_feature_visibility — per-feature audience config (Protocol 3.0) ### shard_feature_visibility — per-feature audience config (Protocol 3.0)
One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5), One row per shard feature: `feature` (PK), `enabled`, `audience` (a rung on the ladder in §6.5),
@@ -657,6 +685,8 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/wiki/:slug` | single page | | GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` | | POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). | | GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. | | GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
| GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. | | GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. |
| GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. | | GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |