From b0a2207c6ad96facd89037655213df7ac3922d9d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 14:39:53 -0500 Subject: [PATCH 1/2] docs(link): record world.ruleset and mark Protocol 3.0 progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 3.0 order 2 (v3.md §5) is built across all four repos; this is its documentation half, plus the running progress record the plan was missing. v3.md - A progress table at the top and a State column on §9's sequencing table, so "what has landed" is answerable without reading four git logs. Part A (order 1) and world.ruleset (order 2) are marked done; the spawn atlas is next. - §5 gains the implementation notes worth keeping, chiefly: where a system's on/off state is DERIVED rather than configured, read the system's own static instead of inventing a .cfg key (Shadowguard has no Enabled key — it's the TOL expansion gate; Factions is `!ViceVsVirtueSystem.Enabled` by construction in stock ServUO). Also that the plugin CAN be compile-verified despite the "no standalone build" caveat, and how. INTEGRATION.md - The world.ruleset catalog entry and GET /ruleset, with the two things consumers get wrong: caps are in TENTHS (1000 = 100.0), and `connect` exists only if the operator set Bridge.PublicConnectAddress — the shard's real listen address is never published. - §2 now says plainly that v3 has NOT been bumped yet and what that means: sidecars on `edge` report 2 while already carrying some v3 kinds, so do not infer feature availability from the version during this window. PROTOCOL_2.md §10.4 - The deferred "which PvP system does this shard run?" is answered (VvV on, Factions off — and mutually exclusive by construction), and world.systems is marked superseded by world.ruleset, which carries the systems block it asked for. No orphan kind is left behind. BACKEND_DESIGN.md — the shard_ruleset table (why it is stored whole rather than normalized, and why no row means null rather than {}) and the public route. PROJECT_TREE.md is deliberately untouched: sync-project-tree regenerates it on push to main, so it updates itself at the v3 cutover. Co-Authored-By: Claude --- link/INTEGRATION.md | 82 +++++++++++++++++++++++++++++++++++++++ link/PLAN.md | 13 +++++++ link/PROTOCOL_2.md | 17 +++++++- link/v3.md | 61 +++++++++++++++++++++++------ website/BACKEND_DESIGN.md | 20 +++++++++- 5 files changed, 180 insertions(+), 13 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index ef9105f..f46e686 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -43,6 +43,20 @@ Pin the version you built against and compare it to the header (or `/health.prot **v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. +**v3 (Protocol 3.0) is being built and the version has not been bumped yet.** It is defined as *adds +`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to +`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main` +cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and +the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site +repeatedly. + +Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints. +That is safe in the direction that matters: event kinds are additive, and a client that ignores +unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not** +do is infer feature availability from the version number during this window — probe the endpoint, or +treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no +feature-negotiation array**: v3 implies all three kinds. + --- ## 3. Health @@ -315,6 +329,58 @@ The house registry — one row per house, complementing the `house.decay` *trans Render from `GET /houses` (§6) on connect, then keep live with these events. +#### Shard ruleset (Protocol 3.0) + +How the shard is actually configured, published by the shard itself. **Not a sweep** — it changes only +when an operator edits `Config/*.cfg`, so it is emitted once per shard↔sidecar connect (and on +`[bridge reload`), exactly like `server.hello`. + +| kind | fields | notes | +|------|--------|-------| +| `world.ruleset` | `rev`, `shard`, `expansion`, `connect?`, `systems`, `caps`, `housing`, `accounts`, `vetRewards`, `loot`, `vendors`, `champions?`, `treasureMaps`, `vvv?`, `store`, `schedule?` | The whole ruleset, always complete — **never a delta**, so the latest frame replaces the previous one outright. Every block except `shard`/`expansion` is optional and is **omitted when its system is off**, so absence means "not applicable here", not "unknown". | + +`rev` is the shard's FNV-1a of the body: identical `rev` means the ruleset is unchanged and this frame +is just a reconnect re-send, so a consumer can skip the write. It is deliberately **not** +`String.GetHashCode()`, which is seeded per process and would change on every shard restart. + +```json +{"kind":"world.ruleset","rev":"1a2b3c4d","shard":"UOMysticmoon","expansion":"EJ", + "systems":{"cityLoyalty":true,"vvv":true,"factions":false,"siege":false,"chat":true, + "store":true,"dailyRares":true,"honesty":true,"shadowguard":true, + "treasureMaps":true,"vetRewards":true,"testCenter":false}, + "caps":{"skill":1000,"totalSkill":7000,"stat":225,"str":125,"dex":125,"int":125, + "strMax":150,"dexMax":150,"intMax":150}, + "housing":{"accountHouseLimit":1}, + "accounts":{"perIp":3,"charSlots":7,"autoCreate":true}, + "vetRewards":{"enabled":true,"rewardIntervalDays":30}, + "loot":{"feluccaLuckBonus":1000,"feluccaBudgetBonus":100,"feluccaMaxProps":11}, + "vendors":{"restockDelayMinutes":60,"maxSell":500,"economyStockAmount":500}, + "champions":{"powerScrolls":6,"statScrolls":16,"scrollChance":0.1, + "transcendenceChance":50.0,"rankThresholds":[5,10,13]}, + "treasureMaps":{"enabled":true,"lootChance":0.01,"resetDays":30}, + "vvv":{"enabled":true,"startSilver":2000,"enhancedRules":false}, + "store":{"enabled":true,"currencyName":"Sovereigns"}, + "schedule":{"autoSaveEnabled":true,"autoSaveFrequencyMinutes":15,"autoRestartEnabled":false}, + "t":1752489280000} +``` + +**Two things consumers get wrong.** + +1. **`caps.skill` and `caps.totalSkill` are in tenths**, the way ServUO stores them: `1000` is `100.0` + skill and `7000` is `700.0` total. Rendering the raw number is actively misleading. The other caps + (`stat`, `str`, …) are plain integers. +2. **`connect` is present only if the operator set `Bridge.PublicConnectAddress`.** The shard's real + listen address (`Server.cfg`) is never published; nor are `Staff.cfg`, `Email.cfg`, `DataPath.cfg`, + `Bridge.cfg`, `Compiler.cfg`, `Reports.cfg` or `Client.cfg`. The frame is built from an explicit + allowlist in `BridgeRuleset.cs` — `Config.Entries` is never enumerated, because that would sweep in + every key on the server. + +Absent entirely if the shard runs `Bridge.RulesetEnabled=false` or an older plugin. Render from +`GET /ruleset` (§6) on connect, then keep live with this event. + +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. + --- ## 5. REST — read queries @@ -658,6 +724,22 @@ GET /houses Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the `house.*` stream (§4). Ordered by name. Survives a sidecar restart. +### Shard ruleset (Protocol 3.0) + +``` +GET /ruleset +→ { "ruleset": {"kind":"world.ruleset","rev":"1a2b3c4d","shard":"UOMysticmoon", + "expansion":"EJ","systems":{...},"caps":{...},"accounts":{...}, ... } } +``` + +The shard's published ruleset (§4 for the full frame and its two gotchas). Served from the sidecar's +store, so it **answers while the shard is down** — a rules page that goes blank during a restart is +worse than one that is briefly stale. Keep it current with the `world.ruleset` stream. + +`{"ruleset": null}` means the shard has never published one — an older plugin, or +`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. + --- ## 7. Status codes diff --git a/link/PLAN.md b/link/PLAN.md index fff21e0..cb36025 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -315,6 +315,14 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. 8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar. +**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, +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), +`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 +edits a file. + ### Config keys (`Config/Bridge.cfg`) ```ini @@ -328,6 +336,11 @@ EconomySweepSeconds=300 Read in `Configure()` via `Config.Get("Bridge.", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`. +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 +`RulesetEnabled` / `PublicConnectAddress` / `RulesetIncludeSchedule`). **`servuo-plugins/overlay/Config/Bridge.cfg` +is the authoritative, commented list**; `BridgeConfig.cs` holds the defaults. + --- ## 11. Phase 1 acceptance diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index bc0dec7..cd9944c 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -285,6 +285,18 @@ City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`, ### 10.4 Factions / Vice vs Virtue +> **Status update (Protocol 3.0, 2026-07-28).** +> +> - **The deferred question is answered.** This shard runs **Vice vs Virtue** (`VvV.cfg Enabled=True`); +> old Factions is off, and in stock ServUO that is not a coincidence — +> `Services/Factions/Core/Faction.cs` sets `Settings.Enabled = !ViceVsVirtueSystem.Enabled`, so the +> two are mutually exclusive by construction. The `vvv.standings` / `vvv.battle` streams below are +> therefore unblocked, but are **not** scoped for 3.0 (see [`v3.md`](v3.md) §2 row 5). +> - **`world.systems` is superseded by `world.ruleset`** ([`v3.md`](v3.md) §5), which shipped in 3.0. +> It was never implemented under this name. `world.ruleset` carries the same +> `systems{cityLoyalty, vvv, factions, …}` sub-object this section asked for, plus the rest of the +> shard's published ruleset, so no orphan kind is left behind. Do not implement `world.systems`. + **Which system is live is a shard decision — verify before building.** Two exist: - **Old Factions** (`Scripts/Services/Factions`): `Faction.Commander` (leader, `Faction.cs:160`), `Faction.Election`, `Faction.Members` (`List`), and faction-controlled **Towns** (`Town.cs` — each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, **off**. @@ -300,7 +312,10 @@ City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`, {"kind":"vvv.standings","order":142000,"chaos":138500,"leaderSide":"Order"} ``` -> Start by detecting which system is enabled at boot and streaming only that one; emit a one-time `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing. +> Start by detecting which system is enabled at boot and streaming only that one. ~~emit a one-time +> `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels +> instead of guessing.~~ — **superseded: `world.ruleset` already carries that `systems` block** (see the +> status note at the top of this section). ## 11. Further integration points — a menu to pick from diff --git a/link/v3.md b/link/v3.md index e969e84..17e99c8 100644 --- a/link/v3.md +++ b/link/v3.md @@ -1,10 +1,23 @@ # Protocol 3.0 — Shard content, standings & the visibility framework -**Status:** Planned, approved 2026-07-28. Not yet built. All work lands on an `edge` branch in each repo; `edge` → `main` is the v3 cutover. +**Status:** In progress. All work lands on an `edge` branch in each repo; `edge` → `main` is the v3 cutover. **Date:** 2026-07-28 **Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API). +### Progress + +Each part is marked off here as it lands on `edge`. §9 carries the same state per sequencing row. + +| Order | Part | State | Landed on `edge` | +|---|---|---|---| +| 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** | PR-LINKS-B1 | +| 3 | **C** — spawn atlas (§6) | ⬜ **Next** | — | +| 4 | **B/2** — `points.board` (§7) | ⬜ Not started | — | +| 5 | **B/3** — `vendor.listing` (§8) | ⬜ Not started | — | +| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — | + --- ## 1. Why 3.0 @@ -64,7 +77,12 @@ independently of this work. --- -## 3. Part A — The visibility framework +## 3. Part A — The visibility framework ✅ Done + +*Landed on `edge`: website [#109](https://gitea.whitlocktech.com/RunicGateway/website/pulls/109) (the framework) +and [#110](https://gitea.whitlocktech.com/RunicGateway/website/pulls/110) (the REST-projection gap §3.6.1 +records), docs [#64](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/64) + [#65](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/65). +Smoke-tested across all five rungs per §11.* ### 3.1 The leak this replaces (verified 2026-07-28) @@ -221,7 +239,28 @@ admin-set `uo_link_config.protocol` column — so it happens **exactly once**, a --- -## 5. Part B/1 — `world.ruleset` +## 5. Part B/1 — `world.ruleset` ✅ Done + +*Landed on `edge`. Implementation notes worth keeping:* + +- ***`shadowguard` is derived, not configured.*** `Shadowguard.cfg` carries only `ReadyDuration` and + `RandomizeInstances` — there is no `Enabled` key — so the systems block reports `Core.TOL` + (the expansion gate) instead. Same shape for `factions`: `Factions.cfg` has no `Enabled` either, and + `Services/Factions/Core/Faction.cs` sets `Settings.Enabled = !ViceVsVirtueSystem.Enabled`, so the + frame reads that static rather than inventing a key. **Where a system's on/off state is derived, read + the system's own static; only read `Config.Get` where the .cfg key IS the truth.** +- **`caps.skill` / `caps.totalSkill` are in tenths** (1000 = 100.0), the way ServUO stores them. + Documented in `INTEGRATION.md` and converted in the client, because the raw number is actively + misleading rather than merely unhelpful. +- **`Config.Get` re-parses when the cached type differs.** `InternalGet` caches the parsed value on + the entry and re-parses if `entry.Object is T` fails, so reading `PlayerCaps.SkillCap` as an `int` + where ServUO reads it as a `double` is correct (both parse) — it just re-parses. Harmless, but worth + knowing before assuming a shared cache. +- **The plugin CAN be compile-verified**, contrary to "no standalone build": point Roslyn + (`dotnet sdk/*/Roslyn/bincore/csc.dll`, `/langversion:7.3`, net48 reference assemblies) at the whole + ServUO `Scripts` tree with `overlay/Scripts/Custom/Bridge/*.cs` substituted for the deployed copy, + excluding `Scripts/obj` and `Scripts/bin`. 6,205 files, ~40 s, and it catches every signature error + a boot would. Worth doing before every plugin PR. `PROTOCOL_2.md` §10.4 sketches a `world.systems` capability frame that was never implemented (`grep` returns nothing across all four repos). **`world.ruleset` subsumes it**, carrying a `systems` @@ -534,14 +573,14 @@ inherently up to one full cycle old, and the UI must say so. ## 9. Sequencing -| Order | Part | Repos touched | Wire change | -|---|---|---|---| -| 1 | **A** — visibility framework + actor-leak fix | website, docs | none | -| 2 | **B/1** — `world.ruleset` (§5) | all four | new kind | -| 3 | **C** — spawn atlas (§6) | website, docs | none | -| 4 | **B/2** — `points.board` (§7) | all four | new kind + `char.profile` field | -| 5 | **B/3** — `vendor.listing` (§8) | all four | new kinds | -| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | +| Order | Part | Repos touched | Wire change | State | +|---|---|---|---|---| +| 1 | **A** — visibility framework + actor-leak fix | website, docs | none | ✅ Done | +| 2 | **B/1** — `world.ruleset` (§5) | all four | new kind | ✅ Done | +| 3 | **C** — spawn atlas (§6) | website, docs | none | ⬜ **Next** | +| 4 | **B/2** — `points.board` (§7) | all four | new kind + `char.profile` field | ⬜ | +| 5 | **B/3** — `vendor.listing` (§8) | all four | new kinds | ⬜ | +| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | ⬜ | --- diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index fb43fb9..7b4c16c 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -99,7 +99,7 @@ server/ pages.router.js (2) /public/pages — the draft-preview route precedes /:slug and is deliberately not site-mode gated - shard.router.js (13) /public/shard/* incl. the anonymous + shard.router.js (14) /public/shard/* incl. the anonymous SSE stream; never site-mode gated site.router.js (4) /settings /status /version /contact — the group-root singletons; declares no @@ -358,6 +358,23 @@ analogue to a password — and there is no hash-lookup constraint (verification unused rows and `bcrypt.compare`s each, like password verification). `used_at` is the single-use marker. Cleared wholesale on TOTP disable / password change / password reset. +### shard_ruleset — the shard's published ruleset (Protocol 3.0) + +Singleton row (`id = 1`, CHECK-constrained) holding the latest `world.ruleset` frame: `rev`, +`expansion`, `payload` JSON (the whole frame), `t`, `updated_at`. The shard re-emits the complete +ruleset on every sidecar connect, so this is an **overwrite, not an append** — and the kind is +deliberately **not** in `LOGGED_KINDS`, since logging it would put a duplicate row in `shard_events` +on every reconnect while `server.hello` already marks each of those. + +The frame is stored whole rather than normalized into columns: it is a flat description of server +config that is read as one page, so splitting it up would mean a schema change every time the shard +grows a new block. `rev` (the shard's FNV-1a of the body) and `expansion` are hoisted only because +they are cheap to display — the same payload-plus-hoisted-columns shape `shard_champs` uses. + +**No row means the shard has never published one** (an older plugin, or `Bridge.RulesetEnabled=false`), +served as `null` rather than `{}`: "not published yet" and "published, everything off" are different +answers and the page renders them differently. + ### 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), @@ -569,6 +586,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate. | GET | `/wiki` | list of pages (slug + title) | | GET | `/wiki/:slug` | single page | | 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/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. | Public content GETs pass through the **siteMode** gate (§5). From 09467c67b00261db6d6e454b333eb2bf00aaf206 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 14:41:54 -0500 Subject: [PATCH 2/2] docs(link): link the world.ruleset PRs from the v3 progress table Co-Authored-By: Claude --- link/v3.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/link/v3.md b/link/v3.md index 17e99c8..6e3b916 100644 --- a/link/v3.md +++ b/link/v3.md @@ -12,7 +12,7 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p | Order | Part | State | Landed on `edge` | |---|---|---|---| | 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** | PR-LINKS-B1 | +| 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) | ⬜ **Next** | — | | 4 | **B/2** — `points.board` (§7) | ⬜ Not started | — | | 5 | **B/3** — `vendor.listing` (§8) | ⬜ Not started | — | @@ -241,7 +241,7 @@ admin-set `uo_link_config.protocol` column — so it happens **exactly once**, a ## 5. Part B/1 — `world.ruleset` ✅ Done -*Landed on `edge`. Implementation notes worth keeping:* +*Landed on `edge`: 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). Implementation notes worth keeping:* - ***`shadowguard` is derived, not configured.*** `Shadowguard.cfg` carries only `ReadyDuration` and `RandomizeInstances` — there is no `Enabled` key — so the systems block reports `Core.TOL`