feat(shard): ingest world.ruleset and publish it at /site/rules #111

Merged
whitlocktech merged 1 commits from feat/shard-ruleset into edge 2026-07-28 20:50:37 +00:00
Member

Protocol 3.0 order 2 — docs/link/v3.md §5. Website half of a four-repo change.

The shard now publishes its own ruleset — expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule — and the site renders it, so the rules page cannot drift from how the shard actually plays.

Server

  • shard_ruleset — a singleton table (id = 1, CHECK-constrained) holding the whole frame in payload, with rev and expansion hoisted for cheap display. Stored whole rather than normalized: it is a flat description of config read as one page, so splitting it into columns would mean a schema change every time the shard grows a block. Same payload-plus-hoisted-columns shape shard_champs uses.
  • shardIngest routes world.ruleset to setRuleset and deliberately does not log it. The shard re-emits the whole ruleset on every sidecar connect, so logging would append a duplicate shard_events row per reconnect — and server.hello already marks each of those.
  • uoLinkSocket backfills GET /ruleset explicitly rather than through snapshot(), which asserts an array under key. This covers the ordering where the sidecar was already up and holding the ruleset when we reconnected.
  • GET /public/shard/ruleset behind requireFeature('ruleset') and projected — per §3.6.1's standing rule that a read path returning shard data which does not call projectFeature is a bug. null means the shard has never published one; a real answer, distinct from a published ruleset.

Client

  • routes/public/Rules.jsx at /site/rules, live via world.ruleset. A frame is a complete ruleset, not a delta, so the newest one wins outright rather than merging.
  • Caps are rendered from tenths7000 is 700.0 total skill. Showing the raw number is actively misleading, not merely unhelpful.
  • A systems key this build doesn't know still renders, humanised, so a newer plugin can't go silently invisible against an older client.
  • The nav entry is gated on the ruleset feature, so it hides rather than 403s.

Verification

Full-stack against the local MariaDB, the Rust sidecar, and a fake shard:

Check Result
WS-connect backfill snapshotted shard ruleset from /ruleset {"rev":"1a2b3c4d"}
Anonymous GET /public/shard/ruleset full ruleset, arrays and nested blocks intact
Live SSE on a changed ruleset world.ruleset rev=feed0042 skillCap=1200 delivered
REST after that change reflects the overwrite (not a second row)
GET /shard/feed?kind=world.ruleset [] — confirming it is not logged
audience=staff, anonymous caller 403, and dropped from /shard/features so nav hides it
feature disabled 404 (existence not leaked)
default config 200

/site/rules rendered clean with no console errors; blocks whose system is off (auto-restart) correctly disappear rather than rendering empty.

497 server tests pass. New test/shardIngest.ruleset.test.js (routing, not-logged, backfill-no-broadcast, overwrite, write-failure resilience) and four getRuleset cases added to test/shardControllerPublic.test.js. routes.manifest.json, routes.guards.json and the OpenAPI spec regenerated and committed.

