- Repoint doc-to-doc references from the old docs/ prefix to the co-located sibling filenames (docs live under link/ here now). - Replace the personal ServUO checkout path (C:\Users\...\servuo) with a <servuo> placeholder throughout.
54 KiB
Protocol 2.0 — Provisioning & World-State Streams
Status: Parts A + B (phases 1–4) built and smoke-tested live on branch feat/protocol2-account-provisioning (2026-07-17) — booted ServUO + the real sidecar and exercised every endpoint (see §15). Part B phase 5 (Factions/VvV) deferred by owner decision.
Date: 2026-07-17
Codebase: ServUO 57.4, <servuo>, net48 / x64, Expansion EJ.
Companion to PLAN.md (read/event plane), ADMIN_CONTROLS.md (staff write plane), and INTEGRATION.md (website API).
Protocol 1.0 shipped the read/event plane, the request/reply plane, [link account linking, town-crier, the player-vendor-sale core edit, the admin write plane, and the help-page queue.
2.0 has two scope areas:
- A — Account provisioning & unlinking (§1–§9). The website can create accounts, unlink them, and the shard runs in one of three signup modes that decide which side may mint accounts. (1.0 could only link an account that already existed, and a link could never be undone.)
- B — Social & political world-state streams (§10–§11). Guilds, town governors ("mayors"), factions/VvV, and player titles — the standings a website community page wants. §10 specs the requested streams; §11 is a menu of further integration points to pick from.
Part A — Account provisioning & unlinking
1. What exists today, and the gap
| Capability | 1.0 | 2.0 |
|---|---|---|
| Create account in-game (first-login auto-create) | ✔ AccountHandler.cs:281 |
unchanged |
| Link an existing game account to a website user | ✔ [link → link.confirm |
unchanged |
| Create a game account from the website | ✗ | new account.create |
| Unlink a game account from its website user | ✗ | new account.unlink + [unlink |
| Choose which side may create accounts | ✗ (always in-game) | new signup mode |
The linking flow is not changing. [link, the one-time code, link.confirm, and the WebsiteUserId tag all stay exactly as they are (BridgeAccountLink.cs). 2.0 only adds verbs alongside them.
The account-creation facts that shape this
new Account(username, password)self-registers — its constructor callsAccounts.Add(this)(Account.cs:186) andSetPasswordhashes per the shard'sAccountHandler.ProtectPasswords(Account.cs:174). So creating an account from the bridge isnew Account(un, pw)plus the link tag — no extra persistence layer, same as the[linktag reaching disk on the next world save.- ServUO's in-game auto-create is gated on the core config
Accounts.AutoCreateAccounts(defaulttrue, read once inAccountHandler's static init,AccountHandler.cs:29). The bridge cannot intercept that path without a core edit, so the signup mode governs the bridge'saccount.createverb; the in-game side is controlled by pairing it with the matching core config (see §3). - The core
CreateAccountpath (AccountHandler.cs:494) validates the username/password character set (printable ASCII0x20–0x7F, no forbidden chars) and enforcesMaxAccountsPerIP(Accounts.AccountsPerIp, default 1). The website path has noNetState, so the browser IP must be passed through explicitly to enforce that same cap (§3.1); and it must reuse the character-safety validation beforenew Account, or it can mint an account no client can log into (or that corrupts serialization). - There is no
EventSink.AccountCreated. The create path is silent. This is why in-game→website creation sync is an open item, not committed scope (§7).
2. Signup modes — the model
A single shard-wide setting, Bridge.SignupMode, with three values. Default hybrid.
| Mode | account.create from website |
In-game first-login auto-create | Who is the account authority |
|---|---|---|---|
website |
accepted | should be off | the website |
game |
rejected (account.error) |
on | the game server |
hybrid (default) |
accepted | on | either side |
The bridge enforces exactly one half of this: whether it honors account.create. The other half — in-game auto-create — is the core Accounts.AutoCreateAccounts config, which the operator sets to match:
Bridge.SignupMode |
pair with Accounts.AutoCreateAccounts |
|---|---|
website |
false — otherwise any client that types a new name still mints an account, defeating website-only |
game |
true |
hybrid |
true |
On boot the bridge reads Accounts.AutoCreateAccounts and warns if it contradicts the selected mode (e.g. SignupMode=website while auto-create is still on), so a half-configured shard is loud, not silently permissive. The bridge does not try to flip the core setting — it only detects and reports the mismatch, the same defensive posture BridgeConfig.ParseAccessLevel already takes.
Reconciliation in hybrid. Both paths can race for the same username. account.create resolves it the only correct way: Accounts.GetAccount(un) != null → refuse with account.error "account already exists". First writer wins; the loser gets a clean error, never a duplicate.
3. account.create — website-driven provisioning
The website has already authenticated and authorized the user (its own signup form). It hands the shard a username, the password the player chose, and the website user id, and asks for an account that is created and linked in one step — no code exchange, because the website is the authority here (unlike [link, where the game side proves ownership with a code).
Request (website → sidecar → shard)
{ "kind": "account.create", "reqId": "c1", "actor": "whitlocktech",
"account": "bob", "password": "hunter2", "websiteUserId": "9931",
"ip": "203.0.113.7" }
reqId— correlation id, echoed on the reply (as everywhere else).actor— the website user/staff id, for the audit line. Required, non-empty (mirrors the admin plane).account— desired username.password— the game-client password the player chose on the site. Plaintext over the loopback + token socket, the same trust boundary every inbound verb already relies on; the shard hashes it viaSetPasswordimmediately.websiteUserId— the site user to auto-link.ip— the end user's browser IP, so the shard can enforceMaxAccountsPerIPon website signups exactly as it does on in-game first-login. The website reads this from its own request context (remote-addr, or a trustedX-Forwarded-For); the sidecar cannot derive it — the sidecar only sees the website's connection IP, not the browser's, so this must be an explicit field. See §3.1.
Shard behavior (Core thread, in BridgeAccounts.cs)
- Gate.
SignupMode == game→account.error "signups disabled for this mode". Master switchBridge.AccountCreateEnabled(default follows mode) must be on. - Validate
actorpresent (as admin plane does). - Validate username/password with the same character-safety rules as
AccountHandler.CreateAccount(printable ASCII, no leading/trailing space, no trailing dot, no forbidden chars). Enforce length caps from config. - Collision check.
Accounts.GetAccount(account) != null→account.error "account already exists". - IP cap. Parse
ip→IPAddress. IfRequireIpForCreateand it is missing/unparseable/loopback →account.error "client ip required"(fail closed — a missing IP must never silently bypass the cap; loopback is exempt inIPLimiter, so accepting it is a bypass). ThenAccountHandler.CanCreate(ip) == false→account.error "ip account limit reached". This is the same read-side check the in-game path runs atAccountHandler.cs:510. - Create + link atomically.
var a = new Account(account, password); a.LogAccess(ip); a.SetTag("WebsiteUserId", websiteUserId);—LogAccessbumpsAccountHandler.IPTable[ip]and records the IP intoLoginIPs(Account.cs:1251), which is exactly what an in-game first-login does, so the per-IP count is both live-accurate and durable (it rebuilds fromLoginIPs[0]on reboot). TheWebsiteUserIdtag persists toaccounts.xmlon the next world save, identical to the[linkpath. - Reply
account.okand emit an unsolicitedaccount.audit(origin:"web",action:"create") to every dashboard, parallel toadmin.audit.
Reply
{ "kind": "account.ok", "reqId": "c1", "action": "create",
"account": "bob", "websiteUserId": "9931" }
{ "kind": "account.error", "reqId": "c1", "reason": "account already exists" }
3.1 The IP flow — who sees what
browser ──HTTP signup──► website ──POST /accounts/create──► sidecar ──account.create──► shard
(real IP) (sees browser IP) (sees WEBSITE's IP, not browser's) (enforces cap)
The chain hops hosts, so the only party that sees the end user's IP is the website, at the edge. By the time the request reaches the sidecar, the socket's peer address is the website, not the player — which is why ip is a body field, not something the sidecar reads off the connection. The website populates it from its request context (remote-addr, or X-Forwarded-For from a proxy it trusts).
Two consequences to state plainly:
- The IP is only as trustworthy as the website's proxy handling. A compromised or misconfigured website could send a spoofed or wrong IP. That is already inside the 2.0 trust boundary (the website is trusted via loopback + token), but it means the per-IP cap is an honesty control against ordinary multi-account signups, not a hard security boundary against a hostile website.
- IPv4/IPv6 skew. A browser may present IPv6 while the UO client connects over IPv4; the two are different
IPAddresskeys, so a website account and a later in-game account from the "same" person may not share anIPTablebucket. Inherent to keying on raw IP — noted, not solved.
The sidecar itself does not validate or transform ip; it forwards the field and lets the shard (which owns IPTable) decide. If a shard wants the sidecar to reject obviously-bad input early, that is a later refinement, not required for correctness — the shard fails closed regardless.
Sidecar route
POST /accounts/create, body {actor, account, password, websiteUserId, ip} → the inbound line, correlated on a fresh reqId. Status mapping (new respond_account, modeled on respond_admin):
| Reply / reason | HTTP |
|---|---|
account.ok |
200 |
"account already exists" |
409 Conflict |
"ip account limit reached" |
429 Too Many Requests |
"signups disabled…" |
403 |
"client ip required", "invalid username/password", missing field |
400 |
| shard down / timeout | 503 / 504 |
⚠️ The password is a secret in a request body and a shard reply. Keep it off the WebSocket broadcast entirely:
account.audit/account.oknever carry the password, and the console/audit log records onlyaccount+actor. This is the same disciplineBridgeEventsalready applies to the plaintextAccountLoginEventArgs.Passwordit deliberately never forwards (PLAN.md§12).
4. Unlinking
Symmetric with [link: either side can sever the tie. Both paths do the same one thing — remove the WebsiteUserId account tag (acct.RemoveTag("WebsiteUserId")) — and both persist on the next world save.
4.1 Website → account.unlink
{ "kind": "account.unlink", "reqId": "u1", "actor": "whitlocktech", "account": "bob" }
- Resolve by
account(username) orserial(a player mobile's account), reusingBridgeAdmin.ResolveTargetAccount. - Not linked →
account.error "not linked"(a no-op is reported honestly, not faked as success). - Apply the Owner floor (
BridgeAdmin.Protected): refuse to unlink an account at/aboveAdminAccessFloor, same defense-in-depth as the admin verbs. - Reply
account.ok action:"unlink"; emitaccount.audit action:"unlink".
Sidecar: DELETE /link/{account} (the existing /link/:account GET already looks a link up; this adds the delete verb next to it) → also clears the sidecar's mirrored link row (store.record_unlink), so event attribution stops immediately without waiting on the shard.
4.2 In-game → [unlink
CommandSystem.Register("unlink", AccessLevel.Player, …) in BridgeAccountLink.cs, next to [link:
- Reads the caller's own account, clears the tag, emits
account.unlinked(so the site learns of a player-initiated unlink and can reconcile its own record). - Player-scoped: a player can only unlink their own account (no target argument), so it needs no floor.
- Symmetric UX with
[link:"Your account is no longer linked."
Note —
[linkbehavior is unchanged.[linkstill refuses when a tag already exists (BridgeAccountLink.cs:96).[unlinkis what clears it; after unlinking,[linkworks again. That is the whole interaction, and it needs no change to the existing link code — only the new command beside it.
5. Trust & attribution
Identical model to the admin write plane (ADMIN_CONTROLS.md §5), because these are the same shape of action (website-authorized, applied on the loopback socket):
- Authorization lives on the website.
account.create/unlinkare gated behind the site's own roles (self-service signup for create; admin/self for unlink). The shard trusts the loopback + token socket and the requiredactorfield. - Owner floor applies to
account.unlink(never unlink a protected staff account from the web). - Attribution is the
web:<actor>string in the console line and theaccount.auditframe; the website keeps its own durable record, as it already does foradmin.audit. account.createnow enforcesMaxAccountsPerIPusing the browser IP the website forwards (§3.1), via the sameCanCreate/LogAccesspath as in-game first-login. But the cap is only as honest as the website's IP reporting, and it fails closed on a missing/loopback IP whenRequireIpForCreateis on. Higher-order abuse control (captcha, email verification, per-account-per-day) remains the website's job — the shard cap is a floor, not the whole defense.
6. Config keys (Config/Bridge.cfg)
SignupMode=hybrid # website | game | hybrid (default hybrid)
AccountCreateEnabled=true # master switch for account.create; auto-off when SignupMode=game
RequireIpForCreate=true # fail closed if account.create omits a usable browser IP
AccountNameMaxLength=16
AccountPasswordMaxLength=30
Read in BridgeConfig.Load(), re-readable via [bridge reload. SignupMode parses like AdminAccessFloor — unrecognized value falls back to the safest option (game, i.e. no website creation) with a console warning, so a typo can never accidentally open provisioning. RequireIpForCreate defaults on: the per-IP cap only means something if a missing IP is refused rather than waved through. Turn it off only for a deployment that deliberately does not cap website signups by IP (and then MaxAccountsPerIP still applies in-game as before).
7. Open item (not committed) — in-game → website creation sync
Per the 2026-07-17 decision, this is not in 2.0's committed scope. When an account is created in-game (first-login auto-create, or staff [AddAccount), the website is not notified today, and 2.0 does not change that. Recorded here so the tradeoff is explicit, not forgotten:
- Why it's hard cleanly: there is no
EventSink.AccountCreated. The only faithful tap is a core edit — anAction<Account>raised in theAccount(string, string)ctor (safe: the load path is a separate ctor,Account.cs:189, so it won't fire during world load), shipped as apatches/diff exactly likePlayerVendorSaleand theCommandLoggingevent. - Why it may not be needed: in
website-mode the website already knows every account (it created them). Sync only matters forhybrid/gamemodes where the website wants a roster of game-born accounts — and even then the sidecar can approximate "new account" from themob.loginacctfield it already receives (first-seen = new), lossy but zero core edits. - If we do it later: it becomes an
account.createdevent stream (origin:"in-game"), the natural mirror of theaccount.audit(origin:"web") thataccount.createemits — the same bidirectional-audit shape §5.5 ofADMIN_CONTROLS.mdestablished. Revisit if a shard chooseshybrid/gameand wants a complete website roster.
8. Where the code goes
| File | Responsibility |
|---|---|
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs |
New. Registers account.create and account.unlink; the create+link, char-safety validation, collision check, Owner floor on unlink, account.audit emission. Mirrors BridgeAdmin.cs structure. |
overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs |
Extend. Add the [unlink player command beside [link. No change to existing link behavior. |
overlay/Scripts/Custom/Bridge/BridgeConfig.cs |
Extend. SignupMode (parsed, safe fallback), AccountCreateEnabled, RequireIpForCreate, name/password length caps; read Accounts.AutoCreateAccounts and warn on mode mismatch. |
overlay/Scripts/Custom/Bridge/BridgeBoot.cs |
Extend. Wire BridgeAccounts.Initialize() into Initialize() (one line, beside the other subsystems). |
overlay/Config/Bridge.cfg + .example |
Extend. The §6 keys, defaults documented. |
sidecar/src/web.rs |
Extend. POST /accounts/create (forwards ip from the body untouched), DELETE /link/:account; respond_account status mapping (409 on collision, 429 on IP cap, 400 on missing IP); scrub password from any logged/broadcast value. |
sidecar/src/store.rs |
Extend. record_unlink (clear the mirrored link row) beside the existing record_link. |
INTEGRATION.md |
Extend. Document POST /accounts/create, DELETE /link/{account}, and the account.audit event. |
| (website, separate repo) | Signup form → POST /accounts/create; unlink control → DELETE /link/{account}; consume account.audit. |
No new core/stock edits in committed scope — account.create/unlink are all script-layer (new Account, SetTag/RemoveTag) called from the new overlay. The only core edit contemplated (the AccountCreated event, §7) is explicitly deferred.
9. Phasing
Config + modes.Done.BridgeConfiggainsSignupMode(parsed, unrecognized →game),AccountCreateEnabled(mode-following default),RequireIpForCreate, name/password caps, and the boot-timeAccounts.AutoCreateAccountsmismatch warning.[bridge statusshowssignup=…(create=…).Done.account.create.BridgeAccounts.cs+POST /accounts/create+respond_account. Gate on mode,actorrequired, char-safety mirrored fromAccountHandler, collision → 409, IP cap viaCanCreate/LogAccess(fail-closed on missing/loopback IP whenRequireIpForCreate), create + link,account.audit, password never logged/echoed. Acceptance below is written for a live run — not yet exercised end-to-end.Unlink — both surfaces.Done.account.unlink+DELETE /link/:account+store.record_unlink, and the in-game[unlink. Owner floor reusesBridgeAdmin.Protected;[unlinkemitsaccount.unlinked.Docs.Done.INTEGRATION.md§2 (protocol bumped to 2), §4 (account.audit/account.unlinked), §6 (POST /accounts/create,DELETE /link/{account}), §7 (409/429).
Build verification (2026-07-17): sidecar cargo check clean; overlay compiled in the full ServUO Scripts tree — 0 errors, 0 warnings. Live end-to-end run still pending (needs a booted shard + sidecar): create+link in website/hybrid, game-mode refusal, duplicate 409, the per-IP cap holding (second create same ip → 429, different IP succeeds, omitted IP → 400 while RequireIpForCreate), LoginIPs[0]/IPTable incremented, and unlink clearing tag+mirror with the Owner floor refusing a protected target.
Deferred (revisit only if a shard needs it): the §7 in-game→website account.created sync; the §12.3 account.setpassword/account.exists siblings; §12.5 credential-verb rate limiting.
Part B — Social & political world-state streams
These are outbound streams (shard → website), the natural extension of PLAN.md's event/sweep plane. None needs a write plane; all reuse the transport, the bounded queue, and the sweep/emit-on-change discipline BridgeSweeps already established. Each entry below states its grounded hook situation so nothing rides an event that doesn't fire.
10. The requested streams
10.1 Guilds
Hook reality (verified):
EventSink.JoinGuildis real — raised atScripts/Misc/Guild.cs:1597when a mobile joins a guild. Usable as a liveguild.join.EventSink.CreateGuildis not a creation notification. It is the load-time deserialization factory: raised only fromServer/World.cs:517while reading the guild index at boot, where the handler's job is to construct the guild instance (Guild.cs:775→new Guild(args.Id)). Player guild creation (new Guild(pm, name, abbrev)atCreate Guild Gump.cs:83,GuildDeed.cs:127) raises no event. Do not useCreateGuildfor "a guild was created" — it would fire once per guild at every boot and never on an actual new guild.- Leave, disband, leader change, alliance change, rename: no events.
Delivery — a guild sweep + diff, exactly like house decay (PLAN.md §5.4). BaseGuild.List is a Dictionary<int, BaseGuild> (Server/Guild.cs:54) — the whole registry, enumerable on the Core thread. Hold a Dictionary<int, GuildSnapshot> (name, abbreviation, leader serial, member count, alliance name, member-serial set hash). On each sweep, diff:
- id present now, absent before →
guild.created - id absent now, present before →
guild.disbanded - leader / alliance / name / abbreviation changed →
guild.updated - member set grew/shrank →
guild.join/guild.leave(the sweep is the reliable source for leaves;EventSink.JoinGuildcan also emit an immediateguild.joinfor joins, with the sweep as the backstop)
Take a silent baseline on ServerStarted (populate without emitting), same as decay, or every guild re-announces on every boot. Cost is trivial — a shard has tens to low-hundreds of guilds, and reading Members.Count + Leader is a handful of field reads each.
{"kind":"guild.created","id":1234,"name":"The Silver Hand","abbr":"TSH",
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"},"members":14,"alliance":null}
{"kind":"guild.leave","id":1234,"who":{"serial":"0x77","name":"Bran"},"members":13}
{"kind":"guild.disbanded","id":1234,"name":"The Silver Hand"}
If real-time (not next-sweep) leave/disband ever matters, the clean tap is a one-line
patches/hook inScripts/Misc/Guild.csRemoveMember/OnDelete— the Phase-7patches/precedent. Start with the sweep; add the patch only if latency is a real complaint. Guild membership does not move fast enough to justify it up front.
10.2 Town governors ("mayors")
In modern ServUO the "mayor of a town" is the Governor in the City Loyalty System (King Blackthorn's governance). Each City (enum, CityLoyaltySystem.cs:15) has a CityLoyaltySystem instance carrying Governor (Mobile), GovernorElect, an Election, a Citizens count, and a herald. The Governor setter already broadcasts a herald message on change (CityLoyaltySystem.cs:193), confirming a governor transition is a first-class in-game event — there just isn't an EventSink for it.
Delivery — a city sweep, emit-on-change. CityLoyaltySystem.Cities (static List<CityLoyaltySystem>, CityLoyaltySystem.cs:680) is the full set, one per city. Sweep, hold Dictionary<City, governorSerial>, emit on transition. Governors change on the order of weeks — a slow sweep (e.g. 5 min, or fold into the economy sweep cadence) is ample. Also emit election open/close and, optionally, the standing.
{"kind":"city.governor","city":"Britain","from":{"serial":"0x55","name":"Old Mayor"},
"to":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"}}
{"kind":"city.election","city":"Moonglow","phase":"nominate","candidates":3,"endsAt":"2026-07-24T…"}
Gate on
CityLoyaltySystem.Enabled(CityLoyalty.Enabled, default true). If a shard runs its own custom town-ownership system instead, this sweep should no-op — detect and log, don't assume.
10.3 Player titles
There is no title-change event. Titles are read-model state, best delivered two ways, not as a stream:
- Enrich
char.profile(BridgeProfile) with atitlesblock. Sources on aPlayerMobile: the reward-title listm_RewardTitles(List<object>) + the selected indexm_SelectedTitle(PlayerMobile.cs:4194,4595), the champion titlem_CurrentChampTitle, plus the computed titles fromTitles.ComputeTitle/ComputeFameTitle/GetSkillTitle/ veteran titles (Scripts/Misc/Titles.cs).char.profilealready carries all-skills, so titles slot in beside it at near-zero extra cost, and it is a read — no hook needed. - Optional
title.changeonly if the community page wants a live "so-and-so is now Grandmaster Blacksmith" feed — and then it comes from a profile-diff in the sidecar, not a shard event (the shard has nothing to subscribe to). Recommend starting with profile enrichment; add the diff feed only if there is demand.
City titles and faction/VvV merchant titles (CityLoyaltySystem.ApplyCityTitle, MerchantTitles.cs) fold into the same titles block.
10.4 Factions / Vice vs Virtue
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<PlayerState>), and faction-controlled Towns (Town.cs— each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, off. - Vice vs Virtue (
Scripts/Services/ViceVsVirtue): the modern replacement.ViceVsVirtueSystem.Enabled(VvV.Enabled, default true), a singletonInstance, an activeBattle, and per-playerVvVPlayerEntry(score, kills, assists). City control in VvV rides the same city-loyalty/governor rails as §10.2.
Delivery — a sweep, gated on whichever is enabled. Neither system raises membership/leadership EventSinks, so it is the same sweep+diff pattern:
- VvV (recommended default): standings per side, active-battle status (
Battle.OnGoing, current city), and the topVvVPlayerEntryscores → avvv.standingssnapshot on change + avvv.battleopen/close event. - Old Factions (only if a shard runs it):
faction.control(town → owning faction on change),faction.commander(leader change from theElection).
{"kind":"vvv.battle","phase":"start","city":"Britain","map":"Felucca","endsAt":"2026-07-17T…"}
{"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.systemsframe (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing.
11. Further integration points — a menu to pick from
Everything below is grounded in a hook or a cheap sweep in this server. Ranked roughly by value-to-effort. Pick the ones you want and I'll fold them into the phasing. (✔ = a real EventSink exists; ⟳ = sweep/diff; ⚑ = needs a small patches/ core tap.)
| # | Stream | Source | Effort | Why it's worth it |
|---|---|---|---|---|
| 1 | Who's-online / population | ⟳ online sweep over NetState.Instances |
low | A live "N players online", per-facet population, and a history series. The single most-asked-for website widget. |
| 2 | Region presence | ✔ EventSink.OnEnterRegion (Region.cs:1160, player-filtered) |
low | Cheap location stream → town population heatmap, "who's in Despise" — PLAN.md §5.6 already flags it as the right answer over Movement. |
| 3 | Crafting feed | ✔ EventSink.CraftSuccess |
low | Who crafted what, exceptional/runic — a crafting economy + "notable crafts" feed. |
| 4 | Taming feed | ✔ EventSink.TameCreature |
low | New tames, esp. rares/greaters — high community interest. |
| 5 | Resource harvesting | ✔ EventSink.ResourceHarvestSuccess |
low-med | Mining/lumber/fishing volume → the raw-material side of the economy (pairs with the vendor/gold streams already shipped). |
| 6 | Virtue progression | ✔ EventSink.VirtueLevelChange |
low | Knight/Seeker/etc. virtue ranks — a progression badge system. |
| 7 | Bulk Order Deeds | ✔ EventSink.BODOffered / BODUsed |
low | BOD turn-ins and rewards — a crafting-endgame feed and reward-title source. |
| 8 | Guild wars | ⟳ from the §10.1 guild sweep (war state on Guild) |
low | Declared/active/ended wars between guilds — a PvP politics board, nearly free once guilds sweep. |
| 9 | Player housing registry | ⟳ extend the existing decay sweep to a full house list | med | Owner → houses map, "houses for sale" (via vendor data already streamed), a housing map. Reuses PLAN.md §5.4 machinery. |
| 10 | Peerless / boss / rare drops | ⚑ virtual-override or drop-system tap (no EventSink) |
med | An "epic loot" feed. Honest cost: no clean event (same gap as per-hit damage, PLAN.md §5.9) — needs a targeted patches/ hook, so it is a deliberate pick, not a freebie. |
| 11 | Secure player trades | ⚑ SecureTrade completion has no EventSink |
med | Player-to-player item/gold transfers → economy + fraud signal, complements the vendor-sale core edit. Needs a core tap. |
| 12 | Champion spawn board | already shipped (BridgeChamps) — extend, don't rebuild |
— | Champs are done in 1.0. Listed so it is not re-proposed; any gap is an extension of the existing sweep. |
Selected for Part B (owner pick, 2026-07-17): guilds (§10.1) + governors (§10.2) + who's-online (#1) + region presence (#2) + housing registry (#9) + titles (§10.3, free as profile enrichment). All reuse the sweep pattern and need no core edit; together they give a website its "living world" page — population, guild politics, town leadership, and a housing map. Factions/VvV (§10.4) is deferred until you confirm which system your shard runs. The phasing is §13.
Where the Part B code goes
| File | Responsibility |
|---|---|
overlay/Scripts/Custom/Bridge/BridgeSocial.cs |
New. The guild sweep+diff and the EventSink.JoinGuild subscription → guild.*. |
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs |
New. The city sweep → city.governor/city.election; the VvV/faction standings sweep (gated on enabled) → vvv.* / faction.*; the one-time world.systems frame. |
overlay/Scripts/Custom/Bridge/BridgeProfile.cs |
Extend. Add the titles block to char.profile. |
overlay/Scripts/Custom/Bridge/BridgeSweeps.cs |
Extend / mirror. New sweep timers (guild, city, presence), re-armable via [bridge reload, one-shot via [bridge sweepnow, counters in [bridge status — same shape as the existing sweeps. |
overlay/Config/Bridge.cfg |
Extend. GuildSweepSeconds, CitySweepSeconds, PresenceSweepSeconds (+ enable flags). |
sidecar/src/store.rs + web.rs |
Extend. Persist the snapshots that back boards (guild roster, governors, population history); GET /guilds, /governors, /online served from the store so they survive a shard outage, exactly like /champs and /economy do today. |
INTEGRATION.md |
Extend. New event catalog entries + the read endpoints. |
12. Cross-cutting additions (recommended)
Five things that are not new streams but make 2.0 correct and complete. The first two I consider essential; the rest are high-value companions to what's already specced.
12.1 Bump the protocol version to 2 — essential
The sidecar is PROTOCOL_VERSION = 1 (sidecar/src/main.rs:23), and every response carries X-UOLink-Version; the gate 409s a client that declares a different one (web.rs:146). 2.0 adds inbound verbs (account.create, account.unlink, …) and event kinds, so it must bump to 2.
The compatibility rule to write down: new outbound event kinds are additive — a 1.x website ignores unknown kinds and keeps working, so the live feed stays backward-compatible. What is not compatible is a client that calls a new inbound verb against an old sidecar, or a new sidecar that a strict old client rejects on the version header. So: bump to 2, keep the feed additive, and document that the new verbs/endpoints require a v2 sidecar while the event feed degrades gracefully.
12.2 Every diff stream needs a REST snapshot companion — essential
The Part B streams are diff-based: guild.created/disbanded, city.governor, housing changes emit only on transition (like house decay). That means a website that connects fresh — or a sidecar that restarts — has seen no deltas yet and therefore has no current state. The live feed alone can never answer "what are the guilds right now."
So every board-backed stream ships with a REST snapshot served from the sidecar's store, exactly as /champs and /economy already are (web.rs): GET /guilds, /governors, /online, /houses. The shard emits deltas; the sidecar persists the latest snapshot; the website hydrates from REST on load and then live-updates from the feed. This is the single most important robustness rule for Part B — without it, a sidecar restart silently blanks the community page until the next guild happens to change.
Concretely: the sidecar keeps a
guilds/governors/populationtable updated from the stream (upsert on each delta, plus a periodic full snapshot the shard can push), and the REST route reads that table. The shard should also support an on-demand full re-emit (asnapshot.requestinbound, or just re-run the sweep with baseline suppression off) so a sidecar that lost its store can rebuild.
12.3 Round out the provisioning surface — password reset, existence check
account.create sets the account's initial password (§3) — that part is done. What it does not cover is the rest of the credential lifecycle. Two small siblings close it, both trivially grounded:
account.setpassword— the later password change/reset for an account that already exists (a player who forgot theirs), distinct from the initial passwordaccount.createsets.acct.SetPassword(newpw)(Account.cs:676) is public; the verb takes{actor, account, password}, applies the Owner floor, emitsaccount.audit action:"setpassword", and — like create — never echoes the password.POST /accounts/{account}/password. Only worth building if the site will offer a "forgot password" flow.account.exists— the signup form wants to say "that name is taken" before submit. A read:Accounts.GetAccount(un) != null.GET /accounts/{account}→{exists: true|false, linked: bool}. Cheap, and it prevents the worse UX of finding out via a 409 on submit.
Both reuse the account.* machinery from Part A verbatim. account.setpassword is the higher-value of the two.
12.4 Mirror hygiene — deletion & link teardown
If the website mirrors rosters/links (it does — store.record_link), it must learn when the game side removes things, or the mirror rots:
- Character deletion.
EventSink.DeleteRequest(EventSink.cs:1754) fires when a player deletes a character at the select screen. Emitchar.deletedso the website drops it from any roster it caches. (PLAN.md§5.1 already lists this hook as a roster-honesty signal — 2.0 is where it earns its place, now that the website keeps rosters.) - Account deletion.
Account.Delete()exists (Account.cs:642); an optionalaccount.deleteverb (Owner-floor-guarded,origin:"web"audit) closes the lifecycle. Lower priority — most shards ban rather than delete — but list it so the option is on record. - On any unlink or account delete, the sidecar clears its link mirror (the
record_unlinkalready specced in §4.1), so event attribution stops immediately.
12.5 Rate-limit the credential verbs
account.create and account.setpassword mint/change persistent credentials. The per-IP cap (§3.1) blocks multi-accounting from one IP, but a compromised or buggy website could still hammer distinct IPs. Add a sidecar-side rate limit on the credential verbs — a global create-per-minute ceiling and a per-actor cooldown — mirroring the caps philosophy town-crier and the admin plane already follow (BridgeConfig.TownCrier*, Admin*). Cheap insurance; the shard stays the last line of defense (collision + IP cap), the sidecar is the first.
13. Part B phasing
Guilds + governors.Built (2026-07-17), compiles clean both sides.BridgeSocial.cs(guild sweep +JoinGuild→guild.update/guild.remove/guild.join) andBridgeGovernance.cs(city sweep →city.update, gated onCityLoyaltySystem.Enabled),GuildSweepSeconds(60s) /CitySweepSeconds(300s) config, both wired into[bridge reload|sweepnow|status. Sidecarguilds/governorsboard tables +GET /guilds,/governorsserved from the store (the §12.2 snapshot rule). SharedBridgeJson.Actorwriter (serial/name/acct/webId/player). Deviation from the §10 sketch: the wire uses full-stateguild.update/city.updateupserts (website derives "created"/"governor changed" from the board) rather than discreteguild.created/city.governorevents — this avoids a reconnect re-emit looking like a storm of creations, matching the provenchamp.updatemodel. Live end-to-end run still pending.Presence.Built (2026-07-17), compiles clean both sides.BridgePresence.cs: apresence.onlinesweep (total + per-facet + per-region, emitted on change) and real-timeregion.enter(EventSink.OnEnterRegion, player-filtered).PresenceSweepSeconds(30s), wired into[bridge.GET /onlineserves the latest snapshot from the event store (population series via/history?kind=presence.online). Live run pending.Housing registry.Built (2026-07-17), compiles clean both sides.BridgeHousing.cs: a house sweep overBaseHouse.AllHouses→house.update/house.remove(owner, region, location, decay, co-owners, friends, price), complementing the existinghouse.decaytransition feed.HousingSweepSeconds(300s), wired into[bridge. Sidecarhousesboard +GET /houses. (Stock ServUO has no "for sale" flag, so this is owner→houses;priceis the placement value, not a listing.) Live run pending.Titles.Built (2026-07-17), compiles clean.char.profilegains atitlesblock (selected,fameKarma,skill,reward[]) fromPlayerMobileaccessors — no new stream, folds intoBridgeProfile. Live run pending.- Factions/VvV — deferred (owner decision): only after confirming which system the shard runs; stream just the enabled one.
Cross-cutting, lands with Phase 1: the protocol bump to 2 (§12.1) and the snapshot-companion rule (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the account.* surface.
14. Built-in reports — replace the FTP/HTML path with JSON over the sidecar
ServUO ships a Reports engine (Server.Engines.Reports, Scripts/Services/Reports/) that already compiles exactly the dashboard data a website wants — it just delivers it the way RunUO did in 2004: render static HTML and FTP it to your website. The bridge can tap the compiled data directly and ship JSON, retiring the file/FTP path entirely. No core edit — the compile methods are public static.
14.1 What the engine produces (verified)
Reports.Generate() runs hourly on the Core thread and builds a Snapshot from public static compile methods (Reports.cs):
| Method | Returns | Content |
|---|---|---|
CompileGeneralStats() |
Report |
NPCs, Players, Clients, Accounts, Items |
CompileStatChart() |
Chart |
population over time |
CompileSkillReports() |
PersistableObject[] |
skill distribution — GM count per skill |
CompileFactionReports() |
PersistableObject[] |
faction membership / stats |
Reports.StaffHistory |
StaffHistory |
staff activity per account (StaffInfo/UserInfo hashtables), help-page-queue length over time (QueueStats), page history |
Each Report is structured (Columns + Items), so it serializes to JSON cleanly with the hand-rolled BridgeJson writers — no reflection serializer. The engine also persists an hourly SnapshotHistory series to disk, so a backfill of historical points is available if wanted.
14.2 How it's delivered today (the file path you flagged)
- HTML + FTP.
UpdateOutput(Reports.cs:406, on a ThreadPool thread) runsHtmlRendererinto<BaseDir>/reports/stats/andreports/staff/(Reports.Path, defaultreports), thenUpload()writes anupload.ftpjob to FTP the HTML to a website. Gated onReports.AutoGenerate(default off). - WebStatus. A separate mechanism (
Scripts/Misc/WebStatus.cs): an in-processHttpListeneron:80/status/serving a live status HTML page. DefaultEnabled = false.
Both are the "report goes to a file / gets pushed out-of-band" pattern. The sidecar already replaces the second one (/health + the live feed cover what WebStatus served); §14 replaces the first.
14.3 The tap — a report sweep, JSON out
BridgeReports.cs runs a Core-thread timer (ReportSweepSeconds, e.g. hourly to match stock, or faster) that calls the same public compile methods, serializes the Report/Chart objects to JSON, emits report.*, and hands the sidecar a snapshot to persist and serve over REST:
{"kind":"report.skills","t":1752…,"skills":[
{"skill":"Swordsmanship","gms":42},{"skill":"Magery","gms":88}, …]}
{"kind":"report.general","players":142,"npcs":42826,"clients":150,"accounts":51,"items":206467}
{"kind":"report.staff","window":"7d","staff":[
{"account":"GreyBeard","actions":318}],"pageQueue":[{"t":…,"open":4}, …]}
Served for hydration (the §12.2 snapshot rule): GET /reports/skills, /reports/general, /reports/staff.
Key points, all grounded:
- No core edit, no HTML, no FTP. Calling
Compile*directly skipsHtmlRenderer/Uploadentirely. LeaveReports.AutoGenerateoff (no HTML files written) and run the bridge tap instead. The FTPupload.ftppath andReports.Pathbecome dead weight for a bridge-connected shard. - Threading.
Compile*readWorld.Mobiles/Skills, so they must run on the Core thread — which the bridge's sweep timers already are (PLAN.mdnon-negotiables). Stock only offloaded the HTML rendering (slow string work) to a ThreadPool; the bridge skips that step, so there's nothing to offload. Skill distribution walks all mobiles once — treat it like the profile-bulk warning inPLAN.md§1: run it on a slow cadence (hourly is plenty), never in a fast sweep. - Dedupe against Part B.
report.generaland the population chart overlap with who's-online (§11 #1); faction reports overlap with §10.4. The unique wins here are skill distribution (a GM-per-skill leaderboard available nowhere else in the bridge) and the staff-activity + page-queue-length history (aggregates that complement the per-actionadmin.auditwe already stream). Prioritize those two; treat the rest as "already covered, don't double-emit." - Optional backfill. On first connect the sidecar could ingest the engine's persisted
SnapshotHistory(Reports.StaffHistory/stats history) to seed the historical series instead of starting empty. Nice-to-have, not required.
14.4 Where the code goes
| File | Responsibility |
|---|---|
overlay/Scripts/Custom/Bridge/BridgeReports.cs |
New. Core-thread report sweep calling Reports.Compile* + Reports.StaffHistory; serialize to report.*; re-armable via [bridge reload, one-shot via [bridge sweepnow. |
overlay/Config/Bridge.cfg |
Extend. ReportSweepSeconds (+ enable flag). |
sidecar/src/store.rs + web.rs |
Extend. Persist the report snapshots; GET /reports/{skills,general,staff} served from the store (survives shard outage, like /champs). |
INTEGRATION.md |
Extend. report.* events + endpoints; note they supersede the stock FTP/HTML reports and WebStatus. |
Recommendation: fold this in as Part B, Phase 6 (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all.
15. Smoke test — live run (2026-07-17)
Deployed the overlay to the ServUO checkout, booted the shard and the real sidecar (protocol 2, plugin_connected: true), and exercised every new surface over REST against the live game. All green.
Part A — provisioning (through the real shard):
| Check | Result |
|---|---|
POST /accounts/create (fresh IP) |
200 account.ok, account created + linked |
| duplicate name | 409 account already exists |
| per-IP cap | shard's real AccountsPerIp=3 enforced: 3rd from one IP allowed, 4th → 429 ip account limit reached |
loopback IP with RequireIpForCreate |
400 client ip required (fails closed) |
GET /link/{acct} after create |
200, linked to the website id |
DELETE /link/{acct} |
200 unlink; lookup then 404 |
Part B — world-state boards (through the real shard):
| Endpoint | Result |
|---|---|
GET /houses |
28 houses, full owner/decay/co-owner/built-on data (shard → house.update → board → REST) |
GET /governors |
9 cities, governor: null/electionPhase: none on this unseeded world |
GET /guilds |
[] — no guilds on this world; the sweep ran without error |
GET /online |
count: 0 — headless (no UO client), snapshot emitted and stored |
GET /char/{acct}/0 |
full profile incl. the new titles block |
Not exercised (needs a live UO client, not a headless boot): presence.online with real players, region.enter, real-time guild.join, and char.vitals. And guild.join/guild board content needs a guild to exist. These are inherent to a clientless smoke test — the board plumbing is proven by /houses, which uses the identical path.
One operational note surfaced: the boot-time Dynamic script recompile cannot replace Scripts.dll while the server is running, because the Scripts build tries to copy the locked ServUO.exe and fails (the PLAN.md §3 trap). The fix used here: build Scripts/Scripts.csproj once with the server stopped, then boot — the offline build produces a fresh Scripts.dll the boot then loads. Rely on this, not the in-process rebuild, when deploying new bridge code. The shard's world save was left untouched (hard-kill, no autosave), so the test accounts did not persist.
16. Town Cryer news — website articles into the news gump (Protocol 2.1)
Status: Built and smoke-tested live (2026-07-17). BridgeNews.cs (pure overlay, no stock edit) + POST /news / DELETE /news/{id} + reconnect replay. Verified against a booted shard: news.add (full + title-only) → news.ok, missing title → 400, idempotent replace, news.remove → news.ok, unknown id → news.error, no shard exceptions, and the reconnect replay confirmed (after a shard restart the stored article was re-pushed with announce:false and re-accepted). The gump rendering itself is verified by source inspection (needs a UO client to view).
There are two distinct town-crier surfaces in ServUO, and 2.0 has so far touched only the first:
- The scrolling crier (
GlobalTownCrierEntryList) — the wandering Town Crier NPC that says short announcement lines. Protocol 1.0 phase 6 (BridgeTownCrier.cs,towncrier.add/remove) already drives this. - The Town Cryer News gump (
TownCryerSystem.NewsEntries) — the paged news UI with title + body + image + a "more info" URL per article. Nothing drives this yet. This section adds it.
The ask: a website news article should land as a full article in the news gump (2), and the crier should also say just the title through the existing say feature (1) — so players get the audible "Hear ye!" proclamation while the full write-up lives in the gump.
16.1 The hook (verified in the shard's source)
TownCryerSystem.NewsEntries(TownCryerSystem.cs:40) —public static List<TownCryerNewsEntry>. The setter is private, but the list is public and mutable, so it can be inserted into and removed from directly.TownCryerNewsEntry(TextDefinition title, TextDefinition body, int gumpImage, Type questType, string url)(TownCryerNewsEntry.cs) — public ctor. PassquestType: nullfor website news.- The display gumps already handle string content, so no gump edits are needed:
- List view (
TownCryerGump.cs:97-103):if (entry.Title.Number > 0) AddHtmlLocalized(...) else AddLabelCropped(..., entry.Title). - Detail view (
TownCryerNewsGump.cs:27-42):if (Entry.Body.Number > 0) AddHtmlLocalized(...) else AddHtml(..., Entry.Body.String, ..., true)— a string body renders as HTML (so<CENTER>…</CENTER><BR><BR>…works),AddImage(..., Entry.GumpImage), andInfoUrlbecomes aLaunchBrowserbutton.
- List view (
- Stock news is live on this shard.
TownCryerSystem.Initialize()adds ~18 hardcodeduo.comentries wheneverTownCryerSystem.Enabled(TownCryerSystem.cs:93-120) — not gated byUsePreloadedMessages(that only gates a reload command). So the list is not empty, and our sync must not clobber it (see §16.3).
16.2 Evaluating the pasted guidance
The pasted analysis is substantially correct and useful — it identifies the right hook (NewsEntries), the right constructor, the cliloc-vs-string branching the gump already does, the image/url fields, and the important instinct to keep stock news separate. Two adjustments for this architecture:
- No stock patch is needed. The pasted plan adds
AddNewsEntry/ClearExternalNewsmethods to the stockTownCryerSystem.cs. That file is stock ServUO, so editing it would ship as apatches/diff (likePlayerVendorSale). We can avoid that entirely: becauseNewsEntriesis a public mutable list, the bridge overlay inserts and removes directly —TownCryerSystem.NewsEntries.Insert(0, entry)/.Remove(entry)— and keeps the "which entries are ours" bookkeeping in an overlay-side list, not in a new field on the stock class. This is exactly howBridgeTownCrieralready mutatesGlobalTownCrierEntryListfrom the overlay. Pure overlay, zero stock edits. - Track our entries to keep stock intact. Rather than the pasted
ExternalNewsEntriesfield on the stock class, the overlay holdsList<TownCryerNewsEntry> _ours. On a sync weRemoveour previous entries fromNewsEntriesand insert the new set — the stockuo.comarticles are never touched.MaxNewsEntriesis 100 (TownCryerSystem.cs:26); the overlay caps its own contribution well under that.
Everything else in the pasted note stands, and the "this is one of the easier integrations — you're replacing the content provider" framing is right.
16.3 The two surfaces, tied together
On an inbound article the bridge does two things on the Core thread:
- News gump — build
new TownCryerNewsEntry(new TextDefinition(title), new TextDefinition(body), image, null, url)andInsert(0, …)at the top ofTownCryerSystem.NewsEntries, tracking it in_ours; trim_ourspast the cap by removing the oldest (from both_oursandNewsEntries). - Say the title — reuse the scrolling-crier path (
GlobalTownCrierEntryList, asBridgeTownCrierdoes) to announce a single line, the title only, for a short duration, so the crier proclaims it in-world. On by default; setannounce: falseon an article to suppress it (e.g. a silent correction that should not re-proclaim).
16.4 Protocol
// website → sidecar → shard
{"kind":"news.add","id":"42","title":"Double XP Weekend",
"body":"<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.",
"image":1614,"url":"https://uomysticmoon.com/news/42"}
// announce defaults to true; add "announce":false to suppress the crier proclamation
{"kind":"news.remove","id":"42"}
- Correlated by
id(echoed on the reply), like town-crier. Re-adding anidreplaces the prior entry (find-by-id in_ours, remove, re-insert) — idempotent. titlerequired;body/image/urloptional (a title-only blurb is valid).imagedefaults to a neutral scroll gump id when absent.- Caps (defense in depth, mirroring
TownCrier*): title/body length, max external entries. Repliesnews.ok/news.error. - Sidecar:
POST /news(add/replace),DELETE /news/{id}. Samerespond-style status mapping as town-crier.
16.5 Restart & re-sync (the source-of-truth rule)
NewsEntries is not persisted by ServUO — it is rebuilt at every boot from stock Initialize() plus whatever we have inserted since. So our external articles vanish on a shard restart until re-pushed. The website is the source of truth: the sidecar re-sends the current external news set on every shard (re)connect, the same discipline §12.2 uses for the diff boards. (The sidecar persists the external set in its store so it can replay it without the website being up.)
16.6 Where the code goes
| File | Responsibility |
|---|---|
overlay/Scripts/Custom/Bridge/BridgeNews.cs |
New. news.add / news.remove: insert/remove TownCryerNewsEntry in the public NewsEntries list, track _ours, cap; optional title announcement via GlobalTownCrierEntryList; replies + caps. No stock edit. |
overlay/Config/Bridge.cfg |
Extend. NewsMaxTitleLength, NewsMaxBodyLength, NewsMaxExternal, default announce duration. |
sidecar/src/web.rs + store.rs |
Extend. POST /news, DELETE /news/{id}; persist the external-news set; replay it on shard (re)connect. |
INTEGRATION.md |
Extend. The news.* verbs + endpoints. |
No core or stock ServUO change — the whole integration rides the public TownCryerSystem.NewsEntries list and the existing crier say path.