2 Commits

Author SHA1 Message Date
eab0a83f26 Merge pull request 'docs(link): the shard-name fallback, the atlas places shape, and two traps' (#76) from docs/protocol-3-smoke-findings into edge
Reviewed-on: #76
2026-08-01 06:02:59 +00:00
1444c77413 docs(link): the shard-name fallback, the atlas places shape, and two traps
Records what the live Protocol 3.0 smoke test (ServUO + sidecar + website +
AVD) turned up, so none of it has to be rediscovered.

v3.md §5.3 — the ruleset `shard` field now falls back to the instance's own
name when the shard publishes ServUO's stock "My Shard", why that is done at
ingest rather than on read (the frame is also broadcast live), and why the
backfill snapshot must go through the dispatcher instead of writing state
directly: a direct call made it a second writer that skipped the
normalization.

v3.md §7.4 — an unscored board renders a placeholder row rather than a blank
card, and why it is deliberately not shaped like a real entry.

PLAN.md §9 M11 — `places` is a list of {facet,label,spawners,maxAlive} OBJECTS,
not of place-name strings, and typing it `List<String>` makes the whole detail
route fail to decode while the request itself returns 200. Adds the rule that
came out of it: decode tests must feed real captured JSON, because the fakes
build DTOs in Kotlin and can never catch a wire mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:58:53 -05:00
2 changed files with 36 additions and 5 deletions

View File

@@ -970,14 +970,22 @@ push, and Play (M6M8) follow the designed app.
(`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard (`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard
*content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like *content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like
`/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar. `/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar.
Two units/naming traps from `v3.md` §6.3: respawn delays are **seconds** throughout, and Three traps. Two are units/naming, from `v3.md` §6.3: respawn delays are **seconds**
`points` is a *count* on the search route while `spawners` is the *list* on the detail route. throughout, and `points` is a *count* on the search route while `spawners` is the *list* on
the detail route. The third is a **shape**: `places` is a list of
`{facet, label, spawners, maxAlive}` **objects**, not of place-name strings — it is the
aggregate the screen exists to show ("Shrines, Isamu-Jima, Yew"), it arrives only on the
detail route, and typing it `List<String>` makes that whole route fail to decode while the
request itself returns `200`.
- **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`) - **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`)
against a local website on the cutover branch, per against a local website on the cutover branch, per
[`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with [`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with
**every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface **every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface
instead of erroring on it. Unit tests cover the menu filter (role × feature set), the instead of erroring on it. Unit tests cover the menu filter (role × feature set), the
`404`/`403`/`503` mapping, and DTO decode for each new shape. `404`/`403`/`503` mapping, and DTO decode for each new shape. **Decode tests must feed real
captured JSON**, not DTOs built in Kotlin: the fakes under `data/api/fake/` construct objects
directly, so they can never catch a wire/type mismatch — which is how the `places` shape above
shipped past a green suite.
- **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard - **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard
Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot
config, uo-link config and OAuth-provider setup. config, uo-link config and OAuth-provider setup.

View File

@@ -350,8 +350,11 @@ Sidecar — `store.rs`: singleton `ruleset(id CHECK(id=1), rev, json, updated_t)
`main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so `main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so
it answers during a shard outage (`PROTOCOL_2.md` §12.2). it answers during a shard outage (`PROTOCOL_2.md` §12.2).
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so follow the Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so it cannot use the
`getPresence()` block's explicit form, not the array-only `snapshot()` helper); `shardIngest.js` array-only `snapshot()` helper — but it **must still go through `shardIngest.ingest()`**, as
`ingestEach` does, rather than calling `shardState.setRuleset` directly: the two arrival orders have
to produce the same stored frame, and a direct call quietly made backfill a second writer that
skipped the normalization below); `shardIngest.js`
`shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello` `shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello`
already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table
(`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind (`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind
@@ -360,6 +363,17 @@ already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_rulese
Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside
`/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`. `/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`.
**The `shard` field falls back to the instance's own name.** ServUO ships `Server.cfg` with
`Name=My Shard`, so an operator who never edited it publishes that verbatim — which is the shard
saying *unnamed*, not naming anything, and the rules page then reads "My Shard" under a header
carrying the real one. `shardIngest` substitutes `settings.getInstanceName()` (the admin-editable
site title, else `BRAND_NAME` — the same resolution `getPublic().brand.name` uses, so one install
never shows two names) when `shard` is absent, blank, or exactly the stock default, matched
case-insensitively and trim-tolerantly but only as a **whole** value: a shard genuinely called
*"My Shard Reborn"* has named itself and keeps it. Applied at **ingest**, not on read, because the
ruleset is also broadcast live — the same object goes to the SSE fan-out, so a read-time
substitution would be undone by the next reconnect's frame.
### 5.4 Risk ### 5.4 Risk
Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit
@@ -638,6 +652,15 @@ 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`.
**An unscored board still renders a row.** Most systems on a young shard have `top: []`, and a page
of blank cards reads as broken rather than as new — so a board with no entries shows a single
placeholder bearing the **instance's own name** with an em dash where a score goes, above the
existing "nobody has earned points here yet" line. It is deliberately **not** shaped like an entry —
no rank, no medal, no bar, muted — because a placeholder that looked like a real standing would be a
fabricated one; the first real entry replaces it outright. Purely presentational: the API keeps
sending an empty `top`, so no consumer ever receives an invented row. Web and app render it the same
way (`Leaderboards.jsx`, `LeaderboardsScreen.kt`).
### 7.5 What the run against a real shard changed ### 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 The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a