Sibling PRs

  • servuo-plugins #3 → the BridgeRuleset.cs emitter
  • link #17GET /ruleset + the store projection
  • docs → INTEGRATION.md catalog, BACKEND_DESIGN.md table + route, v3.md progress

  • 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 2 — [`docs/link/v3.md` §5](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/link/v3.md). Website half of a four-repo change. The shard now publishes its own ruleset — expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule — and the site renders it, so the rules page cannot drift from how the shard actually plays. ## Server - **`shard_ruleset`** — a singleton table (`id = 1`, CHECK-constrained) holding the whole frame in `payload`, with `rev` and `expansion` hoisted for cheap display. Stored whole rather than normalized: it is a flat description of config read as one page, so splitting it into columns would mean a schema change every time the shard grows a block. Same payload-plus-hoisted-columns shape `shard_champs` uses. - **`shardIngest`** routes `world.ruleset` to `setRuleset` and deliberately does **not** log it. The shard re-emits the whole ruleset on *every* sidecar connect, so logging would append a duplicate `shard_events` row per reconnect — and `server.hello` already marks each of those. - **`uoLinkSocket`** backfills `GET /ruleset` **explicitly** rather than through `snapshot()`, which asserts an array under `key`. This covers the ordering where the sidecar was already up and holding the ruleset when *we* reconnected. - **`GET /public/shard/ruleset`** behind `requireFeature('ruleset')` and projected — per §3.6.1's standing rule that *a read path returning shard data which does not call `projectFeature` is a bug*. `null` means the shard has never published one; a real answer, distinct from a published ruleset. ## Client - **`routes/public/Rules.jsx`** at `/site/rules`, live via `world.ruleset`. A frame is a *complete* ruleset, not a delta, so the newest one wins outright rather than merging. - **Caps are rendered from tenths** — `7000` is `700.0` total skill. Showing the raw number is actively misleading, not merely unhelpful. - A `systems` key this build doesn't know still renders, humanised, so a newer plugin can't go silently invisible against an older client. - The nav entry is gated on the `ruleset` feature, so it hides rather than 403s. ## Verification Full-stack against the local MariaDB, the Rust sidecar, and a fake shard: | Check | Result | |---|---| | WS-connect backfill | `snapshotted shard ruleset from /ruleset {"rev":"1a2b3c4d"}` | | Anonymous `GET /public/shard/ruleset` | full ruleset, arrays and nested blocks intact | | Live SSE on a **changed** ruleset | `world.ruleset rev=feed0042 skillCap=1200` delivered | | REST after that change | reflects the overwrite (not a second row) | | `GET /shard/feed?kind=world.ruleset` | `[]` — confirming it is not logged | | `audience=staff`, anonymous caller | `403`, **and** dropped from `/shard/features` so nav hides it | | feature disabled | `404` (existence not leaked) | | default config | `200` | `/site/rules` rendered clean with no console errors; blocks whose system is off (auto-restart) correctly disappear rather than rendering empty. **497 server tests pass.** New `test/shardIngest.ruleset.test.js` (routing, not-logged, backfill-no-broadcast, overwrite, write-failure resilience) and four `getRuleset` cases added to `test/shardControllerPublic.test.js`. `routes.manifest.json`, `routes.guards.json` and the OpenAPI spec regenerated and committed. ## Sibling PRs - servuo-plugins [#3](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/3) → the `BridgeRuleset.cs` emitter - link [#17](https://gitea.whitlocktech.com/RunicGateway/link/pulls/17) → `GET /ruleset` + the store projection - docs → `INTEGRATION.md` catalog, `BACKEND_DESIGN.md` table + route, `v3.md` progress --- - [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-28 19:41:36 +00:00
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.

Server
  - shard_ruleset: a singleton table (id = 1) holding the whole frame in
    `payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
    the frame is a flat description of config read as one page, and splitting it
    into columns would mean a schema change every time the shard grows a block.
  - shardIngest routes world.ruleset to setRuleset and deliberately does NOT
    log it — the shard re-emits the whole ruleset on every sidecar connect, so
    logging would append a duplicate row per reconnect, and server.hello already
    marks each of those.
  - uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
    which asserts an array; this covers the order where the sidecar was already
    up and holding the ruleset when we reconnected.
  - GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
    per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
    means the shard has never published one — a real answer, distinct from a
    published ruleset, and the page says so.

Client
  - routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
    complete ruleset, not a delta, so the newest one wins outright). Caps are
    rendered from tenths — 7000 is 700.0, and showing the raw number would
    mislead. A systems key this build doesn't know still renders, humanised, so
    a newer plugin can't go invisible against an older client.
  - Nav entry gated on the `ruleset` feature, so it hides rather than 403s.

Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.

497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-07-28 20:39:26 +00:00
whitlocktech merged commit 7b98f1a778 into edge 2026-07-28 20:50:37 +00:00
whitlocktech deleted branch feat/shard-ruleset 2026-07-28 20:50:38 +00:00
Sign in to join this conversation.
No description provided.