docs!: Protocol 3.0 cutover — the 3.0 documentation set #73
@@ -28,6 +28,7 @@ ci/ cross-cutting CI/quality notes
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar |
|
| [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar |
|
||||||
| [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design |
|
| [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design |
|
||||||
|
| [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework |
|
||||||
| [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) |
|
| [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) |
|
||||||
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
|
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
|
||||||
| [PLAN.md](link/PLAN.md) | uo-link build plan |
|
| [PLAN.md](link/PLAN.md) | uo-link build plan |
|
||||||
|
|||||||
577
link/v3.md
Normal file
577
link/v3.md
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
# 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.
|
||||||
|
**Date:** 2026-07-28
|
||||||
|
**Codebase:** ServUO 57.4, `<servuo>`, 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why 3.0
|
||||||
|
|
||||||
|
A survey of the live ServUO tree against everything the bridge already surfaces end-to-end found that
|
||||||
|
**the bridge covers live *activity* well and covers shard *content and standings* almost not at all.**
|
||||||
|
|
||||||
|
Covered by 1.0 + 2.0: presence/online, region transitions, char vitals + profile + roster, house
|
||||||
|
registry + IDOC decay, champion spawns, guild board, city governors + term history, help-page queue,
|
||||||
|
total gold supply, player-vendor sales log, deaths/murders/kills, skill gains, fame/karma, quest
|
||||||
|
completes, staff/cheat audit, account linking + creation, town crier + news.
|
||||||
|
|
||||||
|
Not covered by anything: every leaderboard, every ruleset fact, every "where do I find X", and the
|
||||||
|
entire player economy outside a player's own vendors.
|
||||||
|
|
||||||
|
3.0 has **three scope areas**:
|
||||||
|
|
||||||
|
- **A — The visibility framework (§3).** Admin-configurable, per-feature and per-field audience
|
||||||
|
control over every shard-derived surface on the website. Ships first; the rest depends on it.
|
||||||
|
- **B — Three new wire streams (§5, §7, §8).** `world.ruleset`, `points.board`,
|
||||||
|
`vendor.listing`/`vendor.listing.remove`.
|
||||||
|
- **C — One website-only feature (§6).** The spawn atlas, built from static ServUO data files with no
|
||||||
|
wire involvement at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Survey: the full gap list
|
||||||
|
|
||||||
|
Recorded so the items *not* scoped for 3.0 aren't re-derived later.
|
||||||
|
|
||||||
|
| # | Gap | Source on the shard | Value | Cost | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | **Points/loyalty leaderboards** — 25 point currencies | `Scripts/Services/PointsSystems/PointsSystem.cs` → `static List<PointsSystem> Systems`, each `List<PointsEntry>{Player,Points}` | Very high | Low | **3.0 §7** |
|
||||||
|
| 2 | **Shard ruleset page** | `Config/*.cfg` via `Server.Config.Get` | High | Very low | **3.0 §5** |
|
||||||
|
| 3 | **Shard-wide marketplace** | `PlayerVendor.PlayerVendors` + `VendorSearch.cs` | Very high | High | **3.0 §8** |
|
||||||
|
| 4 | **Spawn atlas / bestiary** | `Spawns/*.xml` (6,455 spawners), `RevampedSpawns/*.xml` (333), `Data/Regions.xml`, `Data/Locations/*.xml`, `Config/ChampionSpawns.xml`, `Data/teleporters.csv`, `Data/HarvestLocs/*` | High | Medium | **3.0 §6** |
|
||||||
|
| 5 | VvV standings + battle status | `Services/ViceVsVirtue/{ViceVsVirtueSystem,GuildStats,VvVBattle}.cs` | High | Medium | `PROTOCOL_2.md` §10.4 deferred this pending "which PvP system does this shard run?" — **now answered: `VvV.cfg Enabled=True`, `Factions.cfg` off.** Unblocked, not scoped here |
|
||||||
|
| 6 | Skill leaderboards + shard census | `Services/Reports/Reports.cs` → `GetSkillDistribution()`, `CompileGeneralStats()`, `StaffHistory` | High | Low | Spec'd `PROTOCOL_2.md` §14 (Part B phase 6), unbuilt. Shares §7's UI — fold in after |
|
||||||
|
| 7 | Custom mounts/pets codex — ~35 across 4 tiers | `Scripts/Custom/{Companions,Legendary,Named,New Legacy}` | Medium-high | Very low | Pure wiki/CMS content, zero bridge work. The shard's most distinctive content, with zero site presence |
|
||||||
|
| 8 | Community Collections progress | `Services/CommunityCollections/CollectionsSystem.cs` | Medium | Low | Natural public "community goal" widget |
|
||||||
|
| 9 | Seasonal/holiday event calendar | `Services/Seasonal Events/SeasonalEventSystem.cs`, Krampus, Forsaken Foes | Medium | Low | "What's live now / what's next" |
|
||||||
|
| 10 | Crafting / taming / harvesting feeds | `EventSink.CraftSuccess` / `TameCreature` / `ResourceHarvestSuccess` | Medium | Low | Spec'd `PROTOCOL_2.md` §11 #3/#4/#5, unbuilt |
|
||||||
|
| 11 | Virtue progression | `EventSink.VirtueLevelChange`, `Services/Ethics/` | Medium | Low | Spec'd §11 #6, unbuilt |
|
||||||
|
| 12 | Bulk Order Deeds + reward tables | `Services/BulkOrders/`, `Data/Bulk Orders/*` | Medium | Low | Feed spec'd §11 #7; the static reward tables are a free wiki page |
|
||||||
|
| 13 | Guild wars | war state on `Guild` | Low-medium | Low | Spec'd §11 #8, unbuilt |
|
||||||
|
| 14 | Astronomy discovery log | `Services/Astronomy/AstronomySystem.cs` (104 KB save) | Low | Low | Niche completion leaderboard |
|
||||||
|
| 15 | In-game chat relay | `Services/Chat/`, `Logs/Chat/{General,Help,Trade,LFG}` | Low | Medium | Privacy-sensitive; staff-only at most |
|
||||||
|
| 16 | Shard health telemetry | Crash logs, `LayerConflict.log`, `throttle.log`, `world.save.after` counts, AutoSave/AutoRestart schedule | Low-medium | Low | `world.save.after` is already ingested but never charted — world-size-over-time is nearly free |
|
||||||
|
| 17 | Ultima Store / Sovereigns balance | `Store.cfg Enabled=True, CurrencyName=Sovereigns`; `UltimaStore.GetCurrency` | ? | Medium | Only worth it if sovereigns are actually sold |
|
||||||
|
|
||||||
|
**Excluded permanently** — see [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) Tier H/N: firewall/IP-block,
|
||||||
|
kill/resurrect, jail, item/gold grants, set-access-level, arbitrary `[set`/`[add`.
|
||||||
|
|
||||||
|
**Noticed during the survey, out of scope:** `Scripts/Custom/PerryOwnerFix.cs` hardcodes an
|
||||||
|
`EventSink.Login` hook granting `AccessLevel.Owner` to account `"ShardOnwerPerry"`. Worth reviewing
|
||||||
|
independently of this work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Part A — The visibility framework
|
||||||
|
|
||||||
|
### 3.1 The leak this replaces (verified 2026-07-28)
|
||||||
|
|
||||||
|
`BridgeJson.Actor()` (`BridgeJson.cs:85-117`) writes `serial`, `name`, **`acct`**, **`webId`**,
|
||||||
|
`player`. `shardState.model.js:346 shapeGuild()` returns `r.payload` verbatim, and
|
||||||
|
`GET /api/v1/public/shard/guilds` (anonymous, `shard.controller.js:131`) serves it. **A guild
|
||||||
|
leader's game account name and website user id are readable on an anonymous public endpoint today.**
|
||||||
|
The same path exists for `shapeGovernor` → `/public/shard/governors`. `Actor` also feeds
|
||||||
|
`guild.join`, `city.update` and `region.enter`, all three in `PUBLIC_KINDS` on the anonymous SSE
|
||||||
|
stream.
|
||||||
|
|
||||||
|
The framework below is the vehicle for the fix, and the reason it ships before anything else.
|
||||||
|
|
||||||
|
### 3.2 Where visibility lives
|
||||||
|
|
||||||
|
**On the website, never in the sidecar.** The sidecar's job for 3.0 is unchanged in character: accept
|
||||||
|
frames, persist them to its SQLite store, forward them verbatim over WS, and serve store-backed reads
|
||||||
|
that survive a shard outage. It defines no access parameters, no audiences, no field projection, and
|
||||||
|
advertises no capabilities.
|
||||||
|
|
||||||
|
### 3.3 The audience ladder
|
||||||
|
|
||||||
|
`anonymous → logged_in → player → staff → admin`, each rung implying the ones below it.
|
||||||
|
|
||||||
|
`viewerLevel(req)` resolves: no session ⇒ `anonymous`; authenticated ⇒ `logged_in`; authenticated
|
||||||
|
with a linked shard account ⇒ `player`; moderator/admin role ⇒ `staff`/`admin`. **Staff always
|
||||||
|
satisfy the `player` rung** even without a linked game account, consistent with the existing rule
|
||||||
|
that `/player/*` is role-agnostic self-service.
|
||||||
|
|
||||||
|
### 3.4 Two limits an admin cannot override
|
||||||
|
|
||||||
|
1. **`acct` and `webId` are admin-only, always.** They are not in-game-visible and are not exposed as
|
||||||
|
configurable fields.
|
||||||
|
2. **A kind absent from the kind→feature map is never broadcast below `admin`.** Fail closed. This
|
||||||
|
preserves the property that today's static `PUBLIC_KINDS` allowlist is a security boundary rather
|
||||||
|
than a convenience filter.
|
||||||
|
|
||||||
|
### 3.5 Configuration
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
|
||||||
|
feature VARCHAR(48) NOT NULL PRIMARY KEY,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
|
||||||
|
field_rules JSON NULL, -- {"<field>": "<rung>"} for sensitive fields only
|
||||||
|
updated_by INT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
Seeded on boot in `server.js`, one row per feature. **All ten shard features are covered — the four
|
||||||
|
new ones and the six that already ship — and every default reproduces today's behavior, so the
|
||||||
|
retrofit is a no-op until an admin changes something.**
|
||||||
|
|
||||||
|
| Feature | Default audience | Sensitive fields (default rung) |
|
||||||
|
|---|---|---|
|
||||||
|
| `status`, `activity`, `champs`, `guilds`, `governors` | `anonymous` | guilds/governors: `leaderAcct` / `leaderWebId` → **admin (locked)** |
|
||||||
|
| `houses` | `anonymous` | `owner` → `staff`, `price` → `staff` (matches today's IDOC-only public view) |
|
||||||
|
| `presence` | `anonymous` | `location` → `staff` (matches today's staff-only, location-gated `/online`) |
|
||||||
|
| `ruleset` (new) | `anonymous` | `connect` → `anonymous` |
|
||||||
|
| `atlas` (new) | `anonymous` | — |
|
||||||
|
| `leaderboards` (new) | `anonymous` | `characterName` → `anonymous` |
|
||||||
|
| `market` (new) | `anonymous` | `ownerName` → `anonymous`, `location` → `anonymous` |
|
||||||
|
|
||||||
|
### 3.6 Enforcement — three points, one config
|
||||||
|
|
||||||
|
New `website/server/src/utils/shardVisibility.js`:
|
||||||
|
|
||||||
|
- `LADDER = ['anonymous','logged_in','player','staff','admin']`, `rank()`, `meets(viewer, required)`
|
||||||
|
- `viewerLevel(req)` (§3.3)
|
||||||
|
- `KIND_FEATURE` — every event kind → its feature; unmapped ⇒ admin-only (§3.4)
|
||||||
|
- `getConfig()` — DB-backed, cached ~5 s like `uoLinkClient`'s config cache, busted on admin `PUT`
|
||||||
|
- `requireFeature(name)` — **404 when disabled** (don't leak existence), **403 when enabled but the
|
||||||
|
viewer is below the audience**
|
||||||
|
- `projectFeature(name, payload, viewerLevel)` — strips fields whose rung the viewer doesn't meet;
|
||||||
|
`acct`/`webId` always stripped below `admin`
|
||||||
|
|
||||||
|
Applied at:
|
||||||
|
|
||||||
|
1. **Routes** — `requireFeature(…)` on every `/public/shard/*`, `/public/atlas/*` and the
|
||||||
|
shard-derived player routes; `projectFeature` in the controllers, replacing the ad-hoc
|
||||||
|
`shapeGuild`-returns-payload-verbatim path.
|
||||||
|
2. **SSE** — `shardBroadcast.js` moves from *"one public channel with a static `PUBLIC_KINDS`
|
||||||
|
allowlist plus one admin channel"* to **per-connection filtering**: each subscriber carries its
|
||||||
|
`viewerLevel`; each frame is mapped kind→feature, gated on `enabled && meets(...)`, then passed
|
||||||
|
through `projectFeature` before write. `PUBLIC_KINDS` becomes the seed data for `KIND_FEATURE`
|
||||||
|
rather than a hardcoded gate. **This is the largest single change in Part A and where the security
|
||||||
|
boundary now lives.**
|
||||||
|
3. **Nav** — `GET /api/v1/public/shard/features` returns only the features the calling viewer can
|
||||||
|
see, so the SPA hides nav entries rather than rendering links that 403.
|
||||||
|
|
||||||
|
### 3.7 Admin surface
|
||||||
|
|
||||||
|
`GET` / `PUT /api/v1/admin/shard/visibility` (admin-only). Validate feature names against the known
|
||||||
|
set and rungs against the ladder; reject any attempt to set `acct`/`webId` below `admin`. Writes an
|
||||||
|
`admin.audit`-style row so visibility changes are traceable. New client panel
|
||||||
|
`routes/admin/ShardVisibility.jsx` at `/admin/shard-visibility`, linked from `ShardAdmin.jsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The version bump and the rollout
|
||||||
|
|
||||||
|
`PROTOCOL_VERSION` **2 → 3** in `link/sidecar/src/main.rs:27`. v3 is defined as *"adds
|
||||||
|
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`"*.
|
||||||
|
|
||||||
|
A bump is an operator-visible hard cutover — `web.rs::gate` returns 409 on every protected route on
|
||||||
|
mismatch, `uoLinkSocket.js::handleHello` closes the WS, and the website's declared version is the
|
||||||
|
admin-set `uo_link_config.protocol` column — so it happens **exactly once**, at the end:
|
||||||
|
|
||||||
|
- Cut an **`edge`** branch from `main` in each of `website/`, `link/`, `servuo-plugins/`, `docs/`.
|
||||||
|
- Every phase PRs into `edge`, never `main`. Feature branches are cut from `edge`.
|
||||||
|
- Part A lands first, alone.
|
||||||
|
- When all parts are built and tested, one `edge` → `main` PR per repo, merged together. **That merge
|
||||||
|
is the v3 cutover.**
|
||||||
|
- A schema migration sets `uo_link_config.protocol` (the existing row **and** the column default)
|
||||||
|
from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides.
|
||||||
|
- No feature-negotiation array anywhere — v3 implies all three kinds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Part B/1 — `world.ruleset`
|
||||||
|
|
||||||
|
`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`
|
||||||
|
sub-object with the `cityLoyalty` / `vvv` / `factions` booleans §10.4 asked for. §10.4 is marked
|
||||||
|
superseded; no orphan kind is left behind.
|
||||||
|
|
||||||
|
### 5.1 Plugin
|
||||||
|
|
||||||
|
NEW `servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeRuleset.cs`, modelled on
|
||||||
|
`BridgeBoot.EmitHello` — **not** a sweep. Subscribes `BridgeLink.Connected_Core += Emit` so a sidecar
|
||||||
|
that comes up second still learns the ruleset.
|
||||||
|
|
||||||
|
Built from an **explicit allowlist** of `Server.Config.Get<T>` calls. **Never enumerate
|
||||||
|
`Config.Entries`** (`Server/Config.cs:162`) — it would sweep in secrets. An FNV-1a `rev` over the body
|
||||||
|
makes an unchanged reconnect a site-side no-op (`String.GetHashCode()` is not stable across runs and
|
||||||
|
must not be used).
|
||||||
|
|
||||||
|
`BridgeConfig.cs` + `overlay/Config/Bridge.cfg`: `RulesetEnabled=true`, `PublicConnectAddress=""`,
|
||||||
|
`RulesetIncludeSchedule=true`. `BridgeBoot.cs`: `reload` → re-emit, `status` → rev/bytes. Not wired
|
||||||
|
to `sweepnow`; it isn't a sweep.
|
||||||
|
|
||||||
|
### 5.2 Payload
|
||||||
|
|
||||||
|
Every block optional, omitted when its system is off:
|
||||||
|
|
||||||
|
`shard`, `expansion`, `connect` (only from `PublicConnectAddress`),
|
||||||
|
`systems{cityLoyalty,vvv,factions,siege,chat,store,dailyRares,honesty,shadowguard,treasureMaps,vetRewards,testCenter}`,
|
||||||
|
`caps{skill:1000,totalSkill:7000,stat:225,str/dex/int:125,strMax/dexMax/intMax:150}`,
|
||||||
|
`housing{accountHouseLimit:1}`, `accounts{perIp:3,charSlots:7,autoCreate}`,
|
||||||
|
`vetRewards{enabled,rewardIntervalDays:30}`,
|
||||||
|
`loot{feluccaLuckBonus:1000,feluccaBudgetBonus:100,feluccaMaxProps:11}`,
|
||||||
|
`vendors{restockDelayMinutes,maxSell,economyStockAmount}`,
|
||||||
|
`champions{powerScrolls:6,statScrolls:16,scrollChance,transcendenceChance,rankThresholds}`,
|
||||||
|
`treasureMaps`, `vvv{enabled,startSilver:2000,enhancedRules}`, `store{enabled,currencyName}`,
|
||||||
|
`schedule{autoSaveFrequencyMinutes,autoRestart*}`.
|
||||||
|
|
||||||
|
**Excluded by name — in a code comment and here:** `Server.cfg` (Address/Listen/Port; only
|
||||||
|
`PublicConnectAddress` is published), `Staff.cfg`, `Email.cfg`, `DataPath.cfg`, `Bridge.cfg`,
|
||||||
|
`Compiler.cfg`, `Reports.cfg`, `Client.cfg`.
|
||||||
|
|
||||||
|
### 5.3 Sidecar and website
|
||||||
|
|
||||||
|
Sidecar — `store.rs`: singleton `ruleset(id CHECK(id=1), rev, json, updated_t)` + upsert/get;
|
||||||
|
`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).
|
||||||
|
|
||||||
|
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so follow the
|
||||||
|
`getPresence()` block's explicit form, not the array-only `snapshot()` helper); `shardIngest.js` →
|
||||||
|
`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
|
||||||
|
(`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind
|
||||||
|
`requireFeature('ruleset')`, returning `null` ⇒ "not published yet".
|
||||||
|
|
||||||
|
Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside
|
||||||
|
`/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`.
|
||||||
|
|
||||||
|
### 5.4 Risk
|
||||||
|
|
||||||
|
Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit
|
||||||
|
allowlist, the no-`Config.Entries` rule, the named exclusion list, and a manual eyeball of the emitted
|
||||||
|
frame during verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Part C — Spawn atlas / bestiary (website-only)
|
||||||
|
|
||||||
|
**No plugin, no sidecar, no `Bridge.cfg` knob, no new kinds.** Not part of the v3 wire change.
|
||||||
|
|
||||||
|
**Decision: committed generated artifact + idempotent DB import**, split in two because the build
|
||||||
|
needs the ServUO tree (which the website container does not have) and the import does not. Not
|
||||||
|
runtime import (10.5 MB of XML per boot), not a browser-served blob.
|
||||||
|
|
||||||
|
New in `website/server/`:
|
||||||
|
|
||||||
|
- `src/utils/spawnAtlasParse.js` — **pure functions, no fs**, so they are unit-testable in CI without
|
||||||
|
a ServUO tree: `parseObjects2()`, `parsePoints()`, `parseRegions()`, `parseLocations()`,
|
||||||
|
`resolveRegion()`.
|
||||||
|
- `scripts/buildSpawnAtlas.js` (`--servuo <path> --out db/data/`) and `scripts/importSpawnAtlas.js`
|
||||||
|
(TRUNCATE + batched INSERT in one transaction); `package.json` scripts `atlas:build`, `atlas:import`.
|
||||||
|
- `db/data/spawnAtlas.<facet>.json` ×13 + `spawnAtlas.index.json` (creatures, champions, regions,
|
||||||
|
landmarks, meta with per-source-file hashes).
|
||||||
|
- `src/model/shardAtlas/{shardAtlas.db.js,shardAtlas.model.js}` following the `shardState` split.
|
||||||
|
- `src/router/v1/public/atlas.{router,controller}.js`; `test/spawnAtlas.parse.test.js`.
|
||||||
|
|
||||||
|
Two parsing notes that matter:
|
||||||
|
|
||||||
|
- `<Objects2>` is `Type:MX=n:SB=…` segments joined by `:OBJ=` — verified against `trammel.xml`, where
|
||||||
|
a single point carries six types. Split on `:OBJ=`; the token before the first `:` is the type.
|
||||||
|
- **The high-value transform:** point-in-rect each spawn against the facet's `Regions.xml` rects
|
||||||
|
(highest `priority` wins), falling back to the nearest `Data/Locations` landmark, else
|
||||||
|
`"Wilderness"`. This is what turns *"lizardman at 5411,1234"* into ***"Despise, Felucca"*** and is
|
||||||
|
the entire reason the page is worth building. `Regions.xml` is genuinely nested and needs a ~120-line
|
||||||
|
recursive tokenizer **or** one devDependency (`fast-xml-parser`) — the server has zero XML deps
|
||||||
|
today, so that is an explicit call to make at implementation time. The flat `<Points>` files need
|
||||||
|
only regex/streaming; **do not** put 10.5 MB through a DOM parser.
|
||||||
|
|
||||||
|
Tables: `shard_spawn_creatures` (slug PK, name, total, facets JSON), `shard_spawn_points` (slug,
|
||||||
|
facet, x, y, region, landmark, max_count, tod_*), `shard_regions`, `shard_landmarks`,
|
||||||
|
`shard_champion_spawns`, `shard_atlas_meta`. Plain `INDEX` on name, **not `FULLTEXT`** — ~1,500
|
||||||
|
creature rows makes a `LIKE` scan free, and FULLTEXT brings min-token-length trouble for names like
|
||||||
|
"orc". No FKs, consistent with every existing `shard_*` table.
|
||||||
|
|
||||||
|
Routes at `/api/v1/public/atlas`, **not** under `/shard` — the atlas is static shard *content*, not
|
||||||
|
live shard *state*; it must not look sidecar-dependent, and unlike `/shard/*` it *should* be
|
||||||
|
`siteMode`-gated like `/posts` and `/wiki`. `GET /creatures?q=&facet=`, `/creatures/:slug`,
|
||||||
|
`/regions`, `/landmarks`, `/champions`, `/meta`, all behind `requireFeature('atlas')`. Admin:
|
||||||
|
`GET /admin/shard/atlas/status` (artifact-vs-DB drift) and `POST /admin/shard/atlas/import`. **Build
|
||||||
|
stays CLI-only.**
|
||||||
|
|
||||||
|
Client: `routes/public/Atlas.jsx` (`/site/atlas`) and `AtlasCreature.jsx` (`/site/atlas/:slug`).
|
||||||
|
|
||||||
|
**Payload risk** — a monolithic artifact would be 2–3 MB of committed JSON. Shard per facet and drop
|
||||||
|
every `<Points>` field the site cannot use (`UniqueId`, all trigger/refractory/proximity/sequential
|
||||||
|
fields, sound ids), keeping Name/Map/X/Y/W/H/Range/MaxCount/MinDelay/MaxDelay/TOD*/types — well under
|
||||||
|
1 MB. The artifact never reaches the browser; the browser sees only paginated API responses.
|
||||||
|
|
||||||
|
**Operator re-run story** — spawns changed → `npm run atlas:build -- --servuo <path>` on a machine
|
||||||
|
with the tree → commit the regenerated `db/data/spawnAtlas.*.json` → deploy → `npm run atlas:import`
|
||||||
|
(or `POST /admin/shard/atlas/import`). `shard_atlas_meta.source` holds per-file hashes, so
|
||||||
|
`GET /admin/shard/atlas/status` reports when the DB is behind the artifact. Full detail in
|
||||||
|
`docs/website/SPAWN_ATLAS.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Part B/2 — `points.board`
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### 7.1 Plugin
|
||||||
|
|
||||||
|
NEW `BridgePoints.cs`, copying the `BridgeHousing.cs` diff-sweep shape (`Initialize` →
|
||||||
|
`ServerStarted`, `Connected_Core += OnConnected` clearing `_last` + `Rearm()`, `SweepOnce()`,
|
||||||
|
`Status()`, skip when `!BridgeLink.Connected`, try/catch throughout).
|
||||||
|
|
||||||
|
Which systems: default to `PointsSystem.Systems` filtered to `ShowOnLoyaltyGump == true` — reuse the
|
||||||
|
shard's own "this is player-facing" signal rather than inventing one. `Bridge.cfg PointsSystems=`
|
||||||
|
overrides. Null-guard `PointsSystem.Systems`; it is a mutable static populated by 25 separate
|
||||||
|
subsystem constructors.
|
||||||
|
|
||||||
|
**The perf trap.** `PlayerTable` is a plain `List<PointsEntry>`, and `QueensLoyalty` has `AutoAdd`, so
|
||||||
|
it can hold an entry for every `PlayerMobile` that ever existed. A naive
|
||||||
|
`.OrderByDescending().Take(N)` across 25 systems is 25 full sorts — at 20,000 historical characters,
|
||||||
|
~7.5 M comparisons, tens of ms on the Core thread. `BRIDGE_PLUGIN_PLAN.md` §1 found that nothing
|
||||||
|
except bulk profile generation comes close to a frame budget; this would be the second thing that
|
||||||
|
does.
|
||||||
|
|
||||||
|
**Mitigation — single-pass bounded selection** into a fixed N-element sorted array (N=10): O(n·N) with
|
||||||
|
tiny constants and one allocation. Skip `Player == null || Deleted` and `Points <= 0`. ~500 k cheap
|
||||||
|
iterations at a 300 s interval.
|
||||||
|
|
||||||
|
Diff signature per system: `concat(serial + ":" + (long)points)` over the top N, plus the entry count.
|
||||||
|
**No `points.remove`** — the system set is fixed, the same argument `city.update` already uses.
|
||||||
|
|
||||||
|
### 7.2 Payload — one frame per system
|
||||||
|
|
||||||
|
25 × ~600 B rather than one 12 KB frame, matching `champ.update` / `guild.update`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{"t":…,"kind":"points.board","system":"QueensLoyalty",
|
||||||
|
"nameString":"Queen's Loyalty","nameNumber":1114938,
|
||||||
|
"maxPoints":30000,"showOnGump":true,"players":842,
|
||||||
|
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500}, …]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`nameString` **and** `nameNumber` are both emitted (a `TextDefinition` may be a cliloc), resolved
|
||||||
|
website-side — the contract `titles.reward` already documents at `BridgeProfile.cs:107-110`.
|
||||||
|
|
||||||
|
**Entries are written inline as `{serial, name}` — never via `BridgeJson.Actor`.** Deliberate even
|
||||||
|
though the website can now reveal fields by rung: `acct`/`webId` are not needed here, because the
|
||||||
|
website resolves serial→user from its own `shard_account_links` mirror for staff views. Keep the wire
|
||||||
|
minimal.
|
||||||
|
|
||||||
|
### 7.3 `char.profile` enrichment
|
||||||
|
|
||||||
|
`BridgeProfile.cs` gains `WritePoints(sb, m)` alongside `WriteTitles`:
|
||||||
|
`"points":[{system,nameString,points,maxPoints}]`, omitting systems with no entry or 0 points.
|
||||||
|
|
||||||
|
**Deliberately no `rank`** — computing it means scanning each system's `PlayerTable` once per profile
|
||||||
|
(25 × n), which would dominate the measured 0.069 ms/profile budget. The website derives rank from
|
||||||
|
the board when the character appears in the top N. Gate behind `PointsProfileRank=false` if it is
|
||||||
|
ever wanted.
|
||||||
|
|
||||||
|
### 7.4 Config, sidecar, website
|
||||||
|
|
||||||
|
`Bridge.cfg`: `PointsSweepSeconds=300`, `PointsLeaderboardEnabled=true`, `PointsTopN=10`,
|
||||||
|
`PointsSystems=` (blank ⇒ auto), `PointsProfileEnabled=true`, `PointsProfileRank=false`.
|
||||||
|
`BridgeBoot.cs`: `Rearm()` in `reload`, `SweepOnce()` in `sweepnow`, `Status()` in both.
|
||||||
|
|
||||||
|
Sidecar — `points_boards(system PK, name, json, updated_t)`; `main.rs` arm keyed on `system`;
|
||||||
|
`GET /points` and `GET /points/:system`.
|
||||||
|
|
||||||
|
Website — `shard_points_boards(system PK, name, name_cliloc, max_points, players, show_on_gump,
|
||||||
|
payload JSON, t)`. **The top-N list stays in `payload`** — a fixed-size list read whole, exactly like
|
||||||
|
`shard_governors.candidates`. Do not normalize into a `shard_points_entries` table until a
|
||||||
|
per-character reverse lookup is actually needed. `shardIngest.js` → `upsertPointsBoard`, **not** in
|
||||||
|
`LOGGED_KINDS` (board state, like `guild.update`). `KIND_FEATURE['points.board'] = 'leaderboards'`,
|
||||||
|
with `characterName` as its per-field rule. `GET /public/shard/points` and `/points/:system` behind
|
||||||
|
`requireFeature('leaderboards')`; validate `system` ≤ 48 chars.
|
||||||
|
|
||||||
|
**No new player route** — per-character points ride inside `char.profile`, already served by
|
||||||
|
`GET /player/shard/char/:serial` with its `shardLinks.ownsAccount` check.
|
||||||
|
|
||||||
|
Client — NEW `routes/public/Leaderboards.jsx` at `/site/leaderboards`; a "Loyalty & Points" section
|
||||||
|
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
|
||||||
|
`AdminCharacter.jsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Part B/3 — `vendor.listing`
|
||||||
|
|
||||||
|
### 8.1 It cannot be an RPC, and this is load-bearing
|
||||||
|
|
||||||
|
`rpc.rs::try_route` correlates on the **first** frame carrying a matching `reqId` and resolves a
|
||||||
|
single `oneshot`. A chunked reply sharing one `reqId` would deliver chunk 1 to the HTTP caller and
|
||||||
|
**leak chunks 2..N onto the broadcast feed**. `REPLY_TIMEOUT` is 10 s (the client waits 12 s), so a
|
||||||
|
whole-world snapshot could not fit regardless.
|
||||||
|
|
||||||
|
⇒ **a per-vendor diff sweep on the broadcast stream**, like `champ.update` / `house.update`. The
|
||||||
|
existing per-account `vendor.snapshot` RPC is untouched; the player portal keeps using it.
|
||||||
|
|
||||||
|
Kinds: `vendor.listing` (one frame per vendor, authoritative for that vendor) and
|
||||||
|
`vendor.listing.remove`. Payload: `serial, shopName, owner:{serial,name}, map, x, y, region, house,
|
||||||
|
count, truncated, items:[{serial,itemId,hue,amount,price,name,cliloc,child}]`.
|
||||||
|
|
||||||
|
### 8.2 Two perf traps
|
||||||
|
|
||||||
|
Measured baseline (`BRIDGE_PLUGIN_PLAN.md` §1): 30 vendors / 1,200 listings = 0.343 ms via
|
||||||
|
`pack.Items` + `GetVendorItem`; extrapolated to 500 vendors / 40,000 listings ≈ 12 ms per full pass.
|
||||||
|
Except:
|
||||||
|
|
||||||
|
1. **`VendorSearch.GetItemName(Item)` is a packet builder, not a field read.** It constructs an
|
||||||
|
`ObjectPropertyList`, calls `GetProperties`, serialises, then byte-parses the packet
|
||||||
|
(`VendorSearch.cs:681-789`) — per item. Across 40,000 items in one tick that is a
|
||||||
|
multi-hundred-millisecond stall. **Mandatory: never call it in the sweep.** Emit `itemId`, `hue`,
|
||||||
|
`amount`, `price`, `item.Name` (the plain field, null for most) and `item.LabelNumber`, resolving
|
||||||
|
display names website-side — exactly what `char.profile.equipment` already does
|
||||||
|
(`BridgeProfile.cs:173`).
|
||||||
|
2. **`VendorSearch.GetItems(PlayerVendor)` is private** (`:791`). The reusable public API is
|
||||||
|
`GetItems(Container, List<Item>)` (`:807`), which recurses into sub-containers, so real item counts
|
||||||
|
run above the top-level `pack.Items` the 0.343 ms measurement used. Budget accordingly.
|
||||||
|
|
||||||
|
### 8.3 Mitigations
|
||||||
|
|
||||||
|
- **Amortized round-robin sweep** — `MarketSweepSeconds=60`, at most `MarketSweepBatch=25` vendors per
|
||||||
|
tick, with a persistent cursor over `PlayerVendor.PlayerVendors`. Full coverage in
|
||||||
|
`ceil(vendors/25) × 60 s`, with **per-tick cost bounded independent of world size**. This is the one
|
||||||
|
genuinely new pattern versus the existing sweeps and should be flagged in review.
|
||||||
|
- **Per-vendor signature diff** (`count | Σ(serial ^ price) | x | y | shopName`), as `BridgeHousing`
|
||||||
|
does — most vendors are static, so steady-state emission is near zero.
|
||||||
|
- **`MarketMaxListings=250`**, then `"truncated":true`. `BridgeJson.Parse` caps *inbound* at 1 MB;
|
||||||
|
outbound is uncapped and `shard.rs::read_line` will allocate whatever arrives.
|
||||||
|
- On `Connected_Core`, clear `_last` **and reset the cursor**; the re-emit is self-throttled by the
|
||||||
|
round-robin window.
|
||||||
|
|
||||||
|
### 8.4 Player opt-out and privacy
|
||||||
|
|
||||||
|
**Honour `pv.VendorSearch`** — ServUO's own per-vendor opt-out, which `DoSearch` filters on (`:62`).
|
||||||
|
Skip opted-out vendors entirely; the seen-set removal then drops them from the board, so **a player
|
||||||
|
who hid their vendor in game is hidden on the website too.** Also skip `Map == null || Map.Internal`
|
||||||
|
and `Backpack == null`, matching `DoSearch`.
|
||||||
|
|
||||||
|
A vendor's shop name, owner character name and location are **already globally visible in-game** — the
|
||||||
|
stock Vendor Search gump surfaces exactly this set to any player — which is why they default to
|
||||||
|
`anonymous`. They remain per-field configurable (`ownerName`, `location`) so an admin can tighten
|
||||||
|
them. Account name and website user id never go on the wire.
|
||||||
|
|
||||||
|
### 8.5 Sidecar and website
|
||||||
|
|
||||||
|
Sidecar — one table `vendors(serial PK, shop_name, owner_name, map, x, y, region, count, json,
|
||||||
|
updated_t)` storing the whole-vendor blob. **No `vendor_items` table** — the sidecar's job here is
|
||||||
|
outage resilience (`PROTOCOL_2.md` §12.2), not search; search lives in MariaDB. Endpoint is
|
||||||
|
**`GET /market`**, not `/vendors` — axum would route the latter fine, but the collision with the
|
||||||
|
per-account RPC is a readability trap.
|
||||||
|
|
||||||
|
Website — `shard_vendors` + `shard_vendor_items` (indexes on `vendor_serial`, `price`, `item_id`,
|
||||||
|
`display_name`; delete-then-insert per vendor in one transaction; no FKs). `shardIngest.js` handles
|
||||||
|
both kinds; **not** in `LOGGED_KINDS`.
|
||||||
|
|
||||||
|
`KIND_FEATURE['vendor.listing'] = 'market'`, but the market feature's **SSE mapping is disabled by
|
||||||
|
default**: a live firehose of full vendor inventories would be the site's single biggest bandwidth
|
||||||
|
consumer, and no page needs it live. The page is a paginated DB query with a staleness stamp; an
|
||||||
|
admin can turn the stream on. `uoLinkSocket` paginates `/market` on reconnect, bounded by
|
||||||
|
`MARKET_SNAPSHOT_MAX = 5000` vendors so a pathological world cannot hang startup.
|
||||||
|
|
||||||
|
`GET /public/shard/market?q=&minPrice=&maxPrice=&itemId=&map=®ion=&sort=&limit=&offset=`
|
||||||
|
(limit 1..100, default 50; `q` ≤ 60 chars; `sort ∈ {price_asc, price_desc, recent}`) and
|
||||||
|
`/market/vendors/:serial`, behind `requireFeature('market')`. **Rate-limit it** — this is the first
|
||||||
|
genuinely expensive public endpoint; `express-rate-limit` is already a dependency.
|
||||||
|
|
||||||
|
### 8.6 The open dependency — cliloc names
|
||||||
|
|
||||||
|
`CharacterSheet.jsx:14-15` already documents the gap ("without a cliloc table on the site we can only
|
||||||
|
show literals") and renders equipment as `id {itemId}`. Search-by-name needs that table.
|
||||||
|
|
||||||
|
- **Recommended:** `scripts/buildClilocs.js` reads the UO client's `Cliloc.enu` → committed
|
||||||
|
`db/data/clilocs.json`; ingest denormalizes into `shard_vendor_items.display_name`. Same
|
||||||
|
build-artifact pattern as §6, and it **also fixes the character sheet**.
|
||||||
|
- **Fallback:** ship with item-art + price + region filters, and name search only over renamed items.
|
||||||
|
|
||||||
|
This decision is the reason §8 is sequenced last.
|
||||||
|
|
||||||
|
### 8.7 Client
|
||||||
|
|
||||||
|
`routes/public/Market.jsx` at `/site/market`, with a *"prices last refreshed N minutes ago"* banner
|
||||||
|
driven by `staleAt` (the oldest `shard_vendors.updated_at`). The round-robin sweep means data is
|
||||||
|
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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Documentation obligations
|
||||||
|
|
||||||
|
- This file (`link/v3.md`) is the canonical 3.0 design.
|
||||||
|
- `PROTOCOL_2.md` §10.4 gains a note that `world.systems` is superseded by `world.ruleset`, and that
|
||||||
|
the deferred VvV question is answered (`VvV.cfg Enabled=True`, Factions off).
|
||||||
|
- `INTEGRATION.md` — catalog entries and §6 consumer sections for each new kind, plus the v2→v3
|
||||||
|
upgrade note for operators.
|
||||||
|
- `PLAN.md` — phasing.
|
||||||
|
- `website/BACKEND_DESIGN.md` — every new table and route, and **the visibility framework as a
|
||||||
|
security contract**: the audience ladder, the two locked rules, and the fail-closed kind map belong
|
||||||
|
in the security section.
|
||||||
|
- NEW `website/SHARD_VISIBILITY.md` — admin-facing: what each feature exposes, what each rung means,
|
||||||
|
what cannot be loosened.
|
||||||
|
- NEW `website/SPAWN_ATLAS.md`, NEW `website/MARKETPLACE.md`.
|
||||||
|
- `PROJECT_TREE.md` in each touched repo.
|
||||||
|
- `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed
|
||||||
|
artifacts, and `test/routeManifest.test.js` fails on drift.
|
||||||
|
|
||||||
|
**Follow-up, not scoped for 3.0:** the Android app consumes the same public/player shard API and will
|
||||||
|
need `/public/shard/features` to hide its own nav. Track separately against `android-app/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Verification
|
||||||
|
|
||||||
|
**Plugin** — `servuo-plugins\deploy.ps1 -ServerPath <servuo> -Verify`, inspect the ADD/CHANGE list,
|
||||||
|
then re-run without `-Verify` (ServUO must be stopped). Boot with `tools/stub_sidecar.ps1` listening
|
||||||
|
and **confirm the compile banner in the console, not merely the absence of errors** —
|
||||||
|
`BRIDGE_PLUGIN_PLAN.md` §1 warns that a failing build is silently ignored and the previous
|
||||||
|
`Scripts.dll` reloads. Then `[bridge status`, `[bridge sweepnow`, `[bridge reload`.
|
||||||
|
|
||||||
|
- §5: eyeball the emitted `world.ruleset` frame for anything sourced from `Server.cfg`, `Staff.cfg`,
|
||||||
|
`Email.cfg`, `DataPath.cfg` or `Bridge.cfg`.
|
||||||
|
- §8: with a seeded world, time one sweep tick and confirm the batch cap holds it under ~1 ms.
|
||||||
|
|
||||||
|
**Sidecar** — `cargo build && cargo clippy`; `curl -H "Authorization: Bearer <token>"
|
||||||
|
localhost:8080/ruleset` (and `/points`, `/market`); confirm `X-UOLink-Version: 3` and that a client
|
||||||
|
declaring 2 receives a 409.
|
||||||
|
|
||||||
|
**Website server** — `DB_HOST=127.0.0.1 DB_PORT=59999 node --test`. New tests, each modelled on an
|
||||||
|
existing sibling: `test/shardVisibility.test.js`, `test/shardBroadcast.visibility.test.js`,
|
||||||
|
`test/shardIngest.{ruleset,points,market}.test.js` (after `shardIngest.protocol2.test.js` — stubbed
|
||||||
|
deps, asserting routing and `logged` flags), `test/spawnAtlas.parse.test.js` (pure functions, inline
|
||||||
|
fixtures). Then `npm run routes:manifest` and `npm run swagger`, committing both.
|
||||||
|
|
||||||
|
**Full stack** — against a local MariaDB: apply `db/schema.sql` (idempotent), start the server,
|
||||||
|
confirm `uoLinkSocket` backfill logs the new snapshot lines and that `uo_link_config.protocol`
|
||||||
|
migrated to 3, then load `/site/rules`, `/site/atlas`, `/site/leaderboards`, `/site/market`.
|
||||||
|
|
||||||
|
**Visibility smoke test** — for each of the five rungs, walk every shard page and confirm gating and
|
||||||
|
field projection match the configured matrix. Same shape as the 200-routes × 5-access-levels sweep
|
||||||
|
already run for the domain split.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Critical files
|
||||||
|
|
||||||
|
| File | Why |
|
||||||
|
|---|---|
|
||||||
|
| `website/server/src/utils/shardBroadcast.js` | The security boundary; reworked from a static allowlist to per-connection audience filtering. **The highest-risk file in 3.0.** |
|
||||||
|
| `website/server/src/utils/shardVisibility.js` (new) | Ladder, kind→feature map, projection |
|
||||||
|
| `website/server/src/utils/shardIngest.js` | The dispatcher every new kind routes through |
|
||||||
|
| `website/server/src/model/shardState/shardState.model.js` | The `shape*` projections, including the `shapeGuild` leak §3.1 fixes |
|
||||||
|
| `servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeHousing.cs` | Cleanest copy of the diff-sweep pattern; template for `BridgePoints.cs` and `BridgeMarket.cs` |
|
||||||
|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION` 2→3 and the board-projection match |
|
||||||
|
| `website/server/db/schema.sql` | All new `shard_*` tables plus the `uo_link_config.protocol` migration |
|
||||||
Reference in New Issue
Block a user