15 Commits

Author SHA1 Message Date
2b0d6635bb Merge pull request 'docs: move docs to RunicGateway/docs, repoint all references' (#10) from chore/extract-docs into main
All checks were successful
Release sidecar / release (push) Successful in 17s
Reviewed-on: #10
2026-07-18 05:42:04 +00:00
8751151abc docs: move docs to RunicGateway/docs, repoint all references
Extracted docs/ (ADMIN_CONTROLS, INTEGRATION, PLAN, PROTOCOL_2, RESEARCH,
SHARD_PREREQS) into the central RunicGateway/docs repo under link/, with
full commit history preserved via git filter-repo.

The source cites these design docs by section throughout, so every in-repo
reference (C# + Rust comments, Bridge.cfg, and the READMEs) is repointed at
the new docs-repo URL. README references are rendered as markdown links; a
Documentation pointer section is added to the top-level README.

Docs repo: https://gitea.whitlocktech.com/RunicGateway/docs
2026-07-18 00:08:34 -05:00
6542282ffb Merge pull request 'chore(org): retarget release workflow to RunicGateway/link' (#9) from chore/org-rename-runicgateway into main
All checks were successful
Release sidecar / release (push) Successful in 12s
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-18 04:48:10 +00:00
957f5701d4 chore(org): retarget release workflow to RunicGateway/link
Repo was transferred UOM -> RunicGateway. The REPO env var drives both
the git push URL (bump commit + tag) and the release API base, so the
release workflow would otherwise still target the old UOM/link path.
2026-07-17 23:47:25 -05:00
813ff52059 Merge pull request 'fix(ci): rustfmt sidecar to unblock the release workflow' (#8) from fix/sidecar-rustfmt into main
All checks were successful
Release sidecar / release (push) Successful in 9m8s
Reviewed-on: UOM/link#8
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 16:43:07 +00:00
ed8d24bb1d style(sidecar): rustfmt-wrap wide signatures/calls
The release workflow's `cargo fmt --check` gate failed on the Protocol 2.0
additions: upsert_guild/upsert_house signatures and their call sites in
main.rs exceeded rustfmt's default width. Reformatted with `cargo fmt` — no
behavioral change. `cargo fmt --check`, `cargo check`, and `cargo test` all
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:41:37 -05:00
484bc33706 Merge pull request 'Protocol 2.0 + 2.1 — account provisioning, world-state streams, Town Cryer news' (#7) from feat/protocol2-account-provisioning into main
Some checks failed
Release sidecar / release (push) Failing after 2m18s
Reviewed-on: UOM/link#7
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 16:37:27 +00:00
fd9c9fd96a feat(protocol2): Town Cryer news-gump integration (§16, Protocol 2.1)
Website news articles now land in the modern Town Cryer News gump
(TownCryerSystem.NewsEntries), separate from the scrolling-crier lines.

Overlay BridgeNews (new): news.add / news.remove insert/remove a
TownCryerNewsEntry directly in the public NewsEntries list (no stock edit),
tracking our own id->entry map so stock uo.com news is left intact. Title,
HTML body, image, and URL are all supported (the stock gumps already branch on
TextDefinition.Number, so string content renders). On add the article title is
also proclaimed via GlobalTownCrierEntryList (announce defaults on; set
announce:false to suppress). Config caps: NewsMaxTitleLength/BodyLength/
External, NewsAnnounceDurationSec.

Sidecar: POST /news (add/replace, id-correlated), DELETE /news/{id}; news table
stores each article as its news.add command; on shard server.hello the sidecar
replays the stored set with announce:false (the shard rebuilds NewsEntries each
boot and does not persist ours, so the website is the source of truth).

Docs: PROTOCOL_2 §16 (design + verified), INTEGRATION.md /news endpoints.

Verified live: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors); booted shard + sidecar and exercised add/replace/
remove/error paths and the reconnect replay end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:27:13 -05:00
6f76a8d35f docs(protocol2): add Town Cryer news-gump integration design (§16)
Adds §16: sync website news articles into the modern Town Cryer News gump
(TownCryerSystem.NewsEntries), distinct from the Protocol 1.0 scrolling-crier
lines (GlobalTownCrierEntryList). Grounded in the shard's Town Cryer source.

Key findings / decisions:
- NewsEntries is a public mutable List and TownCryerNewsEntry's ctor is public,
  and the display gumps already branch on Title/Body .Number>0 (cliloc) vs
  string (AddLabelCropped / AddHtml with HTML support). So website content
  needs NO gump changes and NO stock patch — the overlay inserts/removes
  directly and tracks its own entries, leaving stock uo.com news intact.
  (Refines the pasted guidance, which proposed adding methods to the stock
  TownCryerSystem.cs = a patch.)
- Ties the two surfaces together: full article -> news gump; crier "says" just
  the title via the existing GlobalTownCrierEntryList path.
- news.add/news.remove verbs (id-correlated, idempotent), POST /news +
  DELETE /news/{id}; website is source of truth, re-synced on shard reconnect
  since NewsEntries isn't persisted across reboot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:15:24 -05:00
92374ba15c docs(protocol2): record live smoke-test results
Booted ServUO + the real sidecar (protocol 2, plugin connected) and exercised
every Protocol 2.0 endpoint end-to-end. PROTOCOL_2.md §15 records the results:

- Part A: account.create 200 + link; duplicate 409; per-IP cap enforced at the
  shard's real AccountsPerIp=3 (4th from one IP -> 429); loopback IP -> 400
  (fail-closed); unlink 200 then lookup 404.
- Part B: /houses (28, full data), /governors (9 cities), /guilds ([]),
  /online (count 0, headless), /char titles block present.

Not exercised (needs a live UO client): presence.online with players,
region.enter, real-time guild.join, char.vitals. Also documents the
Scripts.dll boot-recompile lock quirk (build offline with the server stopped).
World save left untouched; test accounts did not persist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:23:07 -05:00
e0445d3f94 feat(protocol2): titles in char.profile (Part B ph.4)
Overlay BridgeProfile: char.profile gains a titles block (selected index,
fameKarma, skill, and the raw reward-title list) read from PlayerMobile's
public title accessors. No new stream, no sidecar change — it rides the
existing char.profile served by GET /char. Reward entries may be a cliloc
number as a string or a literal; resolve numeric ones website-side like item
names.

Docs: INTEGRATION.md char.profile titles field; PROTOCOL_2 ph.4 built. Part B
phase 5 (Factions/VvV) remains deferred by owner decision.

Verified: overlay compiles in the full ServUO Scripts tree (0 errors, 0
warnings). Live run pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:12:01 -05:00
b858d526b8 feat(protocol2): house registry board (Part B ph.3)
Overlay BridgeHousing (new): a diff sweep over BaseHouse.AllHouses ->
house.update / house.remove (owner, region, location, decay level, co-owners,
friends, placement price), complementing the existing house.decay transition
feed. HousingSweepSeconds (300s); wired into [bridge reload|sweepnow|status.
Stock ServUO has no "for sale" flag, so this is an owner->houses registry;
price is the placement value, not a listing.

Sidecar: houses board table with upsert/delete/all; main routes house.update/
remove into it; GET /houses served from the store.

Docs: INTEGRATION.md house.* events + /houses endpoint; PROTOCOL_2 ph.3 built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live run pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:05:47 -05:00
d47170581d feat(protocol2): presence stream — online population + region transitions (Part B ph.2)
Overlay BridgePresence (new):
- presence.online sweep over online PlayerMobiles: total plus per-facet and
  per-region counts, emitted only when the population changes.
- region.enter real-time from EventSink.OnEnterRegion (player-filtered), the
  cheap location signal PLAN.md prefers over Movement.
- PresenceSweepSeconds (30s); wired into [bridge reload|sweepnow|status.

Sidecar:
- GET /online serves the latest presence.online snapshot from the event store
  (survives restart); population time series via /history?kind=presence.online.

Docs: INTEGRATION.md presence events + /online endpoint; PROTOCOL_2 ph.2 built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live run pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:02:05 -05:00
2ed0b0bd00 feat(protocol2): guild and town-governor world-state streams (Part B ph.1)
Adds the first Part B streams from docs/PROTOCOL_2.md: guild rosters and
town governors ("mayors"), both outbound diff-board sweeps mirroring the
existing champ board.

Overlay:
- BridgeSocial (new): guild sweep+diff over BaseGuild.List -> guild.update /
  guild.remove (full-state upsert; disband detected via Disbanded), plus a
  real-time guild.join from EventSink.JoinGuild. (EventSink.CreateGuild is only
  the load-time factory, so creation is derived sidecar-side from a first-seen
  id, as champs do.)
- BridgeGovernance (new): city sweep over CityLoyaltySystem.Cities -> city.update
  (governor / governor-elect / election phase), gated on CityLoyaltySystem.Enabled.
- BridgeJson.Actor: shared serial/name/acct/webId/player writer used by both.
- BridgeConfig: GuildSweepSeconds (60s), CitySweepSeconds (300s).
- BridgeBoot: both wired into [bridge reload|sweepnow|status.

Sidecar:
- store: guilds + governors board tables with upsert/delete/all.
- main: route guild.update/remove and city.update into the boards.
- web: GET /guilds, GET /governors served from the store (snapshot-companion
  rule, so a fresh page or a restarted sidecar hydrates without the shard).

Docs: INTEGRATION.md event catalog (guild.*, city.update) + board endpoints;
PROTOCOL_2.md Part B phase 1 marked built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 07:54:11 -05:00
808f6ab68b feat(protocol2): website account provisioning & unlinking (Part A)
Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the
website can create game accounts and unlink them, gated by a shard-wide
signup mode. The existing [link flow is unchanged.

Overlay:
- BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized
  falls back to game), AccountCreateEnabled (mode-following default),
  RequireIpForCreate, name/password caps, and a boot warning when the core
  Accounts.AutoCreateAccounts setting contradicts the mode.
- BridgeAccounts (new): account.create (mode gate, actor required, char-safety
  mirrored from AccountHandler, collision check, per-IP cap via CanCreate/
  LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link,
  account.audit; password never logged or echoed) and account.unlink (Owner
  floor via BridgeAdmin.Protected, clears the tag).
- BridgeAccountLink: in-game [unlink command, emits account.unlinked.
- BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse.

Sidecar:
- POST /accounts/create, DELETE /link/:account, respond_account status mapping
  (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400).
- store.record_unlink drops the mirrored link row.
- PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2).

Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429;
PROTOCOL_2.md Part A marked built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 07:42:06 -05:00
29 changed files with 2077 additions and 1987 deletions

View File

@@ -22,7 +22,7 @@
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases the current Cargo.toml version as-is
#
# Prerequisites (Settings → Actions → Secrets on UOM/link):
# Prerequisites (Settings → Actions → Secrets on RunicGateway/link):
# REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token. For image builds it needed
# write:package; THIS workflow additionally needs
@@ -46,7 +46,7 @@ concurrency:
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: UOM/link
REPO: RunicGateway/link
WORKDIR: sidecar
BIN: uo-link-sidecar
LINUX_TARGET: x86_64-unknown-linux-gnu

View File

@@ -9,6 +9,10 @@ ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidec
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
## Documentation
All project documentation now lives in the central **[RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs)** repo, under [`link/`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link) (design docs, integration guide, protocol spec, research — with full history preserved). Individual docs are linked inline below and referenced throughout the source.
## Layout
| Path | What |
@@ -17,10 +21,10 @@ The shard never speaks WebSocket. Every world read happens on the Core thread; t
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
| `sidecar/` | The Rust sidecar: terminates the loopback link to the shard, exposes WS + REST to the website. See `sidecar/README.md`. |
| `tools/` | Never deployed. Test scaffolding and anything else that must not reach a server. |
| `docs/INTEGRATION.md` | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. Start here to build the front end. |
| `docs/PLAN.md` | Implementation plan, measured performance budget, and the full data catalog. |
| `docs/RESEARCH.md` | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
| `docs/SHARD_PREREQS.md` | Repairs the target shard needed before any of this could load. |
| [INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md) | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. Start here to build the front end. |
| [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) | Implementation plan, measured performance budget, and the full data catalog. |
| [RESEARCH.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/RESEARCH.md) | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
| [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) | Repairs the target shard needed before any of this could load. |
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
@@ -37,13 +41,13 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
| Phase | State |
|------:|-------|
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** |
| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** |
| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** |
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** |
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in `docs/PLAN.md` §16** |
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in `docs/PLAN.md` §17** |
| 1 — transport (`BridgeLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §11** |
| 2 — event streams (`BridgeEvents`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §12** |
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §13** |
| 4 — request/response (`BridgeRequests`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §14** |
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §15** |
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §16** |
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §17** |
Every phase on the ServUO side is complete. Phases 06 are drop-in (`overlay/`); Phase 7 is the one core change, shipped as `patches/`. Remaining work is the Rust sidecar.
@@ -91,4 +95,4 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in `docs/PLAN.md` §1. See `tools/scaffolding/README.md`.
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. See `tools/scaffolding/README.md`.

View File

@@ -1,308 +0,0 @@
# Administrative Controls — Research & Integration Plan
**Status:** Research + design. No code written yet.
**Date:** 2026-07-12
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (the read/event plane) and [`INTEGRATION.md`](INTEGRATION.md) (the website API). This document covers the **write plane**: staff actions the website should be able to take against the live shard.
---
## 1. The question
The bridge today is almost entirely *outbound*. It streams events and answers read queries. Its entire inbound (website → shard) surface is three verbs:
| Verb | File | What it does |
|------|------|--------------|
| `ping` | `BridgeBoot.cs:139` | Liveness echo. |
| `link.confirm` | `BridgeAccountLink.cs` | Ties a game account to a website user. |
| `towncrier.add` / `towncrier.remove` | `BridgeTownCrier.cs` | Publishes news to the in-game criers. |
None of these are *moderation*. A staff member who wants to kick a cheater, ban an account, answer a help page, or teleport a stuck player still has to be logged into the game client. This document surveys what in-game administrative controls exist, decides which are worth exposing over the bridge, and specifies the protocol and safety model for doing it.
**The thesis up front:** a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation (`[add`, `[set`, `[dupe`, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form.
---
## 2. How ServUO admin controls actually work
Four mechanisms, all of which the bridge must respect or reuse.
### 2.1 The AccessLevel ladder
`Server/Mobile.cs:431`:
```
Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer, Administrator, Developer, CoOwner, Owner
```
Every command is gated on a minimum level (`CommandSystem.Register(name, level, handler)`). This ladder is the shard's whole authorization model. **The bridge has no Mobile and therefore no natural place on this ladder** — see §5, the attribution problem.
### 2.2 The command system
Two registration styles:
- **Simple commands** — `CommandSystem.Register("Save", AccessLevel.Administrator, handler)`. The bridge already uses this for `[bridge` (`BridgeBoot.cs:44`, Administrator-gated).
- **Generic/target commands** — `BaseCommand` subclasses in `Commands/Generic/Commands/Commands.cs`, registered as objects (`KillCommand`, `KickCommand`, `FirewallCommand`, …). These are built to be *targeted* in-game (click a mobile). Their **logic** is reusable from the bridge; their **targeting/gump plumbing** is not.
### 2.3 Command logging (the existing audit trail)
Staff actions call `CommandLogging.WriteLine(from, ...)`, which writes `Logs/Commands/*.log` **and** is the source of the bridge's own `audit.command` / `audit.set` events (`INTEGRATION.md` §4). Any web-initiated action **must** feed this same trail, or the in-game audit log develops blind spots exactly where remote power is exercised.
### 2.4 Account model (the moderation state)
`Scripts/Accounting/Account.cs`. The durable, offline-capable levers live here:
| Lever | API | Notes |
|-------|-----|-------|
| Ban (indefinite) | `acct.Banned = true; acct.SetUnspecifiedBan(from)` | `Account.cs:440`, `:1098` |
| Ban (timed) | `acct.SetBanTags(from, DateTime.UtcNow, TimeSpan)` then `acct.Banned = true` | `:1103`; `Banned` getter auto-clears when the window lapses (`:454`) |
| Unban | `acct.Banned = false; acct.SetUnspecifiedBan(null)` | clears the tags |
| Read ban | `acct.GetBanTags(out when, out dur)` | `:1133` |
| Staff level | `acct.AccessLevel = …` | `:557` — promotes/demotes a whole account |
| Young status | `acct.Young` | `:471` |
Account-level state persists and applies whether or not the player is online. Per-*mobile* state (below) generally requires the target resident.
---
## 3. Candidate controls
Grouped by subsystem. **Tier**: **A** = wire in first, **B** = second wave, **N** = never expose remotely. **~~H~~ = excluded.** The former "hold" items (firewall, kill/res, jail, item/gold grants, set-access-level) were reviewed and **cut from the roadmap entirely** per the 2026-07-12 decision — their rows are kept below for the record but will **not** be built. The write plane is deliberately account/session moderation + support, nothing that manipulates the world or the object graph.
### 3.1 Session control (target online)
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Kick** | `[Kick``KickCommand`, `Commands.cs:1170` | `targ.NetState?.Dispose()` | **A** | Pure disconnect. Reversible (they reconnect). Lowest blast radius of any real moderation action. |
| **Firewall (IP block)** | `[Firewall`, `Commands.cs:1125` | `Firewall.Add(state.Address)` | **H** | Blocks an IP, not an account. Collateral damage on shared IPs/CGNAT; hard to reverse from the same UI. Powerful but sharp. |
| **Locate / who** | `[Where`, `[Client` | already have `char.vitals`/`mob.login` | — | Effectively already covered by the event plane. |
### 3.2 Account moderation (works offline)
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Ban (indefinite)** | `[Ban``KickCommand(ban:true)`, `Commands.cs:1225` | `Banned=true; SetUnspecifiedBan` + kick live sessions | **A** | The headline verb. Note the in-game path *also* opens `BanDurationGump` — we replace that with an explicit duration in the request. |
| **Ban (timed)** | (gump) | `SetBanTags(actor, now, dur); Banned=true` | **A** | Duration in the request body; auto-expires. |
| **Unban** | property edit | `Banned=false; SetUnspecifiedBan(null)` | **A** | |
| **Mute / squelch** | property `Squelched` | `mob.Squelched = true` (`Mobile.cs:5807`) | **B** | Per-**character**, not per-account. **Persists** across relog + restart (serialized, `Mobile.cs:6489`/`:6013`); works on offline chars too. Mute an account = squelch each resident character (§7.1). |
| **Page-mute** | `PagingSquelched` | set on `PlayerMobile` | **B** | Stops help-page spam without a full mute. |
| **Set access level** | property `AccessLevel` | `acct.AccessLevel = …` | **H** | Promoting staff from a web UI is a serious privilege path. Gate hard, or omit. |
| **Comments / notes** | account comments | `acct.Comments` | **B** | A staff notes field — pairs naturally with a web moderation panel. |
### 3.3 Player actions (target online)
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Kill / Resurrect** | `[Kill` / `[Res`, `Commands.cs:966` | `mob.Kill()` / `mob.Resurrect()` | **H** | Legitimate for stuck/exploit cleanup; also the most "griefable" verb if the web authz ever leaks. |
| **Teleport / Bring** | `[Go`, `[Move`, `[Tele` | `mob.MoveToWorld(p, map)` | **B** | "Bring to me" has no meaning without a staff mobile; "send to coordinates / named location" does. |
| **Jail** | region only — `Regions/Jail.cs`, **no stock command** | custom: move to jail point (+ flag) | **H** | Needs us to *build* the action (pick a jail location, decide on release). Region exists; the verb does not. |
| **Hide / Unhide** | `[Hide`, `Commands.cs:1066` | `mob.Hidden = bool` | **N** | No remote use case. |
| **Set/Get property** | `[Set` / `[Get` / `[Props` | reflection | **N** | Arbitrary property writes = arbitrary power. Keep in-client. |
| **Give item / gold** | `[Add`, `Bank` | construct + place | **H** | Compensation flows are real but this is a duplication/economy risk; if wanted, expose *specific* curated grants, never `[add` by type. |
### 3.4 Support: the help-page queue ★
`Scripts/Services/Help/PageQueue.cs`. When a player uses the in-game Help button they create a `PageEntry` (`Bug`, `Stuck`, `Account`, `Question`, `Suggestion`, `Harassment`, …) carrying **sender, message, type, location/map, timestamp, and assigned handler**. `PageQueue.List` is the live queue; `PageQueue.Enqueue/Remove` mutate it; a staff reply reaches the player via `ResponseEntry``MessageSentGump`.
This is the single **best** tie-in and deserves its own slice of work:
- **Stream** new pages as a `page.new` event and removals as `page.closed`.
- **Snapshot** the open queue over REST (`GET /pages`).
- **Respond** from the website (`POST /pages/{id}/respond`) → delivers a message to the player in-game, exactly like a staff member typing a response.
- **Close / assign** a page.
It turns "a staff member must be logged into the game to see the queue" into "the queue is a page on the site." Tier **A**, but scoped as its own phase (§6, Phase 2) because it is read+write+stream, not a single verb.
### 3.5 Broadcast & messaging
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Server broadcast** | `[BCast`, `Handlers.cs` | `World.Broadcast(hue, ascii, text)` | **A** | Overlaps town-crier but different UX (instant system message vs. crier loop). Cheap, high-value. |
| **Staff message (SMsg)** | `[SMsg`, `Handlers.cs` | send to online staff | **B** | "Post to staff channel" from the site. |
| **Tell / private msg** | `[Tell` | `mob.SendMessage` | **B** | Message one player from the web (e.g. auto-reply to a page). |
### 3.6 World / server operations
| Control | In-game | Bridge API | Tier | Notes |
|---------|---------|-----------|------|-------|
| **Save** | `[Save`, `Handlers.cs` (Administrator) | `AutoSave.Save()` | **B** | Trigger a world save from a deploy/admin panel. Emits `world.save.*` we already stream. |
| **Background save** | `[BGSave` | | **B** | Non-blocking variant. |
| **Shutdown / restart** | console | process-level | **N** | Do this at the process/host layer, not through a game plugin. |
| **Freeze / Wipe / DecorateDelete / TelGen** | various | — | **N** | Destructive world-building. In-client only. |
---
## 4. Roadmap (decided)
> **Build status (2026-07-13):** Phase 1 is **built and live-verified end-to-end**, branch `feature/admin-controls`.
> - *Plugin* (`BridgeAdmin.cs` + config): all four verbs, `web:<actor>` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner was refused) confirmed against a booted ServUO.
> - *Sidecar* (`sidecar/src/web.rs`): `POST /admin/{kick,ban,unban,broadcast}` routes with the status mapping in §6. Verified with the real sidecar + shard: 200 on success, **403** on the Owner floor, **404** unknown target, **400** missing actor, **401** no token.
> - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event.
> - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored.
>
> **Phase 2 — help-page queue: built and live-verified.** `BridgePages.cs` polls the queue (`PageSweepSeconds`, default 5s) → `page.new`/`page.updated`/`page.closed`; inbound `pages.snapshot`/`page.respond`/`page.close`; sidecar `GET /pages` + `POST /pages/{id}/respond|close`; `INTEGRATION.md` §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both `page.new` emits, respond, close (→ page removed), `page.closed` emit, and 404 on an unknown page.
>
> **Phase 1 + bidirectional audit + Phase 2 are complete — this is the shipped scope.** Phase 3 (below) is **not planned** (owner decision, 2026-07-13). Remaining work is downstream and website-side only: the admin/mod UI (moderation log + support-queue view).
**Wire in, in order:**
1. **Phase 1 — Account & session moderation (Tier A).** `admin.kick`, `admin.ban` (timed + indefinite), `admin.unban`, plus `admin.broadcast`. These are the actions a staff member most often wishes they could do from a phone. Ban/unban work offline and are the highest-value; kick and broadcast are trivial and safe.
2. **Phase 2 — Help-page queue (Tier A, own phase).** Stream + snapshot + respond/close. The biggest single quality-of-life win, but it is a read/write/stream subsystem, not one verb.
3. ~~**Phase 3 — Second wave (Tier B).** Mute/page-mute, account comments, teleport-to-location, staff message, manual save.~~ **Not planned** (owner decision, 2026-07-13). The Tier-B candidates catalogued in §3 stay documented for the record, but the shipped scope is Phase 1 + Phase 2.
Cross-cutting, lands alongside Phase 1: **bidirectional audit** — in-game use of any of these moderation verbs is forwarded to the website in the same shape as web-initiated ones, so the site has a complete moderation picture (§5.5).
**Excluded — will not be built:** firewall, set-access-level, kill/res, jail, item/gold grants (the former Tier H), and the Tier-N set — arbitrary `[set`/`[get`, `[add`, hide, freeze, wipe, decorate, shutdown. Sharp, privilege-escalating, or catastrophic; all stay in the game client.
---
## 5. Authorization & attribution (decided)
Every in-game moderation command carries a `Mobile from` — the staff member — used for two things the bridge has no natural source for:
1. **Audit**`CommandLogging.WriteLine(from, …)` and the `SetBanTags(from, …)` "BanDealer" tag record *who did it*.
2. **Authorization** — e.g. `KickCommand` refuses unless `from.AccessLevel > targ.AccessLevel` (`Commands.cs:1200`), so a GM can't ban an Admin.
The resolved model:
**Authorization lives on the website.** The website gates these commands behind its own **admin-only** roles (and moderator ability levels). The shard does not — cannot — re-derive per-user permission; it trusts the loopback socket + auth token exactly as it already trusts town-crier. The sidecar is the trust boundary.
**Sidecar commands carry `CoOwner`-level authority on the shard.** Because the website has already authenticated and authorized the staff user, an inbound `admin.*` is applied as if issued by a synthetic `CoOwner` — the second-highest rung (`Server/Mobile.cs:431`: only `Owner` is above it). This cleanly satisfies the `from.AccessLevel > targ.AccessLevel` guard for every ordinary target.
**The one shard-side floor: never touch the Owner.** Even at CoOwner authority, an `admin.*` command **refuses any target account whose `AccessLevel >= CoOwner`.** That is the whole defense-in-depth on the plugin side: a compromised or buggy sidecar can moderate players and staff below CoOwner, but can never ban, kick, or demote the Owner (or another CoOwner). *Note the consequence, plainly:* this is a permissive posture — it deliberately lets the web plane act on Administrator/Seer/GM-level accounts, on the assumption that reaching the web admin panel already means near-total trust. If that assumption ever weakens, raise the floor in `Bridge.cfg` (`AdminAccessFloor`).
**Attribution is an explicit `web:<actor>` string.** Every `admin.*` request carries a required `actor` field — the website username/id of the staff member. The shard:
- logs it to the **server console** as `[Bridge][admin] web:<actor> <action> …`. *(Note, corrected during implementation: `CommandLogging.WriteLine` cannot be reused for web actions — it dereferences `from.NetState`/`from.Account`/`from.AccessLevel` (`Scripts/Commands/Logging.cs:93-103`) and there is no staff `Mobile`. So web actions do **not** land in `Logs/Commands/`; the console line plus the `admin.audit` stream plus the website's own log are their durable record. `Logs/Commands/` remains the record for **in-game** staff actions, which §5.5 forwards to the site — so the complete picture lives on the website, by design.)*
- stores `web:<actor>` in the ban "BanDealer" tag (`SetBanTags` wants a `Mobile from`; we pass `null` for the Mobile and set the tag ourselves — no core edit),
- echoes it back in an `admin.audit` event (§5.5) so the website's own moderation record and the game's audit agree.
**The website keeps its own durable record.** Independently of the shard, the website persists every moderation action to its own log (who/what/when/why), mirroring the existing admin-activity-log pattern. The shard's `CommandLogging` + `admin.audit` are the game-side truth; the website log is the site-side truth; §5.5 keeps them in sync in both directions.
### 5.5 Bidirectional audit — one moderation picture, both origins
The website must see moderation actions **whether they originate on the site or in the game client**, in one consistent schema. Two directions:
- **Web → game (already in the request path).** Each applied `admin.*` emits an unsolicited `admin.audit` broadcast frame to every connected dashboard, tagged `"origin":"web"`, `"actor":"web:<user>"`.
- **Game → web (the "full picture" requirement).** When a staff member runs one of these same verbs *in the game client*`[ban`, `[kick`, `[bcast`, a page-queue response, a mute — the plugin forwards it to the website as the **same** `admin.audit` shape, tagged `"origin":"in-game"`, `"actor":"<staff account/name>"`.
The raw hook already exists: `BridgeEvents.OnStaffCommand` subscribes to `EventSink.Command` and emits `audit.command` for every staff command (`BridgeEvents.cs:404`), and `OnStaffPropertySet` emits `audit.set`. Those stay as the low-level firehose. On top of them we add a **normalizer** that emits a structured `admin.audit` for the specific moderation verbs, so the website's moderation log has one shape to store, not a freeform command string to parse.
```json
{ "kind": "admin.audit", "origin": "in-game", "action": "ban",
"actor": "GreyBeard", "target": "griefer42", "reason": null,
"durationSec": 604800, "t": 1783720195626 }
```
**The dispatch path — traced and settled (no longer an open question).** `[ban` and `[kick` *are* registered directly in the command table: `SingleCommandImplementor.Register` calls `CommandSystem.Register(name, level, Redirect)` for each command name (`SingleCommandImplementor.cs:22`), so they sit in `m_Entries` and `EventSink.InvokeCommand(e)` fires for them (`Server/Commands.cs:259`). **So the existing `audit.command` hook already sees them** — the earlier worry that generic commands bypass `EventSink.Command` is wrong.
The genuine subtlety is *when* it fires and *with what*:
| Verb shape | Example | What `EventSink.Command` carries | Complete? |
|------------|---------|----------------------------------|-----------|
| Arg-bearing, no target | `[bcast Server down in 5` | verb **+ full args** | ✅ fully captured |
| **Target-cursor** | `[ban` → click victim | verb only, **empty args** | ⚠️ **verb but not the victim** |
For target-cursor verbs, `Handle` runs `entry.Handler(e)` (→ `Redirect``Process``from.BeginTarget(...)`, which arms the cursor and returns) and *then* `InvokeCommand(e)` (`Commands.cs:255-259`). The event therefore fires the moment `[ban` is **typed**, before the staff clicks anyone. The resolved action — the actual target and `Account.Banned = true` — happens later inside `KickCommand.Execute`, which calls `CommandLogging.WriteLine(from, "… banning {target}")` **with** the victim (`Commands.cs:1211`).
**Conclusion:** the reliable choke point for a *resolved* in-game moderation action (verb **and** victim) is `CommandLogging.WriteLine` (`Scripts/Commands/Logging.cs:86`), which is where every command already records its outcome — but it has **no event to subscribe to** today. So the "full picture" needs one small hook:
- **Add a `WriteLine` event to `Scripts/Commands/Logging.cs`** (a 1-line `Action<Mobile,string>` raised in `WriteLine`). This is a stock file, so it ships as a **`patches/` diff** — the same mechanism Phase 7's `PlayerVendorSale` already established, and arguably the *correct* universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emits `admin.audit`.
- Broadcasts and other arg-bearing simple commands need **no** patch — the existing `EventSink.Command` hook already carries their full payload; the normalizer just reshapes them.
---
## 6. Protocol design
Reuse the existing inbound machinery verbatim — `BridgeBoot.RegisterHandler(kind, handler)`, Core-thread dispatch via `Timer.DelayCall`, `reqId` echo, and `*.ok` / `*.error` replies — exactly as `BridgeRequests` and `BridgeTownCrier` already do. A new `BridgeAdmin.cs` registers the `admin.*` handlers.
### Request shape (website → sidecar → shard)
```json
{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech",
"account": "griefer42", "durationSec": 604800, "reason": "harassment" }
```
- `reqId` — correlation id, echoed on the reply (as in `BridgeRequests`).
- `actor`**required.** The website staff user. Rejected if absent.
- Target — `account` (offline-capable verbs) or `serial` (online mobiles), resolved with the same `ResolveSerial` / `Accounts.GetAccount` helpers `BridgeRequests` uses.
- `reason` — recorded in the audit trail.
### Reply shape (shard → sidecar → website)
```json
{ "kind": "admin.ok", "reqId": "a1b2", "action": "ban", "target": "griefer42" }
{ "kind": "admin.error", "reqId": "a1b2", "reason": "target is staff; refused" }
```
Map to REST like the rest of `INTEGRATION.md`: `admin.ok` → 200, unknown target → 404, floor-violation/`actor` missing → 403, malformed → 400.
### Audit event (shard → website, unsolicited)
Every applied `admin.*` also emits a broadcast audit frame so *all* connected dashboards see it, not just the caller — parallel to the existing `audit.command`, and (per §5.5) emitted for **in-game** uses of the same verbs too:
```json
{ "kind": "admin.audit", "origin": "web", "action": "ban", "actor": "web:whitlocktech",
"target": "griefer42", "reason": "harassment", "durationSec": 604800, "t": 1783720195626 }
```
`origin` is `"web"` for sidecar-initiated actions or `"in-game"` for actions a staff member took in the game client.
### Verbs for Phase 1
| kind | target | required fields | shard action |
|------|--------|-----------------|--------------|
| `admin.kick` | `serial` or `account` | `actor` | dispose live NetState(s) |
| `admin.ban` | `account` | `actor` (+ `durationSec` optional) | set ban tags/flag, then kick live sessions |
| `admin.unban` | `account` | `actor` | clear ban |
| `admin.broadcast` | — | `actor`, `text` (+ `hue`) | `World.Broadcast` |
Every one: enforce the **Owner floor** on the target (refuse `AccessLevel >= CoOwner`), apply on the Core thread as a synthetic CoOwner, `CommandLogging.WriteLine("web:<actor> …")`, emit `admin.audit` (`origin:"web"`), reply `admin.ok`/`admin.error`.
### Caps / defense-in-depth (mirroring town-crier)
- `actor` required and non-empty.
- Target floor: refuse any target with `AccessLevel >= CoOwner` (`AdminAccessFloor` in `Bridge.cfg`, default `CoOwner` → only the Owner/CoOwners are shielded).
- `reason` length cap; `durationSec` clamp (min/max); `broadcast` text length cap.
- Master switch `AdminWriteEnabled` in `Bridge.cfg` (default **off**) so the whole write plane is opt-in per shard.
---
## 7. Verification log (all resolved)
All resolved by source inspection (ServUO checkout at `C:\Users\colby\Desktop\servuo`). No live-shard run was needed — every path below is unambiguous in the code, and a mute smoke-test would in any case require a real UO client to log in and speak.
1. **`Mobile.Squelched` persists — confirmed durable.** Serialized unconditionally (`Server/Mobile.cs:6489` write) and read back in the version ladder at case 9 (`:6013`), so it survives relog **and** a full server restart; no need to persist it ourselves. It gates `OnSaid` (`:7591`*"You can not say anything, you have been muted."*). Two consequences for the plan: (a) it is **per-Mobile (per-character), not per-account** — "mute the account" means squelch each resident character; (b) it works on **offline** characters too, since logged-off mobiles stay resident in `World`. Phase 3 mute is therefore durable and offline-capable out of the box.
2. **Kicking all sessions — settled.** Enumerate `NetState.Instances` (`Server/Network/NetState.cs:583`, a `ReadOnlyCollection<NetState>`), filter on `ns.Account == acct` (`:574`), and `Dispose()` each. This is **strictly better than walking the account's characters' `NetState`**: a client sitting at character-select has a `NetState` with an `Account` but *no* mobile, and only the `Instances` sweep catches it. `admin.kick` and the live-session cleanup in `admin.ban` both use this.
3. **In-game capture of resolved bans/kicks (was the ★ risk).** Traced through the dispatch path — settled in §5.5. `[ban`/`[kick` *do* raise `EventSink.Command`, but at type-time without the target. The complete capture point is a **1-line event added to `Scripts/Commands/Logging.cs:86`**, shipped as a `patches/` diff. Broadcasts need no patch.
4. **Ban attribution** — pass `null` for the `Mobile from` and set `web:<actor>` as the `BanDealer` tag ourselves. No core edit.
5. **Broadcast + town-crier** — keep both; they differ (instant system line vs. looping crier) and both are cheap.
6. **Access floor**`CoOwner` (Owner-only shield). See §5.
**Nothing in §7 remains open — the plan is implementation-ready.**
---
## 8. Decisions — locked 2026-07-12
- **Scope:** Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. **The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely** — not now, not later.
- **Authorization:** enforced on the **website** (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as **CoOwner-level** authority, with a hard floor that refuses any target at `AccessLevel >= CoOwner` (Owner-only shield). Write plane defaults **off** in `Bridge.cfg`.
- **Attribution:** `web:<actor>` in `CommandLogging` and the `BanDealer` tag; no core edits.
- **Logging:** the **website keeps its own durable moderation record**; the plugin **forwards in-game uses** of these same verbs to the site as `admin.audit` (`origin:"in-game"`) so the picture is complete from both sides (§5.5).
- **Help-page queue:** confirmed, lands as **Phase 2**.
---
## 9. Where the code goes
| File | Responsibility |
|------|----------------|
| `overlay/Scripts/Custom/Bridge/BridgeAdmin.cs` | New. Registers `admin.*` handlers; the CoOwner-authority application + Owner floor; `web` `admin.audit` emission. Mirrors `BridgeTownCrier.cs` structure. |
| `overlay/Scripts/Custom/Bridge/BridgeEvents.cs` | Extend: normalize in-game moderation verbs into `admin.audit` (`origin:"in-game"`). Broadcasts reshape from the existing `EventSink.Command` hook; ban/kick subscribe to the new `CommandLogging` event (§5.5). |
| `patches/commandlogging-event.patch` | New. Adds a 1-line `Action<Mobile,string>` event to `Scripts/Commands/Logging.cs:86` so resolved staff actions (verb **+ target**) are observable. Stock file → ships as a patch, per the Phase-7 precedent. |
| `overlay/Scripts/Custom/Bridge/BridgePages.cs` | New (Phase 2). Streams/snapshots/answers the `PageQueue`. |
| `overlay/Config/Bridge.cfg` | Add `AdminWriteEnabled` (default off), `AdminAccessFloor` (default `CoOwner`), and the caps. |
| `sidecar/src/web.rs` | New REST routes (`POST /admin/*`, `/pages/*`) → inbound lines; map replies to status codes. |
| `docs/INTEGRATION.md` | Document the new endpoints + the `admin.audit` / `page.*` events. |
| *(website, separate repo)* | Admin/moderator-gated UI + a durable moderation log that records both its own actions and inbound `admin.audit` frames. |
The Phase-1 **verbs** need no core or stock edit — every web-initiated action is an existing script-layer API called from the new `BridgeAdmin.cs` overlay. The only non-overlay change is the **one-line `CommandLogging` event** (`patches/commandlogging-event.patch`), needed solely so *in-game* bans/kicks forward their resolved target to the website (§5.5); it reuses the Phase-7 `patches/` mechanism and touches nothing else.

View File

@@ -1,519 +0,0 @@
# uo-link Sidecar — Website Integration Guide
This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly.
```
website ──WebSocket (live feed) + REST (queries/commands)──► sidecar ──loopback──► shard
```
- **Base URL** — default `http://127.0.0.1:8080` (WebSocket: `ws://127.0.0.1:8080`). Configurable in `sidecar.toml` (`web.bind`) or `UOLINK_WEB_BIND`. If you serve the site from another host, bind the sidecar to `0.0.0.0:8080` and put it behind TLS.
- **Content type** — all request and response bodies are JSON (`application/json`).
- **Timestamps** — every `t` field is **epoch milliseconds** (UTC). Human-readable timestamps (e.g. `house.decay.builtOn`, `/health.last_event`) are ISO-8601 UTC.
- **Serials** — game object ids are hex strings like `"0x24C"` (mobiles) or `"0x40013AAD"` (items). Treat them as opaque keys.
---
## 1. Authentication
Every route **except `GET /health`** requires the shared token from `sidecar.toml` (`web.auth_token`). Present it any of these ways:
| Transport | How |
|-----------|-----|
| REST | `Authorization: Bearer <token>` |
| REST | `X-Api-Key: <token>` |
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
---
## 2. Protocol version
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
- Every response carries an **`X-UOLink-Version: 1`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 1`.
- **Optionally**, send `X-UOLink-Version: 1` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json
{ "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" }
```
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
---
## 3. Health
```
GET /health (no auth)
```
```json
{
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
"protocol": 1,
"plugin_connected": true, // is the shard link up right now?
"database": "ok", // "ok" | "error"
"uptime": "3d 12h",
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
}
```
Always returns HTTP 200 (read `status`/`plugin_connected` for real state). Use it for liveness checks and to detect when the shard has dropped (`plugin_connected: false`).
---
## 4. WebSocket live feed
```
GET /ws?token=<token> (WebSocket upgrade)
```
A push-only stream of game events as they happen. You do **not** send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.
**On connect**, the first frame is:
```json
{ "kind": "ws.hello", "protocol": 1 }
```
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
Notes:
- **Live-only, no replay.** A client that connects now sees events from now on. For history/backfill, use `GET /history`.
- The sidecar sends WebSocket **ping** frames every ~30s for keepalive; browser clients answer automatically.
- You may occasionally see a `{"kind":"pong",...}` frame (the sidecar's internal heartbeat to the shard). Ignore any `kind` you don't handle.
- A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.
### Minimal browser client
```js
const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
ws.onmessage = (m) => {
const ev = JSON.parse(m.data);
switch (ev.kind) {
case "ws.hello": /* check ev.protocol === 1 */ break;
case "mob.login": onLogin(ev); break;
case "vendor.sale": onSale(ev); break;
case "house.decay": onIdoc(ev); break;
// ...handle the kinds you care about; ignore the rest
}
};
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history
```
### Event catalog
Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"serial","name","acct","player"}` (`acct` present only for player-owned mobiles).
#### Lifecycle
| kind | fields | notes |
|------|--------|-------|
| `server.hello` | `shard`, `bootId`, `connects`, `items`, `mobiles`, `accounts` | Sent to the sidecar on every shard (re)connect. `bootId` changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". |
| `server.shutdown` | — | Clean shutdown. |
| `server.crashed` | `error` | Not always sent (a hard crash may skip it). |
| `world.save.before` / `world.save.after` | (`after` adds `items`, `mobiles`) | Save-cycle boundaries; a natural consistency checkpoint. |
#### Sessions & identity
| kind | fields |
|------|--------|
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
| `mob.logout` | `who` |
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
#### Economy & commerce
| kind | fields | notes |
|------|--------|-------|
| `gold.change` | `acct`, `old`, `new`, `delta` | AccountGold flow (gold in bank/account, not physical coins). |
| `vendor.buy` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor purchase (validation stage). |
| `vendor.sell` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor sale. |
| `vendor.sale` | `buyerSerial`, `buyerAcct`, `ownerSerial`, `ownerAcct`, `vendorSerial`, `itemType`, `itemSerial`, `itemId`, `amount`, `price`, `commission`, `committed:true` | **Player** vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. |
| `vendor.placed` | `owner`, `vendor` | A player vendor was placed. |
```json
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
"ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
"itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
"price":100,"commission":0,"t":1783720195626}
```
#### Character progression & vitals
| kind | fields | notes |
|------|--------|-------|
| `char.vitals` | `serial`, `hits`,`hitsMax`, `mana`,`manaMax`, `stam`,`stamMax`, `str`,`dex`,`int`, `map`, `x`,`y` | Periodic snapshot of each **online** player (~every 30s; configurable). Diff successive snapshots to detect change. |
| `skill.gain` | `who`, `skill`, `gained`, `base`, `cap` | Player skill gains only (NPC gains are filtered out). |
| `fame.change` / `karma.change` | `who`, `old`, `new` | Player only. |
| `quest.complete` | `who`, `quest` | |
#### Death & PvP
| kind | fields |
|------|--------|
| `player.death` | `who`, `killer` |
| `player.murdered` | `victim`, `murderer` |
| `mob.killed` | `killed`, `killer` — only kills that involve a player |
#### Housing / IDOC
| kind | fields |
|------|--------|
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
```json
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
```
#### Economy supply (periodic)
| kind | fields |
|------|--------|
| `economy.supply` | `accounts`, `gold` — total money supply across all accounts (~every 5 min; configurable) |
#### Cheat detection & staff audit
| kind | fields | notes |
|------|--------|-------|
| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. |
| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. |
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. |
| `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:<user>"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. |
#### Account linking
| kind | fields | notes |
|------|--------|-------|
| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. |
#### Help-page (support) queue
| kind | fields | notes |
|------|--------|-------|
| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). |
| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). |
| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). |
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
#### Champion spawns
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
| `category` | source | what it is |
|------------|--------|-----------|
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
| kind | fields | notes |
|------|--------|-------|
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
`status` is one of:
- **`active`** — running (or, for `sea`, the boss is alive).
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
Category-specific fields on `champ.update`:
| category | extra fields |
|----------|--------------|
| `champion` | `level` (016), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
```json
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
```
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
---
## 5. REST — read queries
These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning **504**.
### Character profile
```
GET /char/{account}/{slot} # by account + character slot (0-based)
GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C
```
Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for **offline** characters too. `GET /char/serial/...` falls back to the last **cached** profile if the shard is unreachable (so a page still renders during a shard restart).
```json
{
"kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
"body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
"stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
"mana":123,"manaMax":123, "stam":120,"stamMax":120,
"fame":0,"karma":0,"luck":0,
"resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
"skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
"equipment": [
{ "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
"cliloc":1027933,"mods":{} },
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
"weapon":{"minDamage":16,"maxDamage":18},
"mods":{"WeaponDamage":50,"HitLightning":40} }
]
}
```
Field notes:
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site.
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
### Account roster
```
GET /roster/{account}
```
Lightweight list of an account's characters (up to 57), including offline ones. Use this for a character-picker, then fetch the full profile on demand.
```json
{ "kind":"account.roster", "acct":"whitlocktech",
"chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }
```
### Player vendors
```
GET /vendors/{account}
```
Every player vendor owned by any character on the account, with held gold and current listings.
```json
{ "kind":"vendor.snapshot", "acct":"seed_000",
"vendors":[
{ "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
"ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
"listings":[
{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
] } ] }
```
---
## 6. REST — commands & history
### Confirm an account link
The in-game `[link` flow: the player runs `[link`, the shard emits a `link.request` event (over the WebSocket) carrying a one-time `code`. Your site shows the logged-in website user a box to enter that code, then:
```
POST /link/confirm
{ "code": "AB12CD", "websiteUserId": "9931" }
```
- Success → **200** `{"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}`. The game account is now permanently tagged with your `websiteUserId` (persisted on the shard); subsequent `mob.login` events for that account carry `webId`.
- Bad/expired code → **404** `{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}`.
Codes are one-time and expire (default 5 min).
### Look up an existing link
```
GET /link/{account}
```
- **200** `{"account":"PerryAdimn","websiteUserId":"9931"}` if linked.
- **404** `{"account":"PerryAdimn","linked":false}` if not.
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
### Publish / remove town-crier news
Push a message that every in-game town crier announces until it expires.
```
POST /towncrier
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }
```
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`. Re-posting the same `id` replaces the prior entry.
```
DELETE /towncrier/{id}
```
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`, or **404** `{"kind":"towncrier.error","reason":"unknown id"}`.
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
### Staff moderation — the write plane
Account and session moderation against the live shard. **These are privileged.** The sidecar does
not model per-user roles — **your site must authenticate the staff user and check their permission
before calling.** The shard trusts the loopback socket and applies each command with CoOwner-level
authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner
account) is refused (**403**). The whole plane is **opt-in on the shard** (`AdminWriteEnabled` in
`Bridge.cfg`); when it's off, every call returns **403** `"admin write plane disabled"`.
Every request requires an **`actor`** — the website username/id of the staff member taking the
action. It is recorded in the shard console log, the ban's `BanDealer` tag, and the `admin.audit`
event, so actions are always attributable. A missing `actor` is **400**.
```
POST /admin/kick { "actor":"jane", "account":"griefer42" } # or "serial":"0x2E0"
POST /admin/ban { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
POST /admin/unban { "actor":"jane", "account":"griefer42" }
POST /admin/broadcast { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
```
- **kick** — disconnects every live session of the target account (including one parked at
character-select). Target by `account` or `serial`. Reply carries `sessions` (how many were cut).
- **ban** — bans the account (works offline) and disconnects any live sessions. `durationSec > 0`
is a timed ban that auto-expires; `0`/absent is indefinite. Clamped to the shard's
`AdminBanMaxDurationSec`.
- **unban** — clears the ban.
- **broadcast** — a system message to everyone online. `hue` optional (default `53`, staff green).
Length-capped by the shard.
Success → **200** with an `admin.ok`:
```json
{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }
```
Failure → an `admin.error` with a mapped status:
| Status | When |
|--------|------|
| 400 | missing `actor`, malformed body, or bad parameter |
| 401 | missing/invalid auth token |
| 403 | target is protected (at/above the floor), or the write plane is disabled on the shard |
| 404 | unknown or accountless target |
| 503 / 504 | shard not connected / didn't reply in time |
Each applied action also emits an unsolicited **`admin.audit`** frame on the WebSocket (§4) with
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
by staff in the game client surfaces the same way with `origin:"in-game"`.
### Help-page (support) queue
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
roles, like the moderation endpoints above.
```
GET /pages # the open queue, newest state
POST /pages/{pageId}/respond { "message":"...", "close": false }
POST /pages/{pageId}/close
```
- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new`
event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live
with the `page.new` / `page.updated` / `page.closed` events.
- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if
they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass
`"close": true` to resolve the page in the same call. → **200** `page.ok`.
- **close** removes the page from the queue. → **200** `page.ok`.
- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**.
```json
POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true }
→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true }
```
### History (from the sidecar's database)
```
GET /history?kind={kind}&limit={n} # kind optional, limit default 100 (max 1000)
GET /economy?limit={n} # the money-supply series (economy.supply events)
```
Recent events, **newest first**, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC".
```
GET /history?kind=vendor.sale&limit=50
→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] }
GET /economy?limit=200
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
```
### Champion-spawn board
```
GET /champs
```
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
```
GET /champs
→ { "spawns": [
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
] }
```
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
---
## 7. Status codes
| Code | Meaning |
|------|---------|
| 200 | OK |
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
| 401 | Missing or invalid auth token |
| 404 | Not found (unknown account / character / id) |
| 409 | Protocol version mismatch (you sent `X-UOLink-Version` and it disagreed) |
| 500 | Internal error (e.g. database) |
| 503 | Shard not connected — the query needs the live game and it's down |
| 504 | Shard connected but didn't reply within 10s |
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
---
## 8. Putting it together
A typical character page:
```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" };
// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
// 2. full sheet for the selected character
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
if (res.status === 503) showBanner("Game server is restarting…");
else renderProfile(await res.json());
// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
// (see the WebSocket client in §4)
// 4. recent sales widget
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
.then(r => r.json());
```
---
## 9. Caveats & current limits
- **No rate limiting yet.** The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
- **WebSocket is push-only and live-only.** No client→server messages, no replay. Backfill via `/history`.
- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted.
- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape.

View File

@@ -1,508 +0,0 @@
# ServUO Bridge Plugin — Implementation Plan & Data Catalog
**Status:** Design, grounded in **measurements taken on this shard**, not estimates.
**Date:** 2026-07-10
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
**Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) for where that document is wrong.
Test scaffolding used to produce this plan lives in `Scripts/Custom/BridgeSeeder.cs` (world population) and `Scripts/Custom/BridgeProbe.cs` (timing). Both are gated behind `Config/Bridge.cfg` flags and default to off. **Neither is part of the bridge.** Delete before production.
---
## 1. Measured budget
Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the **Core thread** — the probe printed `thread: Core Thread (id 1)`, which empirically confirms the threading model that `BRIDGE_FINDINGS.md` could only infer from a crash log.
| Read | Cost | Payload | Per-unit |
|------|------|---------|----------|
| Full character profile | **0.069 ms/char** | 2,386 B JSON | — |
| Vitals sweep (150 chars) | 0.223 ms | ~180 B/char | 0.0015 ms/char |
| House decay sweep (35 houses) | 0.007 ms | — | 0.0002 ms/house |
| Economy supply sweep (51 accounts) | 0.001 ms | — | ~0.00002 ms/acct |
| Vendor snapshot (30 vendors, 1200 listings) | 0.343 ms | — | 0.0003 ms/listing |
Linear extrapolation at the same gear complexity:
| Scenario | Cost | Verdict |
|----------|------|---------|
| Vitals sweep @ 200 online | 0.30 ms | free |
| Vitals sweep @ 1000 online | 1.49 ms | free |
| Decay sweep @ 2000 houses | 0.38 ms | free |
| Economy @ 5000 accounts | 0.06 ms | free |
| **Profiles for 1000 chars** | **69.4 ms** | **stall — never in a sweep** |
**The headline result inverts the original doc's anxiety.** `BRIDGE_FINDINGS.md` treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is *not* free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. **Tier by volatility and serve profiles on demand.** That conclusion survives; the reasoning behind it changes.
### Caveat on these numbers
Seeded characters carry **8 equipped items with ~6 non-zero mods each and ~12 trained skills**. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore **understated, plausibly by 24×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~68 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars.
Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget.
---
## 2. Architecture (confirmed, unchanged)
```
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
```
ServUO does **not** speak WebSocket. It writes `{...}\n` lines to `127.0.0.1`. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust.
Non-negotiable rules, all of which the measurements support:
- **Every world read happens on the Core thread.** Verified: probe reported `Core Thread (id 1)`.
- **The Core thread never touches the socket.** Producer formats a line, enqueues to a bounded `ConcurrentQueue`, returns. A dedicated writer thread drains it.
- **Inbound commands marshal back via `Timer.DelayCall(TimeSpan.Zero, ...)`**, which is lock-protected and cross-thread safe (`Server/Timer.cs:243-251`). The read thread touches no `World`/`Mobile`/`Item` API.
- **Bound the outbound queue** (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard.
- **Never block or throw inside an EventSink handler.** Several are veto hooks sitting in a transaction path.
---
## 3. Prerequisite: fix the build, or the plugin will not load
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) runs `dotnet build Scripts/Scripts.csproj -c Release`, **prints the output, never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Two consequences:
1. A failing script build is **silently ignored** and the previous `Scripts.dll` reloads. (`BRIDGE_FINDINGS.md` §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.)
2. The build passes no `Platform`, so it defaults to `AnyCPU`. `OutputPath` is only set under the `Release|x64` condition, so the DLL lands in `Scripts/bin/Release/` while the server loads `Scripts.dll` from the repo root. **Script edits currently never take effect.**
**Fix before writing any bridge code.** Either add a default `<Platform>x64</Platform>` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch.
---
## 4. Plugin layout
All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug.
| File | Responsibility |
|------|----------------|
| `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. |
| `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. |
| `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. |
| `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. |
| `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. |
| `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. |
| `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. |
**Lifecycle** (`Server/Main.cs:544-562`, all Core thread):
`Configure()``World.Load()``Initialize()``EventSink.ServerStarted`.
Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake.
---
## 5. Data catalog — everything the shard can give you
91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified.
### 5.1 Session & identity
| Signal | Hook | Freq | Notes |
|--------|------|:----:|-------|
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
| Player offline | `EventSink.Logout` | low | Pair with Login. |
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
### 5.2 Character state
| Signal | Hook | Freq | Notes |
|--------|------|:----:|-------|
| **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. |
| **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. |
| Skill progression | `SkillGain` | medium | High-signal. Ship it. |
| Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. |
| Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. |
| Hunger | `HungerChanged` | low | Cosmetic; optional. |
> ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free.
>
> ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7.
### 5.3 Economy & commerce
| Signal | Hook | Freq | Notes |
|--------|------|:----:|-------|
| Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). |
| **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. |
| NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** |
| NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. |
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold.
### 5.4 Housing / IDOC
| Signal | Hook | Freq | Notes |
|--------|------|:----:|-------|
| Decay transition | decay sweep, emit on change | 3060 s | **0.0002 ms/house.** No EventSink exists. |
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
**The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected:
- `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.099.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **1224 h random** (`DynamicDecay.cs:18`).
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
Payload per transition: house serial, `from``to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
### 5.5 Combat, death, PvP
| Signal | Hook | Freq | Notes |
|--------|------|:----:|-------|
| Player death | `PlayerDeath` | low | |
| Murder | `PlayerMurdered` | low | High-signal for the website. |
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
### 5.6 Progression & activity
`QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`.
`OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`.
> ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`.
>
> Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object.
### 5.7 Cheat detection & staff audit
This is where the catalog earns its keep, and it is thin in the original doc.
| Signal | Hook | Why |
|--------|------|-----|
| **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. |
| **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. |
| Staff commands | `EventSink.Command` | Every command invocation. |
| **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. |
| Gold flow | `AccountGoldChange` | Reconcile against sale stream. |
### 5.8 Lifecycle
`ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`.
`AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.**
### 5.9 Known gaps (no clean hook)
- **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.**
- **Per-hit combat damage.** Virtual overrides only.
- **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only.
- **Stat/vital deltas.** Sweep. (Cheap — see §5.2.)
---
## 6. The one core edit: `PlayerVendorSale`
Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer:
```csharp
// PlayerVendorGumps.cs:84-96
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
int commission = 0;
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed
```
At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale.
Three edits, then the bridge stays pure-subscription:
1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape).
2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96.
3. Bridge subscribes in `Initialize` like any other event.
~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs.
---
## 7. Wire protocol
Newline-delimited JSON, one object per line, `serial` as the primary key.
### Outbound (shard → sidecar)
```jsonc
{"t":1752,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2,
"items":206467,"mobiles":42826,"accounts":51}
{"t":1752,"kind":"server.shutdown"}
{"t":1752,"kind":"server.crashed","error":"…"}
{"t":1752,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
{"t":1752,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
"str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
{"t":1752,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
{"t":1752,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
"owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
{"t":1752,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC",
"map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0},
"region":"Britain","name":"The Silver Anvil",
"owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[],
"builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
{"t":1752,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
{"t":1752,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
{"t":1752,"kind":"economy.supply","accounts":51,"gold":110478209}
```
`char.profile` follows the shape in `BRIDGE_FINDINGS.md` §IV.3 — it was correct — with `mods` a flattened union of non-zero entries across `AosAttributes`, `AosWeaponAttributes`, `AosArmorAttributes`, produced by iterating each enum through the bag's indexer (`Scripts/Misc/AOS.cs:924,1464,2238`). No hardcoded property names.
### Inbound (sidecar → shard)
```jsonc
{"kind":"char.request","account":"PerryAdimn","slot":0}
{"kind":"account.roster","account":"PerryAdimn"}
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
{"kind":"towncrier.remove","id":"n123"}
```
Every inbound handler marshals to the Core thread before touching world state.
### `server.hello` is per-connection, not per-boot
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.
`bootId` is a GUID generated at `ServerStarted`. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes *"I reconnected"* (keep cached state) from *"the shard restarted"* (discard it). `connects` is the shard's count of successful connections, so the first `hello` of a run carries `connects:1`.
Counts in `hello` are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning.
### Item names are clilocs
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo**`BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
---
## 8. Corrections to `BRIDGE_FINDINGS.md`
| § | Claim | Reality |
|---|-------|---------|
| §1 | "A compile error in your bridge file takes the whole shard down at boot." | **False.** `Compile()` ignores the build exit code; a failing build silently reloads the stale `Scripts.dll`. Worse: your plugin would appear absent, not broken. See §3. |
| §III.3 | IDOC = 95.099.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 1224 h. Also: `CanDecay` is true only for `Condemned`/`ManualRefresh`, so an active owner's newest house never decays. |
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. |
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
---
## 9. Implementation phases
0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.`
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests.
5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm``WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15.
6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16.
7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17.
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar.
### Config keys (`Config/Bridge.cfg`)
```ini
Host=127.0.0.1
Port=7788
QueueCap=10000
StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
```
Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds``Bridge.StatSweepSeconds`.
---
## 11. Phase 1 acceptance
Run against the seeded shard with `tools/stub_sidecar.ps1`. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed.
| Claim | Evidence |
|-------|----------|
| The shard boots normally with **no sidecar listening**. | World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat. |
| Events emitted while disconnected are **buffered and delivered on connect**. | `server.hello` carried `t=…070312` (boot) but arrived at `…114209`, 44 s later, when the sidecar first appeared. |
| Inbound commands execute on the **Core thread**. | `{"kind":"ping","id":"t1"}``{"kind":"pong","id":"t1"}`. |
| An **unknown kind** is ignored, not fatal. | `[Bridge] no handler for inbound kind 'nonsense.kind'` |
| **Malformed JSON** does not kill the reader. | `[Bridge] malformed inbound line, ignoring`, connection stayed up. |
| Killing the sidecar **does not disturb the shard**. | Shard stayed up, CPU unchanged, no exception, no log spam. |
| The shard **reconnects unattended**. | Second `[Bridge] connected`, `hello` re-sent with `connects:2` and the same `bootId`. |
Two defects were found this way and fixed:
- **Backoff ceiling was 30 s**, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s.
- **A stale reader could kill a fresh connection.** `reader.Join(1s)` can time out, and the old reader's `finally` then set the shared `_dead` flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned.
---
## 17. Phase 7 acceptance
The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`.
Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so.
Verified end to end with a probe that fired the event using **real seeded-vendor data**:
```json
{"kind":"vendor.sale","committed":true,
"buyerSerial":"0x1F8","buyerAcct":"seed_001",
"ownerSerial":"0x1F5","ownerAcct":"seed_000",
"vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword",
"itemId":3937,"amount":1,"price":69819,"commission":0}
```
Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events.
**Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`).
---
## 16. Phase 6 acceptance
`BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary.
Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree:
| Sent | Reply | Crier list |
|------|-------|------------|
| `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines |
| `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list |
| `remove n1` | `towncrier.ok` | entry gone |
| `remove does-not-exist` | `towncrier.error "unknown id"` | no change |
The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged.
Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs.
No core changes; this closes the pure-plugin inbound work.
---
## 15. Phase 5 acceptance
`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`.
Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it):
```
<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300
-> link.confirm code=77M9TK websiteUserId=web-9931
<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931
-> link.confirm code=BADCOD ...
<- link.error code=BADCOD reason="unknown or expired code"
```
**The tag persists.** After a `World.Save()`, `accounts.xml` contained:
```xml
<tags>
<tag name="WebsiteUserId">web-9931</tag>
</tags>
```
This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised.
Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup.
Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`.
---
## 14. Phase 4 acceptance
`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence.
Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread:
- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline.
- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed.
- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree.
- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each.
- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`.
Two things the real character surfaced that the seeded dummies could not:
- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully.
- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking.
Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete.
---
## 13. Phase 3 acceptance
`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters.
Verified on the seeded world with intervals cut to 8 s:
- **Decay is transition-only.** Baseline recorded 29 houses **silently** on `ServerStarted`. A probe bumped one house `Somewhat → Fairly` with `SetDynamicDecay`; the next sweep emitted **exactly one** `house.decay`, none for the other 28:
```json
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
"builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"}
```
- **Economy supply** emitted a snapshot each interval: `{"kind":"economy.supply","accounts":51,"gold":…}`.
- **Vitals** correctly emitted nothing — the seeded characters are all offline (`NetState == null`). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client.
Notes from the run:
- **`region` is null** for the seeded houses — they sit outside any named region. The handler guards `Region`, `Sign`, and `Owner` for null; all three can be absent on abandoned or oddly-placed houses.
- The sweeps **skip emitting when the sidecar is disconnected** (`BridgeLink.Connected`), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless.
- **Config duplicate keys: last write wins** (`Config.cs` does `_Entries[key] = e`), which is why the scaffolding appends test overrides to the end of `Bridge.cfg`.
---
## 12. Phase 2 acceptance
The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar:
```
{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
{"kind":"karma.change",...,"old":7903,"new":7853}
{"kind":"world.save.before"}
{"kind":"world.save.after","items":206312,"mobiles":42826}
```
`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.
### The finding: `SkillGain` fires for NPCs, hard
The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events.
This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.
### Safety facts baked into the handlers
- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process.
- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these.
- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer.
- The property is `FastWalkEventArgs.NetState`, not `.State`.
---
## 10. Operational notes
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
- **Pending link codes are in-memory** and lost on crash. Acceptable — the player re-runs `[link`.
- **`zlibwapi64` `DllNotFoundException`** already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
- The bridge should carry the resolved `websiteUserId` on every player event once the account tag is read at `Login` and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.

View File

@@ -1,544 +0,0 @@
# ServUO ⇄ External Service Bridge — Research Findings
**Status:** Research only, no implementation.
**Architecture:** Rust sidecar owns a bidirectional WebSocket + JSON endpoint for the website; ServUO links to it over a **local loopback socket**. Tracking players/stats/gold/economy/NPC+player-vendor sales, IDOC/house decay, in-game **`[link`** account linking, and website→game town-crier news. See **Part II** (design/transport/tracking/link), **Part III** (player-vendor, IDOC, town crier, config), and **Part IV** (full character profiles — gear/skills/stats, online & offline, up to 5/account).
**Date:** 2026-07-07
**Codebase:** ServUO 57.4 (this repo, `C:\Users\colby\Desktop\servuo`), target framework **.NET Framework 4.8 / x64**.
**Method:** Grounded in this repo's source. Where the running server would normally be used to confirm behavior, see the note in [§0](#0-note-on-empirical-verification) — the shard was **not running** at research time, so live-boot verification was deliberately skipped and replaced with source-level proof plus evidence from this repo's own crash logs. A ready-to-run empirical probe is included in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself).
---
## 0. Note on empirical verification
You said the shard was running and to verify against it. At research time **no `ServUO.exe` / `dotnet` process was live** (`Get-Process` returned nothing; `Logs/Console.log` absent). I chose **not** to boot it myself because a cold boot on this machine would:
- shell out to `dotnet build Scripts.csproj` (per `ScriptCompiler.Compile`, `Compiler.Dynamic=true` by default),
- **bind the live game port** and load/write your actual `Saves/` world (117 mobiles / 2469 items per the last crash report),
- run `EventSink.ServerStarted` and AutoSave against real state.
That's outward-facing and hard to reverse, so it needs your go-ahead. **It turned out not to be necessary for the core threading claims**, because:
1. The source pins the threading model exactly (call sites shown below), and
2. **Your own crash log is live evidence.** `Crash 6-5-2026-22-38-3.log` contains this stack:
```
Server.EventSink.InvokeClientVersionReceived(...)
Server.Network.MessagePump.HandleReceive(NetState ns)
Server.Network.MessagePump.Slice()
Server.Core.Main(String[] args)
```
That is a network-triggered EventSink handler executing **inside `MessagePump.Slice()`, called directly from `Core.Main`** — i.e. on the Core (main) thread, synchronously in the game loop. This is exactly the thread-identity fact item 3/5 hinges on, captured from this instance at runtime.
If you want the live thread-ID trace anyway (Timer + ServerStarted, no client needed), drop in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself) and start the shard, or tell me to boot it.
> ⚠️ Unrelated but worth flagging: that crash was `DllNotFoundException: zlibwapi64`. The DLL **is** present in the repo root, so this is a working-directory / native-load-path issue that has already crashed your shard once when sending a packed gump. Not a bridge concern, but it will bite the bridge too if the bridge ever triggers gump sends. Track separately.
---
## PART II — Re-evaluation for the Rust WebSocket sidecar (READ FIRST)
**Confirmed architecture (from you):** a **Rust sidecar** holds a bidirectional **WebSocket** connection and exposes a **JSON endpoint the website consumes**. Goals: track players + stats, gold, overall economy, vendor sales; and an in-game **`[link`** command that ties a game account to a website account.
The §1§5 findings below are unchanged and still govern (lifecycle, events, timers, threading). This part maps them onto *your* design and supersedes the old §6/§7.
### II.1 Transport: put the WebSocket in Rust, keep the C# side dumb
```
ServUO plugin (C#, net48) ──local loopback, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
(main-thread events) ◄──inbound commands (link, etc.)──┘ (owns WS, buffering, auth, fan-out)
```
**Recommendation: ServUO ↔ sidecar = a plain local TCP loopback socket (`127.0.0.1`), newline-delimited JSON, bidirectional. Do NOT make ServUO speak WebSocket.**
- `System.Net.WebSockets.ClientWebSocket` *does* exist on net48 + Windows 11 and would work, but it's the wrong place for WS complexity. The sidecar already terminates WS for the website; a second WS hop inside the shard buys nothing and adds a heavier, blockier client on the one thread you must never block (§5). A raw `TcpClient` with `\n`-framed JSON is ~30 lines of C#, trivially non-blocking, and lets the **sidecar restart independently** without touching the shard.
- Named pipes (old §6) also work and are fine if you prefer them; loopback TCP is marginally simpler cross-process and cross-language (Rust `tokio::net::TcpListener` ↔ C# `TcpClient`).
- **This split is exactly what §5 demands.** All backpressure, reconnect, retry, website fan-out, and schema validation live in **Rust**. ServUO only ever does: (outbound) format a small JSON line → enqueue → a background writer thread drains to the socket; (inbound) a background read loop parses a line → `Timer.DelayCall` to the main thread. A slow or absent website can never stall the shard, because the Rust side owns the buffer and the socket write from C# is to loopback with a bounded local queue in front of it.
**Framing:** newline-delimited JSON objects (`{...}\n`), `PipeTransmissionMode`/message-mode not needed. One writer thread on the C# side keeps event ordering intact. Bound the outbound queue (drop-oldest + a dropped-counter) so a stalled sidecar can't OOM the shard.
### II.2 Tracking targets → concrete hooks (and the gaps)
| Target | Hook | Freq | Notes / caveats |
|--------|------|------|-----------------|
| **Player online / identity** | `EventSink.Login` / `Logout` | Low | Snapshot `Account.Username`, char name, `Mobile.Serial`, `Map`, `Location`. Best per-player anchor. |
| **Player stats** (Str/Dex/Int, Hits/Mana/Stam, skills, Fame/Karma) | ⚑ **No per-change EventSink** | — | Strategy: full snapshot on `Login`, then a **periodic sweep** (every 1530 s) of online `PlayerMobile`s pushed as-is; let the **sidecar diff** and forward only changes. Add `FameChange`/`KarmaChange`/`SkillGain` for high-signal jumps. Don't try to hook the per-stat delta system — it's invasive and firehose-y. |
| **Gold (per player)** | `EventSink.AccountGoldChange` | LowMed | ✔ **AccountGold is ENABLED on this shard** (expansion EJ ≥ TOL, `CurrentExpansion.cs:20`). Args give `IAccount` + `OldAmount`/`NewAmount` (`TotalCurrency`, a `double`). Most gold flow fires this. Caveat: physical coins/checks sitting in a bankbox aren't fully reflected here — see economy row. |
| **Overall economy / money supply** | Periodic account sweep + flow events | Low | Money **supply** = periodic sum of `TotalCurrency` across all `Accounts` (+ optionally bankbox coin/check items) on the main thread, pushed as a snapshot. Money **velocity/flow** = the `AccountGoldChange` + vendor-sale event stream. Sidecar aggregates both. |
| **NPC vendor — player buys** | `EventSink.ValidVendorPurchase` | Med | Args: `Mobile` (buyer), `Vendor`, `Bought` (IEntity/item), `AmountPerUnit`. **Total = AmountPerUnit × stack `Amount`.** Raised from `GenericBuy.cs:379`. |
| **NPC vendor — player sells** | `EventSink.ValidVendorSell` | Med | Args mirror above (`Sold`, `AmountPerUnit`). Raised from `BaseVendor.cs:2209`. |
| **Player vendor sales** | ⚑ **No EventSink (gap)** | Med | Player-vendor buys go through `PlayerVendor.TryToBuy` (`PlayerVendor.cs:447`), not the Valid* events. To capture these you must override/patch the PlayerVendor buy completion. Flag if the spec counts player-vendor commerce as "vendor sales." |
| **Account ↔ website link** | `Account.Username` + `Account.SetTag/GetTag` | — | `SetTag("WebsiteUserId", id)` persists to `accounts.xml` across restarts (`Account.cs:1078,1093`). No schema/DB work needed on the C# side. |
> ⚠️ The `Valid*` vendor events are **validation-stage veto hooks**, not "sale committed" callbacks. They fire when the purchase is being validated; in rare cases a sale could still fail afterward. For coarse economy metrics that's fine; if you need exact ledger accuracy, treat them as "sale attempted" and reconcile against `AccountGoldChange`, or hook the actual completion path. **Never block or throw in these handlers** — you're inside the transaction path.
### II.3 The `[link` command flow
Prefix is `[` (`Commands.cs:131`), so `[link` is registered directly. Everything below runs on the main thread except the socket I/O.
1. **Register** in your plugin's `Initialize()`:
`CommandSystem.Register("link", AccessLevel.Player, OnLink);`
2. **`[link` handler** (`e.Mobile`): read `e.Mobile.Account as Account`. If already tagged (`GetTag("WebsiteUserId") != null`), tell them so. Otherwise generate a **short, one-time, expiring code** (e.g. 68 chars, 5-min TTL), store `code → {accountUsername, expiry}` in an in-memory dict (main thread), and:
- push `{"kind":"link.request","code":"AB12CD","account":"PerryAdimn","char":"Thunderheat"}` to the sidecar, and
- `e.Mobile.SendMessage("Enter code AB12CD at https://yoursite/link to connect your account.")`
3. **Website** (user logged in there) submits the code → sidecar → ServUO inbound line `{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}`.
4. **Inbound handler** marshals to main thread (`Timer.DelayCall`), validates code + TTL, then `account.SetTag("WebsiteUserId","9931")`, drops the code, and replies `{"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}`. Optionally `SendMessage` the player if still online.
5. **Thereafter**, every player event you emit can carry the resolved `websiteUserId` (read the tag on Login and cache account→id in the sidecar), so the website can attribute stats/gold/sales to a site user.
Security notes: codes one-time + short-TTL; the link socket is **loopback-only** (bind `127.0.0.1`, never `0.0.0.0`); the account write happens on the main thread; rate-limit `[link` per account to avoid code spam. Treat `websiteUserId` from the sidecar as trusted only because the socket is local — if the sidecar is ever exposed, add a shared secret.
### II.4 Revised flags for THIS architecture
1. **✔ Threading is a solved problem given the split.** Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget + `Timer.DelayCall` inbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO.
2. **⚑ Player stats have no change-event** → sweep-and-diff in the sidecar (II.2). Budget for a 1530 s snapshot of online players; don't expect push-on-change.
3. **⚑ Player-vendor sales aren't covered by any EventSink** (II.2) — **RESOLVED in §III.1.** You've confirmed this stream is critical (economy + cheat detection), so add the small `PlayerVendorSale` EventSink (~15 lines of core instrumentation). It's the one non-drop-in piece.
4. **⚑ "Economy" needs both a periodic supply snapshot and the flow stream.** `AccountGoldChange` alone is flow, not total; physical bank coins/checks aren't in it. Do a periodic `Accounts` `TotalCurrency` sum for money supply.
5. **✔ Linking needs no new persistence layer** — account tags serialize to `accounts.xml` for free (II.3). Survives restarts and saves.
6. **⚑ Commands/inbound don't apply during world saves** (§5 pitfall 3, ~every 5 min). A `[link.confirm` arriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly.
7. **⚑ Crash path skips `Shutdown`** (§1): the sidecar must treat socket EOF as normal and reconnect; don't rely on a clean goodbye frame. Pending link codes are in-memory and lost on crash — acceptable (user re-runs `[link`).
8. **⚑ (unchanged) Item pickup/drop and per-hit combat have no EventSink** (§2 gap) — only relevant if the tracking scope grows beyond stats/gold/economy/vendors.
---
## PART III — Player-vendor tracking, IDOC, town-crier news, config
Follow-ups you added: **(1)** player-vendor tracking is *critical* (economy balance + admin cheat detection); **(2)** the 30 s stat sweep must be config-editable; **(3)** hook **IDOC / house decay**; **(4)** town criers receive **news pushed from the website**.
### III.1 Player-vendor sales — the one place you need a small core touch
There is genuinely **no EventSink** on the player-vendor buy path (confirmed). The purchase *completes* in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), specifically at the gold transfer:
```csharp
// PlayerVendorGumps.cs ~line 81-96 (existing code)
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
...
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited ◄── sale is now committed
```
At that point every field cheat-detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the real player who profits), **item** (`m_VI.Item`, incl. `Serial`, type, `Amount`), **price** (`m_VI.Price`), and **commission**. This is *better* data than the NPC-vendor `Valid*` events (which lack owner + commission), and unlike them it fires on a **committed** sale, not a validation stage.
**Recommendation (idiomatic, minimal): add a first-class EventSink event, mirroring the existing vendor events.** Three tiny edits, then the bridge stays pure-subscription like everything else:
1. In `Server/EventSink.cs`: declare `public static event PlayerVendorSaleEventHandler PlayerVendorSale;`, an `InvokePlayerVendorSale`, and a `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape at `EventSink.cs:1508`).
2. In `PlayerVendorGumps.cs`, one line right after the `HoldGold +=` at ~line 96:
`EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));`
3. Bridge subscribes in `Initialize` like any other event.
This is **the single spot where the bridge can't be pure drop-in** — worth calling out explicitly since I'd earlier listed player vendors as a "gap." It's a ~15-line core instrumentation, not a rework. (Alternative if you refuse to touch core scripts: a periodic diff of every `PlayerVendor`'s inventory + `HoldGold` — but that can't attribute the *buyer*, which is exactly what cheat detection needs, so it's a poor substitute.)
**For cheat detection specifically**, emit per sale: buyer serial+account, owner serial+account, item type/serial/amount, price, commission, vendor serial, house/region, timestamp. The sidecar can then flag e.g. same-account buyer≈owner (gold laundering), wildly off-market prices, or burst patterns. Note `m_Vendor.Owner` + `from.Account` are the two identities that matter; both are readable synchronously in the handler (main thread).
### III.2 Config-editable sweep interval (and other tunables)
Use ServUO's own config system (`Server/Config.cs`), which reads `Config/*.cfg`. Read tunables in `Configure()` (runs before world load):
```csharp
StatSweep = Config.Get("Bridge.StatSweepSeconds", 30);
DecaySweep = Config.Get("Bridge.DecaySweepSeconds", 60);
```
Drop a `Config/Bridge.cfg` with `Bridge.StatSweepSeconds=30` etc. `Config.Get<T>` handles `int`/`TimeSpan`/`bool`. Make the sweep timer re-readable on demand (a `[bridge reload` admin command that re-reads config and re-arms the `Timer`) so you can retune without a restart. Store all bridge knobs (sweep intervals, which event streams are enabled, sidecar host/port, queue cap) in that one cfg.
### III.3 IDOC / house decay — sweep `BaseHouse.AllHouses`, emit on transition
Also **no EventSink** here. The model (`Scripts/Multis/BaseHouse.cs`):
- `DecayLevel` enum (`BaseHouse.cs:4341`): `Ageless, LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, DemolitionPending`. **IDOC = 95.099.9%** of the decay period elapsed (`GetOldDecayLevel`, `BaseHouse.cs:211-213`); `Collapsed` = 100%.
- `BaseHouse.AllHouses` is a static list of every house; `Decay_OnTick` (`BaseHouse.cs:59`) already periodically calls `CheckDecay()` on all of them.
- The `DecayLevel` getter has internal transition detection (`m_LastDecayLevel`, `BaseHouse.cs:193`) but it's private and only invalidates the sign — **not** exposed as an event.
**Decision: emit on transition only, tracked plugin-side.** A low-frequency **sweep** (3060 s, config per III.2) over `BaseHouse.AllHouses` reads `house.DecayLevel` on the main thread. The plugin holds a `Dictionary<Serial, DecayLevel>` of last-known levels and emits **only when a house's level changes** — no per-sweep spam, one message per real transition. Houses number in the hundreds/thousands (not the mobile firehose), so the sweep is cheap even though we scan all of them each pass.
**State & re-baseline (important, since the plugin now holds state):**
- The last-known map is **in-memory and resets on restart**. On `ServerStarted` (§1), do a **silent baseline pass**: populate the dictionary from the current `DecayLevel` of every house **without emitting** — otherwise every house re-announces its current stage on every boot. Optionally emit a single `idoc.snapshot` of all houses already at IDOC/Collapsed so the website/admin panel is correct immediately after a restart, clearly flagged as a snapshot (not a transition).
- Emit direction matters for cheat/economy signals: include both `from`/`to` levels so the consumer can tell decay progression from a **refresh** (owner logged in → level jumps back toward `LikeNew`; `RefreshDecay`, `BaseHouse.cs`). A house leaving IDOC because someone refreshed it is itself a useful signal.
- `house.DecayLevel` is a computed property — read it **once per house per sweep** into a local, don't call it repeatedly.
**Payload (home location state you asked for — all readable synchronously in the sweep):** `BaseHouse` is a `BaseMulti` (an item), so it has `Serial`, `Location`/`X`/`Y`/`Z`, `Map`. Plus:
| Field | Source |
|-------|--------|
| house serial | `house.Serial` |
| decay from → to | tracked dict → `house.DecayLevel` |
| coords + facet | `house.X/Y/Z`, `house.Map` |
| stable landmark (where a player stands) | `house.BanLocation` (`BaseHouse.cs:3637`) |
| region / area name | `house.Region` (`:3672`) → `Region.Name` |
| house name | `house.Sign?.GetName()` (`:2108`) |
| owner | `house.Owner` (`:3564`) → serial + `Owner.Account.Username` (may be null if abandoned) |
| co-owners / friends | `house.CoOwners`, `house.Friends` (`:3679-3680`) — serials/accounts |
| built / last refreshed | `house.BuiltOn`, `house.LastRefreshed` (`:3786,:66`) |
| time-to-collapse | `house.NextDecayStage` and/or derive from `LastRefreshed + DecayPeriod` |
Example emit:
```jsonc
{ "kind":"house.decay", "serial":"0x40001234", "from":"Greatly", "to":"IDOC",
"map":"Felucca", "x":1420, "y":1631, "z":0, "ban":{"x":1422,"y":1635,"z":0},
"region":"Britain", "name":"The Silver Anvil",
"owner":{"serial":"0x1A2B","account":"PerryAdimn"},
"coOwners":[], "builtOn":"2026-01-02T...", "lastRefreshed":"2026-06-30T...",
"collapseEta":"2026-07-08T..." }
```
This gives the website a live IDOC feed with exact map pins and the admin side an owner-attributed decay timeline. Guard against `Owner`/`Sign`/`Region` being null (abandoned or mid-demolition houses).
### III.4 Town-crier news pushed from the website (inbound → main thread)
Clean API, no core changes needed: `GlobalTownCrierEntryList.Instance.AddEntry(string[] lines, TimeSpan duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`) posts a **global** entry that *every* town crier announces until it expires; `RemoveEntry(entry)` pulls it early. `AddEntry` returns the `TownCrierEntry`.
**Flow:** website publishes news → sidecar → ServUO inbound `{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","The market tax is now 5%."],"durationSec":3600}` → **marshal to main thread** (`Timer.DelayCall`) → `var e = GlobalTownCrierEntryList.Instance.AddEntry(lines, TimeSpan.FromSeconds(durationSec));` and stash `id → e` so a later `{"kind":"towncrier.remove","id":"n123"}` can call `RemoveEntry(e)`.
Must run on the main thread (mutates a shared list and sends packets to crier NPCs) — same marshaling rule as `[link` (§II.3 / §5). Guard against abuse: cap line length/count and active-entry count in the handler; the socket being loopback-only is your trust boundary. Note the crier speaks lines on its own timer, so there's a natural delay before players hear it — fine for news.
### III.5 Updated capability map
| Capability | Mechanism | Core touch? | Runs on |
|-----------|-----------|:-----------:|---------|
| Player online/stats/gold | EventSink + 30 s sweep (§II.2) | No | main thread |
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | main thread |
| **Player-vendor sales** | **new `PlayerVendorSale` EventSink** (§III.1) | **Yes, ~15 lines** | main thread |
| `[link` account linking | `CommandSystem.Register` + account tags (§II.3) | No | main thread |
| IDOC / house decay | sweep `BaseHouse.AllHouses` on transition (§III.3) | No | main thread |
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | main thread (marshaled) |
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
**Net:** everything you listed is doable, and **only player-vendor sales requires a (small, idiomatic) core edit** — which is justified because it's your critical/cheat-detection stream and reflection-based alternatives can't identify the buyer.
---
## PART IV — Full character profiles (armor / weapons / skills / everything)
You want the site's **player endpoint** to show a whole character — worn gear, weapon/armor detail, every skill, all stats — for **up to 5 characters per account**, online *or* offline, and eventually their vendor stats. The object model supports all of it; the design question is *how to ship it without turning the 30 s sweep into a firehose.*
### IV.1 It's all on the live `Mobile` — and offline chars stay resident
- **Account → characters:** `Account` holds `Mobile[] m_Mobiles` with `account.Length` slots and `account[index]` (`Account.cs:592,598`); non-null slots are the characters (max 5, engine allows up to 7). Iterate them to enumerate an account's roster.
- **Offline = still in memory.** Mobiles are removed from `World.Mobiles` **only on `Delete()`, never on logout.** A logged-off character is a live `Mobile` with `NetState == null`; all its gear/skills/stats are intact. **→ the bridge can build a full profile for any character at any time, online or offline** — exactly what "see my characters from the website" needs. `m.NetState != null` (or `m.Player && online`) is your online flag.
- **Stats/vitals** (`Server/Mobile.cs`): `Str/Dex/Int` (`:8276+`), `Hits/HitsMax`, `Mana/ManaMax`, `Stam/StamMax` (`:8554+`), the five resists `PhysicalResistance…EnergyResistance` (`:931+`), `VirtualArmor`, plus `Fame`, `Karma`, `Luck`, `TotalWeight`, `Title`, `Body`, `Hue`, `Name`.
- **Skills** (`Server/Skills.cs`): `m.Skills` is `IEnumerable<Skill>` (`:1099`) with `Length` + indexer. Each `Skill`: `SkillName`, `Base`, `Value` (base + item/temp bonuses), `Cap`, `Lock` (`Skills.cs:259,322,373,350,269`). Emit all ~58.
- **Worn equipment:** `m.Items` (`Mobile.cs:6695`) is the list of *equipped* items (one per `Layer`); `FindItemOnLayer(Layer)` (`:10545`) fetches a slot. `Layer` enum (`Item.cs:25`) covers the ~25 wearable slots (OneHanded, TwoHanded, Helm, Gloves, Ring, Neck, Arms, InnerTorso, Talisman, …). Filter out non-gear layers (Backpack, Bank, Mount, Hair/FacialHair) unless you want them.
- **Weapon/armor detail** (`BaseWeapon.cs`, `BaseArmor.cs`): rich AOS attribute objects — `Attributes` (`AosAttributes`), `WeaponAttributes`, `ArmorAttributes`, `AosElementDamages`, `ExtendedWeaponAttributes`, `NegativeAttributes`, plus `MinDamage/MaxDamage/StrRequirement` (weapon) and `BaseArmorRating`/resists (armor). **Each attribute bag exposes an enum indexer** — `AosAttributes[AosAttribute]`, `AosWeaponAttributes[AosWeaponAttribute]`, `AosArmorAttributes[AosArmorAttribute]` (`Scripts/Misc/AOS.cs:924,1464,2238`) — so you can **flatten every mod generically** by iterating the enum and emitting non-zero entries, without hardcoding 30+ property names.
### IV.2 Ship it tiered + on-demand (don't stream heavy profiles blindly)
A full profile ≈ 58 skills + ~15 gear items each with a mod table. Pushing that for every character every 30 s (× N accounts, most idle/offline, most unviewed) is wasteful. Split by volatility:
| Tier | Contents | When emitted |
|------|----------|--------------|
| **Vitals** (small, volatile) | hits/mana/stam, current str/dex/int, gold, location, online flag | 30 s sweep of **online** players + events |
| **Profile** (large, semi-static) | all skills, worn equipment + item mods, resists, caps, fame/karma/luck | on `Login`, on equip/skill change, and **on demand** |
**On-demand request/response drives the website player endpoint.** When the site opens a character page: website → sidecar → ServUO `{"kind":"char.request","account":"PerryAdimn","slot":0}` (or by serial) → marshal to main thread → build the full profile → reply `{"kind":"char.profile", …}`. The **sidecar caches** the last profile so the page renders instantly and the game only rebuilds on request or on change. This scales: you never pay to serialize characters nobody is looking at. (For a "roster" view, a light `{"kind":"account.roster"}` returning name/body/slot/online per character is enough; fetch the heavy profile only when a specific char is opened.)
### IV.3 Character-profile schema (sketch)
```jsonc
{
"kind": "char.profile",
"account": "PerryAdimn", "slot": 0,
"serial": "0x0075", "name": "Thunderheat", "title": "the Legendary",
"body": 400, "hue": 33770, "online": true,
"stats": { "str":100,"dex":90,"int":45, "hits":95,"hitsMax":100,
"mana":40,"manaMax":45,"stam":88,"stamMax":90,
"resist":{"phys":70,"fire":68,"cold":55,"pois":60,"energy":62},
"gold":124500, "fame":12000,"karma":-4000,"luck":140,"weight":320 },
"skills": [ {"name":"Swords","base":100.0,"value":120.0,"cap":120.0,"lock":"Up"},
{"name":"Tactics","base":100.0,"value":110.0,"cap":120.0,"lock":"Locked"} /* …all */ ],
"equipment": [
{ "serial":"0x4001A2","layer":"TwoHanded","itemId":5046,"hue":0,
"name":null,"cliloc":1023721, // resolve name via cliloc (IV.4)
"weapon":{"minDamage":16,"maxDamage":18,"strReq":40},
"mods":{"WeaponDamage":50,"HitLightning":40,"SwingSpeedIncrement":30,"DefendChance":15} },
{ "serial":"0x4002B3","layer":"InnerTorso","itemId":7168,"hue":1157,
"name":"Ancient Plate","armor":{"baseRating":45},
"mods":{"ResistFireBonus":15,"LowerManaCost":8,"BonusHits":5} }
],
"vendorsOwned": 3 // future (IV.5)
}
```
Locks/enum values serialize as their names. `mods` is the flattened non-zero union across the item's attribute bags.
### IV.4 Gotchas for the profile export
- **⚑ Item names are usually clilocs, not strings.** `Item.Name` (`Item.cs:4860`) is frequently `null`; the real display name is `LabelNumber` (`:3771`), a cliloc ID resolved against `Data/Cliloc.enu`. For the website either (a) resolve cliloc → text server-side from the cliloc file and send the string, or (b) send the number and resolve on the site with a cliloc map. Crafted/renamed items *do* carry a plain `Name`. Send both (`name` + `cliloc`) and prefer `name` when present.
- **⚑ Don't recurse the whole backpack/bank by default.** A pack can hold hundreds of nested items — that's a different (huge) payload than "what they're wearing." Ship **worn equipment** fully; expose backpack/bank as an opt-in or a summarized count, not a default deep dump.
- **Building a profile allocates** (skill list + per-item mod scans). Keep it on-demand / on-change, **not** in the 30 s vitals sweep. A burst of `char.request`s should be fine (main-thread, fast) but rate-limit at the sidecar.
- **`Value` vs `Base` for skills:** `Base` is the trained number; `Value` includes item/temp bonuses (what the client shows in combat). Send both — the site likely wants `Base` for "character sheet" and `Value` for "effective."
- **Read on the main thread only.** Everything above touches live `Mobile`/`Item` state (§5). Build the DTO synchronously in the request handler / sweep, hand the finished JSON to the writer thread.
### IV.5 Vendor stats per player (the "eventually")
Ties into §III.1. A character/account can own player vendors; each `PlayerVendor` has `Owner`, an inventory of `VendorItem`s (item, `Price`, description), `HoldGold`, `BankAccount`, and commission. For a player-facing "my vendors" view, enumerate `PlayerVendor`s whose `Owner` is one of the account's mobiles and emit: vendor serial, house/location, held gold, and inventory (item, price, sold-state). Combined with the §III.1 `PlayerVendorSale` stream, the site can show both **current listings** and **sales history**. Same tiered/on-demand rule — fetch on request, refresh on sale.
### IV.6 Updated capability map (supersedes III.5)
| Capability | Mechanism | Core touch? | Cadence |
|-----------|-----------|:-----------:|---------|
| Player vitals (hp/mana/stam/gold/loc) | 30 s sweep of online + events | No | periodic/event |
| **Full character profile** (stats/skills/gear/mods) | build from live `Mobile`, **on-demand + on-change** (§IV) | No | request/response + on change |
| Account roster (up to 5 chars) | `account[0..Length]`, incl. offline (§IV.1) | No | on request |
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | event |
| Player-vendor sales | new `PlayerVendorSale` EventSink (§III.1) | **Yes, ~15 lines** | event |
| Player-owned vendor stats | enumerate `PlayerVendor` by owner (§IV.5) | No | on request |
| `[link` account linking | `CommandSystem` + account tags (§II.3) | No | event |
| IDOC / house decay | sweep `AllHouses`, transition-only (§III.3) | No | 3060 s sweep |
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | inbound |
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
**Net:** the full-character requirement adds **no** new core touches — it's all readable off live objects. The only structural addition it implies is an **inbound request/response channel** (already needed for `[link` and town-crier), used here as `char.request` / `account.roster`, with the sidecar caching profiles for the website.
---
## 1. Script lifecycle — how `Scripts/Custom` loads and hooks startup/shutdown
**Compilation model (this is a *modern* ServUO, not the old CodeDom one).**
`Server/ScriptCompiler.cs:18` → when `Compiler.Dynamic` is true (default), the core literally runs:
```
dotnet build "Scripts/Scripts.csproj" -c Release (or Debug)
```
then `Assembly.LoadFrom("Scripts.dll")` (`ScriptCompiler.cs:63`). `Scripts.csproj` is SDK-style (`Microsoft.NET.Sdk`) with **default globbing**, so **every `.cs` anywhere under `Scripts/` — including `Scripts/Custom/` — is compiled automatically**. There is no per-file registration. A new plugin = drop a `.cs` file in `Scripts/Custom/` and restart (or rebuild `Scripts.dll`).
- If `dotnet build` fails, the core loops asking to retry (`Main.cs:525`); under `-service` it just returns/exits. So **a compile error in your bridge file takes the whole shard down at boot** — keep the plugin minimal and defensive.
- `-service`/non-interactive suppresses the console prompt (`Main.cs:386`).
**Lifecycle entry points (in boot order, all on the Core thread — `Main.cs:544-562`):**
| Order | Mechanism | How you hook it |
|------:|-----------|-----------------|
| 1 | `ScriptCompiler.Invoke("Configure")` | Any `public static void Configure()` in any script type |
| 2 | `World.Load()` | (world state restored from `Saves/`) |
| 3 | `ScriptCompiler.Invoke("Initialize")` | Any `public static void Initialize()` in any script type |
| 4 | `EventSink.InvokeServerStarted()` | `EventSink.ServerStarted += ...` |
`Invoke()` (`ScriptCompiler.cs:87`) reflects over **all** loaded types, finds the named `public static` method, sorts by `[CallPriority(n)]` (`Server/Attributes.cs:27`), and calls them. **`Configure` runs *before* `World.Load`; `Initialize` runs *after*.** → Register EventSink handlers in `Initialize` (or `Configure`); read config in `Configure`. Canonical example already in-tree: `Scripts/Misc/WeightOverloading.cs:15` subscribes to `EventSink.Movement` inside `Initialize()`.
**Shutdown.** Two clean hooks, both fire on the Core thread:
- `EventSink.Shutdown` — invoked from `Core.HandleClosed()` (`Main.cs:313`) on normal exit, *after* `World.WaitForWriteCompletion()`. **Not** invoked if `_Crashed`.
- `EventSink.Crashed` — invoked from the unhandled-exception handler (`Main.cs:198`); gives you an `args.Close` vote.
- Windows console-close / Ctrl-C routes through `OnConsoleEvent` → `Kill()` → `HandleClosed()` (`Main.cs:254`), so `Shutdown` normally still fires.
**Bridge implication:** your named-pipe writer/listener should be **created in `Initialize` (or on `ServerStarted`) and torn down in `Shutdown`**. Don't assume `Shutdown` runs on a crash — the pipe handle may be abandoned; the external service must tolerate an abrupt EOF.
---
## 2. EventSink — available events, subscription, and frequency
**Subscription pattern:** `EventSink.<Name> += handler;` (static multicast delegates, declared `Server/EventSink.cs:1692-1784`). Handlers are plain delegates invoked synchronously via `EventSink.Invoke<Name>(args)` from the code path that raises them. **Every handler runs on whatever thread raised the event — in practice always the Core thread** (movement, speech, combat, login all originate from packet handling in `MessagePump.Slice()` or from the main-loop delta processing).
### Events relevant to a state-export bridge
| Event | Fires when | Frequency | Notes for export |
|-------|-----------|-----------|------------------|
| `Login` | Player fully in-world | Low | Best "player online" signal; gives `Mobile`. |
| `Logout` | Player disconnect (in-world) | Low | Pair with Login. |
| `Connected` / `Disconnected` | Socket up/down | Low | Lower-level than Login/Logout (fires for char-select too). |
| `PlayerDeath` | Player dies | Low | `PlayerDeathEventArgs` (mobile, corpse-ish context). |
| `CreatureDeath` | NPC/creature dies | **MediumHigh** | Fires for *every* mob kill; on a busy shard this is a firehose. Filter/aggregate. |
| `Speech` | Player/NPC speech | Medium | `SpeechEventArgs`; raised from `Mobile.cs:5114`. Includes NPC/system speech. |
| `Movement` | **Any mobile takes a step** | **Very High** | See ⚠️ below. |
| `AggressiveAction` | Combat aggression declared | MediumHigh | `AggressiveActionEventArgs` (`EventSink.cs:372`). Not per-swing, per aggression state change. |
| `ItemCreated` / `ItemDeleted` | Item constructed/deleted | **Very High** | Fires for *every* item incl. transient/loot/internal. Huge volume. |
| `MobileCreated` / `MobileDeleted` | Mobile constructed/deleted | High | Same caveat as items. |
| `SkillGain`, `CraftSuccess`, `ResourceHarvestSuccess` | Progression | Medium | Good "interesting player activity" signals. |
| `AccountGoldChange`, `FameChange`, `KarmaChange` | Economy/rep deltas | LowMedium | Naturally diff-shaped. |
| `QuestComplete`, `JoinGuild`, `TameCreature`, `PlayerMurdered` | Milestone events | Low | Cheap, high-signal — ideal to export. |
| `WorldSave` / `BeforeWorldSave` / `AfterWorldSave` | Save cycle | Low (~5 min) | Natural checkpoint boundary for the bridge. |
| `ServerStarted` / `Shutdown` / `Crashed` | Lifecycle | Once | Bridge connect/disconnect signaling. |
Full list of 70+ events at `EventSink.cs:1692-1784` (context menus, vendor buy/sell, BOD, virtue, targeting macros, etc.).
> ⚠️ **`Movement` is the single most dangerous event to naively export.** `EventSink.InvokeMovement` is called from `Mobile.InternalOnMove` (`Mobile.cs:3029`), which runs for **every mobile that takes a step — all NPCs, all creatures, not just players.** On a populated shard that's thousands of invocations/second. It is **synchronous and cancellable** (`args.Blocked` gates the move), so your handler sits *inside the movement decision path* — any latency there (a blocking pipe write!) stalls the whole server. Additionally the args object is **pooled and immediately `Free()`d** (see §5). Rules: filter to `PlayerMobile` at the top of the handler, copy out primitives synchronously, never block, never retain the args reference.
### ⚑ Gap flag — events with *no* clean EventSink hook
These are things a bridge spec commonly wants to export but that **do not have a first-class `EventSink`**:
- **Item pickup / drop / "lift".** There is **no `EventSink` for picking up or dropping items.** It's handled by **virtual methods** on the objects: `Item.OnDragLift` / `Item.OnDragDrop` / `Item.OnDroppedInto` (`Item.cs:4647,2157,5060`) and `Mobile.OnDragDrop` / `Mobile.OnDragLift` (`Mobile.cs:10877,10949`). To observe these you must **override them on your own subclasses** or patch base classes — you can't subscribe globally from `Initialize`. Partial coverage exists via `EventSink.OnItemObtained`, `EventSink.ContainerDroppedTo`, and `EventSink.CorpseLoot`, but none of these is a universal "player moved item X from A to B" hook. **This is the biggest event-availability gap for the bridge.**
- **Per-hit combat damage.** `AggressiveAction` marks aggression, not each swing/damage tick. For damage numbers you'd hook `Mobile.Damage` / weapon `OnHit` paths (virtual/override), not an EventSink.
- **Equip/unequip of items generally.** `CheckEquipItem` exists (a *veto* hook), plus `EquipMacro`/`UnequipMacro` (macro-triggered only). No clean "item equipped" firehose via EventSink.
- **Stat/hits/mana/stam changes.** No EventSink; these move through the delta/`ProcessDeltaQueue` system (§4). You'd poll or hook `Mobile` delta handling.
---
## 3. Timers — mechanism and which thread callbacks run on
**This is the crux, and the answer is unambiguous.** ServUO splits timers into a *scheduler thread* and *main-thread execution*:
- **Timer Thread** (`Main.cs:429-434`, named `"Timer Thread"`) runs `Timer.TimerThread.TimerMain` (`Timer.cs:314`). Its *only* job is bookkeeping: walk the priority buckets, decide which timers are due, and **enqueue** them into a shared `m_Queue` (`Timer.cs:354-357`). It **does not execute callbacks.** When anything becomes due it calls `Core.Set()` (`Timer.cs:374`) to wake the main loop.
- **Core / main thread** runs `Timer.Slice()` (`Timer.cs:391`, called from `Core.Main` at `Main.cs:580`). This dequeues due timers and calls **`t.OnTick()` on the main thread** (`Timer.cs:409`).
**→ Every `Timer` / `Timer.DelayCall` callback executes on the Core (main) game thread.** The separate Timer Thread never touches game state; it's a scheduling clock. This is verifiable live via Appendix A (the probe logs `Thread.CurrentThread` from a Timer tick and from `Initialize` — they match, and match the network path shown in your crash log).
Other properties worth knowing:
- Timers are bucketed by `TimerPriority` (`EveryTick`, `TenMS`, … `OneMinute`); priority is auto-computed from delay/interval (`Timer.cs:468`).
- `Timer.Slice` has a `BreakCount` (default **20000**, `Timer.cs:383`) — if more than that many timers are due in one slice, the overflow waits for the next slice. Relevant if the bridge ever schedules a flood of one-shot timers.
- **Timers do not fire during world save/load.** `TimerMain` early-continues while `World.Loading || World.Saving` (`Timer.cs:322`). See §5 — this directly affects inbound-command latency.
---
## 4. Object model & serialization — and a diff-friendly state shape
**Identity.** `Serial` (`Server/Serial.cs:7`) is a `struct` wrapping a single `int`. **Mobiles** get serials `< 0x40000000`; **items** start at `0x40000000` (`Serial.cs:11-12`); `IsItem`/`IsMobile` test that boundary. Serials are stable for an object's lifetime and are the natural **primary key** for any external mirror of state. `World.Mobiles` / `World.Items` are `Dictionary<Serial, >` (`World.cs:19-20`) — O(1) lookup by serial from the main thread.
**ServUO's own persistence** (`Server/Serialization.cs`, `Server/World.cs`):
- Every `Item`/`Mobile`/`SaveData` implements `Serialize(GenericWriter)` / `Deserialize(GenericReader)` plus a serial-taking ctor. `Core.VerifySerialization` (`Main.cs:679`) enforces this at boot.
- `GenericWriter`/`GenericReader` are a **versioned, positional binary stream** of primitives (`ReadInt`, `ReadString`, `ReadMobile`, `ReadPoint3D`, …; `Serialization.cs:17+`). Each object writes an `int` version first, then fields in a fixed order. It is **compact but *not* diff-friendly**: it's a full positional snapshot with no field names, meaningless without the exact type+version that wrote it, and it encodes the *entire* object every save.
- Saves are orchestrated by `World.Save` (`World.cs:1102`) on the main thread; a `SaveStrategy` may flush bytes to disk on a **background thread**, guarded by `m_DiskWriteHandle` (`ManualResetEvent`, `World.cs:29`). During a save `World.Saving` is true and object add/delete is deferred into `_addQueue`/`_deleteQueue` (`World.cs:1247-1280`).
**Recommendation for a diff-friendly representation (do NOT reuse the save system):**
The internal serializer is the wrong tool for the bridge — it's full-snapshot, schema-coupled, and versioned per type. Instead, build an **event-sourced delta keyed by `Serial`**:
```jsonc
// one line per change, main-thread produced, drained by background writer
{ "t": 172..., "kind": "mob.move", "serial": "0x1A2B", "x": 1420, "y": 1631, "z": 0, "dir": "North" }
{ "t": 172..., "kind": "mob.login", "serial": "0x1A2B", "name": "Thunderheat", "acct": "PerryAdimn" }
{ "t": 172..., "kind": "item.gold", "serial": "0x1A2B", "delta": -500, "total": 12000 }
```
- Derive fields from the **EventSink args + the live object** at event time (e.g. `m.X/Y/Z/Map/Serial`), not from `Serialize`.
- Keyed by `Serial` so the external service maintains its own mirror and applies deltas.
- Emit a periodic/`ServerStarted` **full snapshot** (iterate `World.Mobiles`/`World.Items` on the main thread) as a baseline the deltas layer onto; `AfterWorldSave` is a natural snapshot boundary.
- Keep each record to primitives copied out **synchronously on the main thread** (pooled args, live objects mutate — see §5).
---
## 5. Thread-safety rules & marshaling onto the main thread
**Golden rule (RunUO/ServUO-wide):** the world — `World.Mobiles`, `World.Items`, every `Mobile`/`Item`/`Account`, the delta queues, packet sends — is **single-threaded and owned by the Core thread.** None of it is locked for general access. Reading or mutating any of it from another thread is a data race / heisenbug generator. The dictionaries aren't concurrent; `Mobile.ProcessDeltaQueue`/`Item.ProcessDeltaQueue` run on the main loop (`Main.cs:577-578`) with no cross-thread guard.
**What *is* safe from a non-main thread:**
- `Core.Set()` — wake the main loop (`AutoResetEvent`, `Main.cs:324`).
- **`Timer.DelayCall(...)`** — verified safe cross-thread. `DelayCall`→`Start`→`TimerThread.AddTimer`→`Change` takes `lock (m_Changed)` and signals the timer thread (`Timer.cs:243-251,883-892`). The scheduling call is lock-protected; the **callback then runs on the main thread.** This is the intended marshaling primitive.
- Pushing onto a **`ConcurrentQueue`** you own, then letting the main thread drain it — this is literally how the network stack works: `MessagePump.m_Queue` is a `ConcurrentQueue<NetState>` (`MessagePump.cs:14`) filled by listener threads and drained by `MessagePump.Slice()` on the main thread (`MessagePump.cs:113`).
**The two marshaling patterns for inbound named-pipe commands** (pick one; pattern A is simplest):
- **A — `Timer.DelayCall` from the pipe thread.** On each inbound command, from the pipe read-callback thread call `Timer.DelayCall(TimeSpan.Zero, () => ApplyCommand(cmd))`. The lambda executes on the main thread on the next slice. Zero shared mutable state of your own. Caveat: a burst of commands = a burst of one-shot timers (mind `BreakCount`).
- **B — your own `ConcurrentQueue` + `Core.Slice`.** Pipe thread enqueues; register a handler on the `Core.Slice` delegate (`Main.cs:41,586`) that drains the queue every loop iteration on the main thread. Mirrors the network design; better for high inbound rates.
**Pitfalls specific to this codebase:**
1. **Pooled event args.** `MovementEventArgs` (and several others) are recycled via a plain `Queue` pool and `Free()`d immediately after the event (`EventSink.cs:802-834`). The pool itself is **not** thread-safe (main-thread-only). **Never** hand an args object to the pipe writer thread; copy primitives out first. Holding the reference = reading fields that belong to an unrelated later mobile.
2. **Blocking the main thread = stalling the shard.** EventSink handlers and Timer ticks run on the Core thread. A synchronous named-pipe **write** that blocks (slow/absent reader, full pipe buffer) will freeze movement, combat, saves — everything. The writer *must* be fire-and-forget onto a background queue (see §6).
3. **Timers pause during save/load.** Because `TimerMain` skips while `World.Saving`/`World.Loading` (`Timer.cs:322`), **inbound commands marshaled via `Timer.DelayCall` are deferred until the save finishes** (typically seconds; longer with background write). If commands must apply during a save window, prefer pattern B (Core.Slice) — but note the main loop also spends the save inside `World.Save`, so nothing script-side really runs mid-save regardless. Treat "commands don't apply during a save" as a design constraint, and have the external side tolerate the latency spike.
4. **Reentrancy / world-mutation during save.** Adding/deleting entities during a save is deferred to safety queues and logs a warning (`World.cs:988,1247`). If a bridge command spawns/deletes, it may silently queue.
5. **Crash path skips `Shutdown`.** Don't rely on graceful pipe teardown (§1).
---
## 6. Local ServUO↔sidecar transport (net48) — non-blocking bridge I/O
> **Superseded by [Part II.1](#ii1-transport-put-the-websocket-in-rust-keep-the-c-side-dumb).** For the Rust WS sidecar design the recommended C↔Rust link is **loopback TCP + newline-JSON**, not a named pipe, and **ServUO should not speak WebSocket**. The non-blocking principles below still apply verbatim to whichever local transport you pick.
Target is **net48** (`Scripts.csproj:3`), so you have `System.IO.Pipes` / `System.Net.Sockets` with `async`/`await` and `Begin/End` APIs, but **not** the newer `IAsyncEnumerable`/`CancellationToken` niceties of modern .NET. Design around that.
**Outbound (fire-and-forget writer) — the important one:**
- The producer is the Core thread (event handlers). It must **never touch the pipe directly.** Producer does only: format the delta record → `ConcurrentQueue.Enqueue` → return. This is a non-blocking, allocation-only operation.
- A **single dedicated background writer thread** (or a long-running `Task`) owns the `NamedPipeServerStream`/`ClientStream` and drains the queue, using `WriteAsync`/`FlushAsync`. One writer = writes stay ordered and you avoid interleaved frames on the pipe.
- Use a **length-prefixed or newline-delimited framing** (`PipeTransmissionMode.Byte` is simplest and most portable; `Message` mode has size/OS quirks). Don't rely on message boundaries.
- **Bound the queue.** If the external reader stalls, an unbounded queue is a memory leak that eventually OOMs the shard. Drop-oldest or drop-on-full with a dropped-count counter is the safe default for telemetry-style data.
- Handle `IOException`/`Broken pipe` by reconnecting in the writer thread; the game keeps running, the queue keeps the newest N records.
**Inbound (command listener):**
- A separate background thread/loop `WaitForConnectionAsync` → `ReadAsync` loop, parse a framed command, then **marshal to the main thread** via pattern A or B from §5. The read thread must not call any `World`/`Mobile`/`Item` API.
- Server vs client: making ServUO the **`NamedPipeServerStream`** (external service connects in) is usually cleaner for lifecycle — the shard owns the pipe, survives external restarts, and you control `maxNumberOfServerInstances`. Two half-duplex pipes (one in, one out) are simpler to reason about than one duplex pipe shared across your writer and reader threads.
- Set `PipeOptions.Asynchronous` at construction — required for the `*Async` methods to actually overlap I/O rather than block a thread-pool thread.
**Pitfalls:**
- Don't `await` pipe I/O on the Core thread — there's no synchronization context that returns you to the Core thread anyway, and you'd risk resuming world access on a thread-pool thread. Keep all pipe `await`s on your dedicated background threads.
- Named-pipe ACLs: if the external service runs as a different user/session, set a `PipeSecurity` explicitly or the connect will `UnauthorizedAccessException`.
- First-chance `IOException` on client disconnect is normal; log-and-reconnect, don't crash the writer loop.
---
## 7. Flags against the bridge architecture
> **See [Part II.4](#ii4-revised-flags-for-this-architecture) for the flags that matter to the Rust WS sidecar + tracking/link design.** The list below is the original generic set (still valid background).
1. **⚑ Item pickup/drop has no EventSink (§2 gap).** If the spec assumes "subscribe to item move events" the way you subscribe to login/movement, that assumption is wrong. Pickup/drop/lift live on **virtual methods** (`Item.OnDragLift/OnDragDrop/OnDroppedInto`, `Mobile.OnDragDrop`). Exporting them cleanly requires base-class overrides/patching, not `Initialize`-time subscription. This is the item most likely to change the design.
2. **⚑ `Movement` (and `Item/MobileCreated/Deleted`) are firehoses on the main thread (§2, §5).** Any spec that says "export all movement" must add player-filtering + aggregation, and the export path must be non-blocking. `Movement` args are **pooled** — copy-out-synchronously is mandatory, not optional.
3. **⚑ Everything you'd export runs on the single Core thread (§3, §5).** The whole bridge stands or falls on the writer being fire-and-forget. If the spec has event handlers writing to the pipe synchronously, that's a shard-wide stall waiting to happen. Confirmed by your own crash log that even packet-triggered handlers run inline on `Core.Main`.
4. **✔ Inbound commands *can* be safely marshaled to the main thread** via `Timer.DelayCall` (verified thread-safe) or a `ConcurrentQueue` drained on `Core.Slice`. The named-pipe approach is **not** blocked by threading — but:
5. **⚑ Commands don't apply during world saves (§5 pitfall 3).** Timers pause and the main loop is inside `World.Save` (~seconds, every ~5 min by default). If the spec expects sub-second inbound command latency 100% of the time, it needs to tolerate periodic save-window spikes.
6. **⚑ Don't mirror state via ServUO's serializer (§4).** If the spec imagined "reuse ServUO's save format to ship state," reconsider — it's full-snapshot, schema-versioned, and unnamed. Use event-derived deltas keyed by `Serial` + periodic snapshots.
7. **⚑ Crash path skips graceful shutdown (§1).** The external service must treat pipe EOF as normal and re-handshake; don't assume a clean `Shutdown` teardown.
8. **⚑ A compile error in the bridge plugin fails the whole shard boot (§1).** Keep the plugin small, wrap handler bodies in try/catch, and never let a bridge exception escape into a game code path.
9. **(Environmental) The `zlibwapi64` native-load crash (§0)** already downed this shard once. Unrelated to the bridge, but resolve it before load-testing or it will confound results.
---
## Appendix A — Drop-in empirical probe (run this yourself)
Save as `Scripts/Custom/BridgeThreadProbe.cs`, start the shard, watch the console. **No game client needed** — it proves the thread identity of `Initialize`, `ServerStarted`, a `Timer` tick, and `Core.Slice`. Delete the file afterward. (This is a throwaway diagnostic, not the bridge.)
```csharp
using System;
using System.Threading;
using Server;
namespace Server.Custom
{
public static class BridgeThreadProbe
{
private static void Log(string where)
{
var t = Thread.CurrentThread;
Console.WriteLine("[PROBE] {0,-16} thread id={1} name=\"{2}\"",
where, t.ManagedThreadId, t.Name);
}
public static void Initialize()
{
Log("Initialize"); // expect: Core Thread
EventSink.ServerStarted += () => Log("ServerStarted"); // expect: Core Thread
EventSink.Login += e => Log("Login (client)"); // needs a client login
// Timer tick — proves callbacks run on the main thread, not the Timer Thread.
Timer.DelayCall(TimeSpan.FromSeconds(3), () => Log("Timer.DelayCall")); // expect: Core Thread
// Cross-thread marshal test: schedule from a raw background thread,
// confirm the callback still lands on Core Thread.
new Thread(() =>
{
Log("raw bg thread"); // expect: some worker id, NOT Core Thread
Timer.DelayCall(TimeSpan.Zero, () => Log("marshaled->main"));
}).Start();
// Core.Slice runs every main-loop iteration; log once then detach.
Slice one = null;
one = () => { Log("Core.Slice"); Core.Slice -= one; };
Core.Slice += one; // expect: Core Thread
}
}
}
```
**Expected result:** every line except `raw bg thread` reports `name="Core Thread"` with the same managed id as `Initialize` — confirming EventSink handlers, Timer ticks, and `Core.Slice` all execute on the one main thread, and that `Timer.DelayCall` from a background thread correctly hops work onto it. If you connect a client, `Login (client)` also reports `Core Thread`, matching the `MessagePump.Slice` evidence in your crash log.
---
## Key source references
| Topic | File:line |
|-------|-----------|
| Main game loop / thread setup | `Server/Main.cs:329,410-434,573-599` |
| `Core.Slice` main-thread hook | `Server/Main.cs:41,586` |
| `Core.Set` wake main loop | `Server/Main.cs:322-327` |
| Shutdown / Crashed hooks | `Server/Main.cs:198,313` |
| Script compile (`dotnet build`) | `Server/ScriptCompiler.cs:18-65` |
| `Configure`/`Initialize` invoke + CallPriority | `Server/ScriptCompiler.cs:87-112`, `Server/Attributes.cs:27` |
| EventSink event declarations | `Server/EventSink.cs:1692-1784` |
| Movement raise (all mobiles, pooled, cancellable) | `Server/Mobile.cs:3020-3036`, `Server/EventSink.cs:792-834` |
| Item pickup/drop = virtual, no EventSink | `Server/Item.cs:2157,4647,5060`, `Server/Mobile.cs:10877,10949` |
| Timer scheduler thread (enqueue only) | `Server/Timer.cs:314-379` |
| Timer execution on main thread | `Server/Timer.cs:391-419`, `Server/Main.cs:580` |
| `Timer.DelayCall` cross-thread safety | `Server/Timer.cs:243-251,524-534,883-892` |
| Network marshaling (ConcurrentQueue → main) | `Server/Network/MessagePump.cs:14,108,113` |
| Serial identity | `Server/Serial.cs:7-33` |
| Serialization API | `Server/Serialization.cs:17+` |
| World save threading / safety queues | `Server/World.cs:29,1102-1208,1247-1280` |
| Runtime evidence: EventSink on Core thread | `Crash 6-5-2026-22-38-3.log` |

View File

@@ -1,71 +0,0 @@
# Shard prerequisites
Repairs the target shard (`C:\Users\colby\Desktop\servuo`, ServUO 57.4) required before the bridge could load. These are **deletions and edits of existing files**, so they cannot be expressed as an overlay copy. They are recorded here, and where practical as diffs under `patches/`.
Applied 2026-07-10. Backups on the Desktop: `servuo_saves_backup_2026-07-10_032608`, `servuo_bin_backup_2026-07-10_032608`, `servuo_removed_files_2026-07-10`.
---
## The symptom
`Scripts.dll` had not been rebuilt since **2026-05-30 17:01**. Every script change after that — including all of `Scripts/Custom/Named/`, `MyStats.cs`, and `SearchAdd.cs` — had never executed.
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) shells out to `dotnet build`, prints the output, ignores the exit code, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. A failing script build is invisible: the stale DLL simply reloads. The retry loop at `Main.cs:525` never trips.
Four independent breakages, all introduced between 17:14 and 21:55 on 2026-05-30.
---
## 1. Stray `Server/Gumps/Gumps.cs`
A **byte-identical copy** of `Scripts/Services/Pet Training/Gumps.cs` (75,468 bytes), sitting in the Server project. It declares `namespace Server.Mobiles` and extends `BaseGump`, referencing `BaseCreature`, `PlayerMobile`, `TrainingPoint` — all defined in Scripts. Server cannot reference Scripts, so `Server.csproj` failed with 35 errors.
**Action:** deleted. The canonical copy under `Scripts/Services/Pet Training/` was edited 10 minutes later and is the one that matters.
## 2. Eleven duplicate creature classes
`Scripts/Custom/{Named,Legendary}/` redefined classes already present in `Scripts/Mobiles/Normal/`, producing `CS0111` / `CS0579`.
**Named**`Eowmu`, `SkeletalCat`, `Windrunner`. The stock files each define **two** types: the mount *and* an `ICreatureStatuette` item (`EowmuStatue`, …) that `Scripts/Services/UltimaStore/UltimaStore.cs` references. Deleting the stock files outright would have re-broken the build.
**Action:** removed only the duplicate mount class from each stock file; kept the statues.
**Legendary**`FireSteed`, `Kirin`, `Nightmare`, `OsseinRam`, `Phoenix`, `PolarBear`, `ShadowWyrm`, `TsukiWolf`. Clean 1:1 pairs. All custom versions sit in `namespace Server.Mobiles`, so the serialized type name is unchanged, and each `Deserialize` guards on `version` and migrates from 0 (`ShadowWyrm`: `if (version >= 1)`; `FireSteed`: `if (version < 1)` skill-cap migration; `Kirin`: `if (version == 0)` AI fixup).
**Action:** deleted the eight stock files. Custom wins.
## 3. `PolarBear` — a base-class change, not a version bump
Custom `PolarBear : BaseMount`; stock `PolarBear : BaseCreature`. The saved world contained a bear serialized through the `BaseCreature` chain, so loading it as a `BaseMount` misaligned the stream. World load aborted at `Server.Mobiles.PolarBear` serial `0x00000412` with `Delete the object? (y/n)`.
**Changing a saved type's base class is not version-migratable.** The custom class also carried `[TypeAlias("Server.Mobiles.Polarbear")]`, which would have hijacked the same records.
**Action:** restored stock `PolarBear : BaseCreature`; renamed the custom mount to `LegendaryPolarBear` and dropped the `TypeAlias`. Stock scripts referencing `typeof(PolarBear)` (`TalismanSlayer`, `SpeedInfo`, `RoyalZooDonationBox`, `SummonCreature`, `PetTrainingHelper`) continue to resolve to the `BaseCreature`.
Note: `Scripts/Custom/Legendary/PolarBear.cs` was renamed to `LegendaryPolarBear.cs`.
## 4. `AnimalLore.cs` referenced a package that does not exist
`Scripts/Skills/AnimalLore.cs` had `using ShrinkSystem;` and two `IShrinkItem` branches. No `ShrinkSystem` namespace exists anywhere in the repo, and `IShrinkItem` appears nowhere in the stale `Scripts.dll`**the code had never compiled or run.** (`Scripts/Misc/ShrinkTable.cs` is unrelated stock: `namespace Server`, class `ShrinkTable`.)
**Action:** removed the `using` and collapsed the shrink branches back to the `BaseCreature` path. This restores exactly the behavior the shard was already running.
---
## Verification
After the repairs, `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` succeeded with 0 warnings, 0 errors. Rebuilding `ServUO.exe` and `Ultima.dll` from current source produced **byte-identical** binaries (same SHA-256), confirming the core was never stale in content — only `Scripts.dll` was.
With Phase 0 applied, a plain boot shows:
```
Core: Compiling scripts...
Build succeeded.
Core: Verified 6023 item and 1385 mobile types
World: Loading...
...done (206208 items, 42771 mobiles, 0 customs)
```
## Unrelated, still open
`DllNotFoundException: zlibwapi64` crashed this shard once (`Crash 6-5-2026-22-38-3.log`) while sending a packed gump. `zlibwapi64.dll` is present in the repo root, so this is a working-directory / native-load-path problem. It will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.

View File

@@ -14,7 +14,7 @@ Port=7788
QueueCap=10000
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See docs/PLAN.md §1.
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
StatSweepSeconds=30
DecaySweepSeconds=60
EconomySweepSeconds=300
@@ -29,6 +29,25 @@ ChampSweepSeconds=10
# support queue; the full open queue is also available on demand via pages.snapshot.
PageSweepSeconds=5
# Guild roster poll (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
GuildSweepSeconds=60
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
CitySweepSeconds=300
# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
# interval and emitted as presence.online only when it changes. Region transitions come
# through separately in real time as region.enter (EventSink.OnEnterRegion).
PresenceSweepSeconds=30
# Housing registry poll. Every house is diffed on this interval to emit house.update /
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
HousingSweepSeconds=300
# Shown to a player when they run [link. The website page where they enter the code.
LinkUrl=https://yoursite/link
@@ -39,6 +58,15 @@ TownCrierMaxLineLength=200
TownCrierMaxActive=20
TownCrierMaxDurationSec=86400
# Town Cryer news gump. Website articles (news.add) become entries in the modern Town
# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines
# above. The article title is also proclaimed by the criers (announce defaults on). Caps
# are defense in depth on top of the loopback trust boundary.
NewsMaxTitleLength=100
NewsMaxBodyLength=2000
NewsMaxExternal=20
NewsAnnounceDurationSec=300
# Admin write plane (staff moderation from the website). OFF by default: the whole
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
@@ -57,6 +85,31 @@ AdminReasonMaxLength=400
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
AdminBanMaxDurationSec=31536000
# Account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A). Which side may mint game accounts:
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
# (else an in-game login of any new name still mints an account).
# game — the game server is the authority; website account.create is refused.
# hybrid — either side may create (the default).
# The bridge governs only the account.create verb; the in-game first-login auto-create is
# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
# (the safest — no website creation).
SignupMode=hybrid
# Master switch for the account.create verb. Absent, it follows the mode (on unless
# SignupMode=game). Set explicitly to force it on or off regardless of mode.
AccountCreateEnabled=true
# Fail closed if account.create omits a usable browser IP. The per-IP cap
# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
# than waved through. Turn off only for a deployment that deliberately does not cap website
# signups by IP (MaxAccountsPerIP still applies in-game either way).
RequireIpForCreate=true
# Length caps on a website-supplied username / password, checked before the account is made.
AccountNameMaxLength=16
AccountPasswordMaxLength=30
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed

View File

@@ -18,7 +18,7 @@ namespace Server.Custom.Bridge
/// and replies link.ok. The tag persists to accounts.xml across restarts.
///
/// The code table and the account write both live on the Core thread. The websiteUserId in
/// link.confirm is trusted only because the socket is loopback-only (docs/PLAN.md §2); if the
/// link.confirm is trusted only because the socket is loopback-only (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2); if the
/// sidecar ever moves off-host, gate it behind a shared secret.
/// </summary>
public static class BridgeAccountLink
@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
return;
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
// Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
@@ -127,6 +128,53 @@ namespace Server.Custom.Bridge
url, (int)CodeTtl.TotalMinutes);
}
// ---- [unlink ----
[Usage("unlink")]
[Description("Unlinks this game account from your website account.")]
private static void OnUnlinkCommand(CommandEventArgs e)
{
Unlink(e.Mobile);
}
/// <summary>
/// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so
/// the website can reconcile a player-initiated unlink. Player-scoped (own account only),
/// so it needs no access floor. After unlinking, [link works again.
/// </summary>
public static void Unlink(Mobile m)
{
if (m == null)
return;
var acct = m.Account as Account;
if (acct == null)
{
m.SendMessage("Bridge: no account on this character.");
return;
}
var existing = acct.GetTag(Tag);
if (existing == null)
{
m.SendMessage("Your account is not linked to a website account.");
return;
}
acct.RemoveTag(Tag);
DropCodesFor(acct.Username); // drop any pending codes so nothing dangles
BridgeLink.Emit(BridgeJson.Begin("account.unlinked")
.Str("origin", "in-game")
.Str("account", acct.Username)
.Str("websiteUserId", existing)
.Str("char", m.Name)
.End());
m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing);
}
// ---- inbound link.confirm ----
private static void OnLinkConfirm(Dictionary<string, object> o)

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Net;
using Server.Accounting;
using Server.Misc;
namespace Server.Custom.Bridge
{
/// <summary>
/// The account provisioning plane (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A): website-driven account
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
/// is unchanged.
///
/// account.create — mint a game account and link it to a website user in one step.
/// account.unlink — sever the WebsiteUserId tie from the website side.
///
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
/// Timer.DelayCall first), so they touch accounts freely.
///
/// Trust model matches the admin plane (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization lives on
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
/// AccountCreateEnabled.
/// </summary>
public static class BridgeAccounts
{
private const string Tag = "WebsiteUserId";
// Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an
// in-game one (AccountHandler.cs). Kept local because that array is private.
private static readonly char[] ForbiddenChars =
{
'<', '>', ':', '"', '/', '\\', '|', '?', '*', ' '
};
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("account.create", OnCreate);
BridgeBoot.RegisterHandler("account.unlink", OnUnlink);
}
// ---- account.create ----
/// <summary>
/// Creates a game account and links it to the given website user. Refused unless the
/// signup mode allows website creation. Enforces the same username/password character
/// safety and per-IP cap as ServUO's in-game create path; the password never leaves the
/// process in any reply, audit, or log.
/// </summary>
private static void OnCreate(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "create";
if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game)
{
Err(reqId, action, "signups disabled for this mode");
return;
}
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
{
Err(reqId, action, "missing actor");
return;
}
var account = BridgeJson.GetString(o, "account");
var password = BridgeJson.GetString(o, "password");
var webId = BridgeJson.GetString(o, "websiteUserId");
var ipStr = BridgeJson.GetString(o, "ip");
if (String.IsNullOrEmpty(account))
{
Err(reqId, action, "missing account");
return;
}
if (String.IsNullOrEmpty(password))
{
Err(reqId, action, "missing password");
return;
}
if (String.IsNullOrEmpty(webId))
{
Err(reqId, action, "missing websiteUserId");
return;
}
if (account.Length > BridgeConfig.AccountNameMaxLength ||
password.Length > BridgeConfig.AccountPasswordMaxLength)
{
Err(reqId, action, "username or password too long");
return;
}
if (!IsSafeUsername(account) || !IsSafePassword(password))
{
Err(reqId, action, "invalid username/password");
return;
}
// Collision: the only correct resolution of a website/in-game race for a name.
if (Accounts.GetAccount(account) != null)
{
Err(reqId, action, "account already exists");
return;
}
// Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is
// exempt in IPLimiter, so accepting it would silently bypass the cap.
IPAddress ip;
bool haveIp = TryParseIp(ipStr, out ip);
if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip)))
{
Err(reqId, action, "client ip required");
return;
}
if (haveIp && !AccountHandler.CanCreate(ip))
{
Err(reqId, action, "ip account limit reached");
return;
}
// Create + link. new Account self-registers (Accounts.Add) and hashes the password per
// the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an
// in-game first-login does; the tag persists on the next world save.
var acct = new Account(account, password);
if (haveIp)
acct.LogAccess(ip);
acct.SetTag(Tag, webId);
Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}",
actor, account, webId, haveIp ? ip.ToString() : "-");
BridgeLink.Emit(AuditBegin(action, actor, account)
.Str("websiteUserId", webId)
.End());
var sb = BridgeJson.Begin("account.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("account", account).Str("websiteUserId", webId);
BridgeLink.Emit(sb.End());
}
// ---- account.unlink ----
/// <summary>
/// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the
/// Owner floor keeps a protected staff account unreachable from the web.
/// </summary>
private static void OnUnlink(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "unlink";
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
{
Err(reqId, action, "missing actor");
return;
}
var acct = BridgeAdmin.ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
if (BridgeAdmin.Protected(acct))
{
Err(reqId, action, "target is protected staff; refused");
return;
}
var existing = acct.GetTag(Tag);
if (existing == null)
{
Err(reqId, action, "not linked");
return;
}
acct.RemoveTag(Tag);
Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})",
actor, acct.Username, existing);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Str("websiteUserId", existing)
.End());
var sb = BridgeJson.Begin("account.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("account", acct.Username);
BridgeLink.Emit(sb.End());
}
// ---- helpers ----
private static void Err(string reqId, string action, string reason)
{
var sb = BridgeJson.Begin("account.error");
if (reqId != null) sb.Str("reqId", reqId);
if (action != null) sb.Str("action", action);
sb.Str("reason", reason);
BridgeLink.Emit(sb.End());
}
/// <summary>
/// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
/// admin.audit. Never carries the password.
/// </summary>
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
{
return BridgeJson.Begin("account.audit")
.Str("origin", "web")
.Str("action", action)
.Str("actor", "web:" + actor)
.Str("target", target);
}
/// <summary>Mirrors the username safety rules in AccountHandler.CreateAccount.</summary>
private static bool IsSafeUsername(string un)
{
if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith("."))
return false;
for (int i = 0; i < un.Length; i++)
{
char c = un[i];
if (c < 0x20 || c >= 0x7F || IsForbidden(c))
return false;
}
return true;
}
/// <summary>Mirrors the password safety rules in AccountHandler.CreateAccount.</summary>
private static bool IsSafePassword(string pw)
{
for (int i = 0; i < pw.Length; i++)
{
char c = pw[i];
if (c < 0x20 || c >= 0x7F)
return false;
}
return true;
}
private static bool IsForbidden(char c)
{
for (int i = 0; i < ForbiddenChars.Length; i++)
if (c == ForbiddenChars[i])
return true;
return false;
}
private static bool TryParseIp(string s, out IPAddress ip)
{
ip = null;
if (String.IsNullOrEmpty(s))
return false;
return IPAddress.TryParse(s.Trim(), out ip);
}
}
}

View File

@@ -13,7 +13,7 @@ namespace Server.Custom.Bridge
/// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
/// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
///
/// Trust model (docs/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
/// Trust model (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
/// these commands are gated there behind admin/moderator roles. The shard trusts the
/// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
/// CoOwner authority. Its one hard floor is <see cref="Protected"/>: a command refuses any
@@ -248,7 +248,7 @@ namespace Server.Custom.Bridge
/// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
/// connected dashboard so the website's moderation log stays complete regardless of which
/// client issued the action. The in-game counterpart (origin=in-game) is emitted from
/// BridgeEvents; see docs/ADMIN_CONTROLS.md §5.5.
/// BridgeEvents; see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
/// </summary>
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
{
@@ -283,9 +283,10 @@ namespace Server.Custom.Bridge
/// <summary>
/// Resolves the command's target account, by "serial" (a player mobile's account) or by
/// "account" (username). Returns null if neither resolves to a real account.
/// "account" (username). Returns null if neither resolves to a real account. Public so the
/// account plane (unlink) resolves targets the same way the moderation plane does.
/// </summary>
private static Account ResolveTargetAccount(Dictionary<string, object> o)
public static Account ResolveTargetAccount(Dictionary<string, object> o)
{
var serialStr = BridgeJson.GetString(o, "serial");
if (serialStr != null)
@@ -301,9 +302,10 @@ namespace Server.Custom.Bridge
/// <summary>
/// The one shard-side safety floor. Protects any account whose effective access level —
/// the account's own or the highest of its characters' — is at or above the configured
/// floor. Even under CoOwner authority the Owner is never reachable from the web.
/// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
/// so the account plane (unlink) enforces the identical floor.
/// </summary>
private static bool Protected(Account acct)
public static bool Protected(Account acct)
{
var lvl = acct.AccessLevel;

View File

@@ -161,6 +161,10 @@ namespace Server.Custom.Bridge
BridgeSweeps.Rearm();
BridgePages.Rearm();
BridgeChamps.Rearm();
BridgeSocial.Rearm();
BridgeGovernance.Rearm();
BridgePresence.Rearm();
BridgeHousing.Rearm();
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
break;
@@ -173,9 +177,17 @@ namespace Server.Custom.Bridge
case "sweepnow":
BridgeSweeps.SweepOnce();
BridgeChamps.SweepOnce();
BridgeSocial.SweepOnce();
BridgeGovernance.SweepOnce();
BridgePresence.SweepOnce();
BridgeHousing.SweepOnce();
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
break;
default:
@@ -186,6 +198,10 @@ namespace Server.Custom.Bridge
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
break;
}

View File

@@ -2,6 +2,18 @@ using System;
namespace Server.Custom.Bridge
{
/// <summary>
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
/// the operator pairs with this (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §2).
/// </summary>
public enum SignupMode
{
Website, // website is the account authority; in-game auto-create should be off
Game, // game server is the authority; account.create is refused
Hybrid // either side may create
}
/// <summary>
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
/// reads as "Bridge.Port" here.
@@ -19,6 +31,10 @@ namespace Server.Custom.Bridge
public static int EconomySweepSeconds { get; private set; }
public static int PageSweepSeconds { get; private set; }
public static int ChampSweepSeconds { get; private set; }
public static int GuildSweepSeconds { get; private set; }
public static int CitySweepSeconds { get; private set; }
public static int PresenceSweepSeconds { get; private set; }
public static int HousingSweepSeconds { get; private set; }
public static string LinkUrl { get; private set; }
@@ -27,12 +43,25 @@ namespace Server.Custom.Bridge
public static int TownCrierMaxActive { get; private set; }
public static int TownCrierMaxDurationSec { get; private set; }
// Town Cryer news gump (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16).
public static int NewsMaxTitleLength { get; private set; }
public static int NewsMaxBodyLength { get; private set; }
public static int NewsMaxExternal { get; private set; }
public static int NewsAnnounceDurationSec { get; private set; }
public static bool AdminWriteEnabled { get; private set; }
public static AccessLevel AdminAccessFloor { get; private set; }
public static int AdminBroadcastMaxLength { get; private set; }
public static int AdminReasonMaxLength { get; private set; }
public static int AdminBanMaxDurationSec { get; private set; }
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
public static SignupMode Signup { get; private set; }
public static bool AccountCreateEnabled { get; private set; }
public static bool RequireIpForCreate { get; private set; }
public static int AccountNameMaxLength { get; private set; }
public static int AccountPasswordMaxLength { get; private set; }
public static bool Enabled { get; private set; }
public static void Configure()
@@ -60,6 +89,24 @@ namespace Server.Custom.Bridge
if (ChampSweepSeconds < 1)
ChampSweepSeconds = 1;
// Social/political sweeps (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Both change slowly, so the
// defaults are unhurried; the pass is a handful of field reads over a small set.
GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
if (GuildSweepSeconds < 1)
GuildSweepSeconds = 1;
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
if (CitySweepSeconds < 1)
CitySweepSeconds = 1;
PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
if (PresenceSweepSeconds < 1)
PresenceSweepSeconds = 1;
HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
if (HousingSweepSeconds < 1)
HousingSweepSeconds = 1;
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
@@ -67,14 +114,77 @@ namespace Server.Custom.Bridge
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
NewsMaxTitleLength = Config.Get("Bridge.NewsMaxTitleLength", 100);
NewsMaxBodyLength = Config.Get("Bridge.NewsMaxBodyLength", 2000);
NewsMaxExternal = Config.Get("Bridge.NewsMaxExternal", 20);
NewsAnnounceDurationSec = Config.Get("Bridge.NewsAnnounceDurationSec", 300);
if (NewsAnnounceDurationSec < 1)
NewsAnnounceDurationSec = 1;
AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
// unrecognized* value falls back to Game (the safest — no website creation), so a
// typo can never accidentally open provisioning.
Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game);
// Default follows the mode: creation is on unless the shard is game-authority.
AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game);
RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true);
AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16);
AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30);
if (AccountNameMaxLength < 1)
AccountNameMaxLength = 1;
if (AccountPasswordMaxLength < 1)
AccountPasswordMaxLength = 1;
if (QueueCap < 16)
QueueCap = 16;
WarnOnSignupMismatch();
}
/// <summary>
/// The bridge governs only the account.create verb; ServUO's in-game first-login
/// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves
/// disagree is quietly broken (website-only that still auto-creates in game, or a mode
/// that expects in-game creation with it switched off), so surface the contradiction
/// loudly rather than silently doing the permissive thing.
/// </summary>
private static void WarnOnSignupMismatch()
{
var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true);
if (Signup == SignupMode.Website && autoCreate)
Console.WriteLine(
"[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; "
+ "an in-game login of any new name still mints an account. Set it false for website-only.");
else if (Signup == SignupMode.Game && !autoCreate)
Console.WriteLine(
"[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; "
+ "in-game creation is off and account.create is refused, so no account can be created.");
else if (Signup == SignupMode.Hybrid && !autoCreate)
Console.WriteLine(
"[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; "
+ "in-game first-login creation is off. Only website account.create will work.");
}
/// <summary>
/// Parses a SignupMode name, case-insensitively, falling back to <paramref name="fallback"/>
/// on anything unrecognized so a typo can never open provisioning wider than intended.
/// </summary>
private static SignupMode ParseSignupMode(string value, SignupMode fallback)
{
SignupMode parsed;
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
Enum.IsDefined(typeof(SignupMode), parsed))
return parsed;
Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback);
return fallback;
}
/// <summary>
@@ -95,9 +205,9 @@ namespace Server.Custom.Bridge
public static string Describe()
{
return String.Format(
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})",
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor);
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
}
}
}

View File

@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using Server.Engines.CityLoyalty;
namespace Server.Custom.Bridge
{
/// <summary>
/// The town-governor stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a
/// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of
/// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises
/// an EventSink, so — like <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — the set
/// is polled and each city emits `city.update` only when its signature changes. Governors turn
/// over on the order of weeks, so a slow sweep (default 5 min) is ample.
///
/// The wire model is uniform with the rest of Part B: a full-state `city.update` upsert, with
/// "the governor changed" derived sidecar-side by comparing to the stored board — rather than a
/// discrete from→to event, which a sidecar reconnect (cache cleared, full re-emit) would
/// otherwise fire spuriously for every city.
///
/// Gated on CityLoyaltySystem.Enabled: a shard running its own town system emits nothing here.
/// </summary>
public static class BridgeGovernance
{
private static Timer _timer;
// City enum value -> last-emitted signature.
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
private static long _sweeps, _emitted;
private static bool _warnedDisabled;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
BridgeLink.Connected_Core += OnConnected;
Rearm();
}
private static void OnConnected()
{
_last.Clear();
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
CitySweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
public static string Status()
{
return String.Format("cities(enabled={0} sweeps={1} emitted={2} tracked={3})",
CityLoyaltySystem.Enabled, _sweeps, _emitted, _last.Count);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
CitySweep();
}
private static void CitySweep()
{
try
{
_sweeps++;
if (!CityLoyaltySystem.Enabled || CityLoyaltySystem.Cities == null)
{
if (!_warnedDisabled)
{
Console.WriteLine("[Bridge] city loyalty disabled; governor stream idle.");
_warnedDisabled = true;
}
return;
}
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
foreach (var city in CityLoyaltySystem.Cities)
{
if (city == null)
continue;
var sig = Signature(city);
int key = (int)city.City;
string prior;
if (_last.TryGetValue(key, out prior) && prior == sig)
continue; // unchanged since last emit
_last[key] = sig;
BridgeLink.Emit(WriteCity(city));
_emitted++;
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] city sweep threw: {0}", ex.Message);
}
}
// The volatile fields: governor, governor-elect, and the election phase / candidate count.
private static string Signature(CityLoyaltySystem city)
{
var gov = city.Governor == null ? 0 : city.Governor.Serial.Value;
var elect = city.GovernorElect == null ? 0 : city.GovernorElect.Serial.Value;
var e = city.Election;
var phase = ElectionPhase(e);
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
return String.Concat(
gov.ToString(), "|", elect.ToString(), "|", phase, "|", candidates.ToString());
}
private static string WriteCity(CityLoyaltySystem city)
{
var e = city.Election;
var phase = ElectionPhase(e);
var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
var sb = BridgeJson.Begin("city.update")
.Str("city", city.City.ToString())
.Str("electionPhase", phase)
.Num("candidates", candidates);
sb.Actor("governor", city.Governor);
sb.Actor("governorElect", city.GovernorElect);
if (e != null && e.Ongoing)
sb.Str("autoPickAt", e.AutoPickGovernor.ToUniversalTime().ToString("o"));
return sb.End();
}
/// <summary>Folds the election state into one of: none / nominate / vote / pending.</summary>
private static string ElectionPhase(CityElection e)
{
if (e == null)
return "none";
if (e.CanNominate())
return "nominate";
if (e.CanVote())
return "vote";
if (e.Ongoing)
return "pending";
return "none";
}
}
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Server.Multis;
namespace Server.Custom.Bridge
{
/// <summary>
/// The housing registry (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay
/// *transitions*; this is the complementary *board*: one row per house with owner, location,
/// region, co-owners, value, and current decay level, so the website can render an owner→houses
/// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit
/// house.update only when a house's signature changes, and house.remove when a house is gone.
///
/// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the
/// registry is owner→houses; `price` is the house's placement value, not a sale listing.
/// </summary>
public static class BridgeHousing
{
private static Timer _timer;
// house serial -> last-emitted signature.
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
private static long _sweeps, _emitted, _removed;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
BridgeLink.Connected_Core += OnConnected;
Rearm();
}
private static void OnConnected()
{
_last.Clear();
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
HouseSweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
public static string Status()
{
return String.Format("housing(sweeps={0} emitted={1} removed={2} tracked={3})",
_sweeps, _emitted, _removed, _last.Count);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
HouseSweep();
}
private static void HouseSweep()
{
try
{
_sweeps++;
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
var seen = new HashSet<Serial>();
foreach (var house in BaseHouse.AllHouses)
{
if (house == null || house.Deleted)
continue;
seen.Add(house.Serial);
var level = house.DecayLevel; // computed getter — read once
var sig = Signature(house, level);
string prior;
if (_last.TryGetValue(house.Serial, out prior) && prior == sig)
continue; // unchanged since last emit
_last[house.Serial] = sig;
BridgeLink.Emit(WriteHouse(house, level));
_emitted++;
}
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
foreach (var serial in gone)
{
_last.Remove(serial);
BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End());
_removed++;
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message);
}
}
private static string Signature(BaseHouse house, DecayLevel level)
{
var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value;
var region = house.Region;
var regionName = region == null ? "" : (region.Name ?? "");
var sign = house.Sign;
var name = sign == null ? "" : (sign.GetName() ?? "");
var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count;
return String.Concat(
ownerSerial.ToString(), "|",
level.ToString(), "|",
regionName, "|",
name, "|",
coOwners.ToString(), "|",
house.Price.ToString());
}
private static string WriteHouse(BaseHouse house, DecayLevel level)
{
var sb = BridgeJson.Begin("house.update")
.Ser("serial", house.Serial)
.Str("decay", level.ToString())
.Num("price", house.Price)
.Str("map", house.Map == null ? null : house.Map.Name)
.Num("x", house.X).Num("y", house.Y).Num("z", house.Z);
var sign = house.Sign;
if (sign != null)
sb.Str("name", sign.GetName());
var region = house.Region;
if (region != null)
sb.Str("region", region.Name);
sb.Actor("owner", house.Owner);
sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count);
sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count);
sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o"));
sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o"));
return sb.End();
}
}
}

View File

@@ -8,7 +8,7 @@ namespace Server.Custom.Bridge
{
/// <summary>
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
/// every emitted event, and the measured budget in docs/PLAN.md assumes this cost, not a
/// every emitted event, and the measured budget in https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md assumes this cost, not a
/// reflection serializer's.
///
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
@@ -76,6 +76,46 @@ namespace Server.Custom.Bridge
return sb;
}
/// <summary>
/// Writes a nested actor object: serial, name, account (when there is one), the linked
/// webId (when the account is linked), and the player flag. A `null` mobile writes null.
/// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a
/// guild leader / joiner / governor can be attributed to a site user without a lookup.
/// </summary>
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
{
sb.Append(",\"").Append(name).Append("\":");
if (m == null)
{
sb.Append("null");
return sb;
}
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
sb.Append(",\"name\":");
Escape(sb, m.Name ?? "");
var acct = m.Account as Accounting.Account;
if (acct != null)
{
sb.Append(",\"acct\":");
Escape(sb, acct.Username);
var webId = BridgeAccountLink.WebIdFor(acct);
if (webId != null)
{
sb.Append(",\"webId\":");
Escape(sb, webId);
}
}
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
sb.Append('}');
return sb;
}
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
public static string End(this StringBuilder sb)
{

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using Server.Mobiles;
using Server.Services.TownCryer;
namespace Server.Custom.Bridge
{
/// <summary>
/// Website news articles pushed into the modern Town Cryer News gump
/// (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier
/// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML),
/// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries,
/// which the stock news gumps already render (they branch on TextDefinition.Number, so string
/// content needs no gump change).
///
/// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep
/// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just
/// the title through the existing crier say path (default on), so players hear it in-world.
///
/// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall),
/// which is required to touch the shared news list and to send crier packets.
/// </summary>
public static class BridgeNews
{
// A neutral scroll gump when the website supplies no image.
private const int DefaultImage = 0x64E;
// Website id -> the news entry we created for it, so a later remove/replace can find it.
private static readonly Dictionary<string, TownCryerNewsEntry> _ours =
new Dictionary<string, TownCryerNewsEntry>(StringComparer.Ordinal);
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("news.add", OnAdd);
BridgeBoot.RegisterHandler("news.remove", OnRemove);
}
private static void OnAdd(Dictionary<string, object> o)
{
var id = BridgeJson.GetString(o, "id");
if (id == null)
{
Reply("news.error", null, "missing id");
return;
}
var list = TownCryerSystem.NewsEntries;
if (list == null)
{
Reply("news.error", id, "town cryer unavailable");
return;
}
var title = BridgeJson.GetString(o, "title");
if (String.IsNullOrEmpty(title))
{
Reply("news.error", id, "missing title");
return;
}
var body = BridgeJson.GetString(o, "body") ?? "";
var url = BridgeJson.GetString(o, "url");
int image = BridgeJson.GetInt(o, "image", DefaultImage);
// announce defaults to true (proclaim the title in-world); "announce":false suppresses it.
bool announce = true;
object rawAnnounce;
if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool)
announce = (bool)rawAnnounce;
if (title.Length > BridgeConfig.NewsMaxTitleLength)
title = title.Substring(0, BridgeConfig.NewsMaxTitleLength);
if (body.Length > BridgeConfig.NewsMaxBodyLength)
body = body.Substring(0, BridgeConfig.NewsMaxBodyLength);
try
{
// Replace an existing id in place: drop the old entry first.
TownCryerNewsEntry old;
if (_ours.TryGetValue(id, out old) && old != null)
{
list.Remove(old);
_ours.Remove(id);
}
else if (_ours.Count >= BridgeConfig.NewsMaxExternal)
{
Reply("news.error", id, "too many news entries");
return;
}
var entry = new TownCryerNewsEntry(
new TextDefinition(title),
new TextDefinition(body),
image,
null,
url);
list.Insert(0, entry); // newest first, as the gump reads top-down
_ours[id] = entry;
if (announce)
Announce(title);
Reply("news.ok", id, null);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message);
Reply("news.error", id, "internal error");
}
}
private static void OnRemove(Dictionary<string, object> o)
{
var id = BridgeJson.GetString(o, "id");
if (id == null)
{
Reply("news.error", null, "missing id");
return;
}
TownCryerNewsEntry entry;
if (!_ours.TryGetValue(id, out entry))
{
Reply("news.error", id, "unknown id");
return;
}
_ours.Remove(id);
try
{
var list = TownCryerSystem.NewsEntries;
if (list != null && entry != null)
list.Remove(entry);
Reply("news.ok", id, null);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message);
Reply("news.error", id, "internal error");
}
}
/// <summary>Proclaims a single line — the article title — through the town criers.</summary>
private static void Announce(string title)
{
try
{
GlobalTownCrierEntryList.Instance.AddEntry(
new[] { title },
TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec));
}
catch (Exception ex)
{
// A failed proclamation must not fail the news add — the article is already posted.
Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message);
}
}
private static void Reply(string kind, string id, string reason)
{
var sb = BridgeJson.Begin(kind);
if (id != null) sb.Str("id", id);
if (reason != null) sb.Str("reason", reason);
BridgeLink.Emit(sb.End());
}
}
}

View File

@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// The presence stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts:
///
/// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted
/// on a sweep but only when it changes, so the site has a live "N online"
/// plus a change history without a firehose of identical frames.
/// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap
/// per-player movement signal PLAN.md §5.6 recommends over Movement.
///
/// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the
/// same population the vitals sweep already walks; counting them by map and region is a handful
/// of field reads. region.enter is filtered to players.
/// </summary>
public static class BridgePresence
{
private static Timer _timer;
// Signature of the last-emitted snapshot, so an unchanged population emits nothing.
private static string _lastSig;
private static long _sweeps, _emitted, _regionEnters;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
EventSink.OnEnterRegion += OnEnterRegion;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
// Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the
// current population within one sweep.
BridgeLink.Connected_Core += OnConnected;
Rearm();
}
private static void OnConnected()
{
_lastSig = null;
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
PresenceSweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
public static string Status()
{
return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})",
_sweeps, _emitted, _regionEnters);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
PresenceSweep();
}
private static void PresenceSweep()
{
try
{
_sweeps++;
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
int total = 0;
var byFacet = new SortedDictionary<string, int>(StringComparer.Ordinal);
var byRegion = new SortedDictionary<string, int>(StringComparer.Ordinal);
foreach (var m in World.Mobiles.Values)
{
var pm = m as PlayerMobile;
if (pm == null || pm.NetState == null || pm.Deleted)
continue;
total++;
var facet = pm.Map == null ? "Internal" : pm.Map.Name;
Bump(byFacet, facet);
var region = pm.Region;
var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name;
Bump(byRegion, regionName);
}
var sig = Signature(total, byFacet, byRegion);
if (sig == _lastSig)
return; // population unchanged since last emit
_lastSig = sig;
BridgeLink.Emit(WriteOnline(total, byFacet, byRegion));
_emitted++;
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message);
}
}
private static void Bump(IDictionary<string, int> map, string key)
{
int n;
map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
}
private static string Signature(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
{
var sb = new System.Text.StringBuilder();
sb.Append(total);
foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
sb.Append('#');
foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
return sb.ToString();
}
private static string WriteOnline(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> byRegion)
{
var sb = BridgeJson.Begin("presence.online").Num("count", total);
WriteCounts(sb, "byFacet", byFacet);
WriteCounts(sb, "byRegion", byRegion);
return sb.End();
}
/// <summary>Writes a nested object of {name: count} pairs.</summary>
private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary<string, int> counts)
{
sb.Append(",\"").Append(field).Append("\":{");
bool first = true;
foreach (var kv in counts)
{
if (!first)
sb.Append(',');
first = false;
BridgeJson.Escape(sb, kv.Key);
sb.Append(':').Append(kv.Value);
}
sb.Append('}');
}
// ---- real-time region transitions ----
private static void OnEnterRegion(OnEnterRegionEventArgs e)
{
try
{
if (e == null || e.From == null || !e.From.Player)
return;
var from = e.OldRegion;
var to = e.NewRegion;
// Only meaningful when the named region actually changed.
var fromName = from == null ? null : from.Name;
var toName = to == null ? null : to.Name;
if (String.Equals(fromName, toName, StringComparison.Ordinal))
return;
var sb = BridgeJson.Begin("region.enter")
.Str("from", fromName)
.Str("to", toName)
.Str("map", e.From.Map == null ? null : e.From.Map.Name);
sb.Actor("who", e.From);
BridgeLink.Emit(sb.End());
_regionEnters++;
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message);
}
}
}
}

View File

@@ -15,7 +15,7 @@ namespace Server.Custom.Bridge
///
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
/// a sweep. See docs/PLAN.md §1.
/// a sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
/// </summary>
public static class BridgeProfile
{
@@ -83,7 +83,7 @@ namespace Server.Custom.Bridge
}
sb.Append(']');
// worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4)
// worn equipment only — not the backpack/bank (see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §IV.4)
sb.Append(",\"equipment\":[");
first = true;
foreach (var item in m.Items)
@@ -98,9 +98,55 @@ namespace Server.Custom.Bridge
}
sb.Append(']');
WriteTitles(sb, m);
return sb.End();
}
/// <summary>
/// The titles a character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.3). `selected` is the index into
/// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed
/// display titles (may be absent). `reward` is the raw reward-title list — an entry may be
/// a cliloc number (as a string) or a literal string; resolve clilocs website-side.
/// </summary>
private static void WriteTitles(StringBuilder sb, PlayerMobile m)
{
sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle);
var fameKarma = m.FameKarmaTitle;
if (!String.IsNullOrEmpty(fameKarma))
{
sb.Append(",\"fameKarma\":");
BridgeJson.Escape(sb, fameKarma);
}
var skill = m.PaperdollSkillTitle;
if (!String.IsNullOrEmpty(skill))
{
sb.Append(",\"skill\":");
BridgeJson.Escape(sb, skill);
}
sb.Append(",\"reward\":[");
var rewards = m.RewardTitles;
if (rewards != null)
{
bool first = true;
for (int i = 0; i < rewards.Count; i++)
{
var r = rewards[i];
if (r == null)
continue;
if (!first) sb.Append(',');
first = false;
BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture));
}
}
sb.Append("]}");
}
private static bool IsGearLayer(Layer layer)
{
switch (layer)

View File

@@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Server.Guilds;
namespace Server.Custom.Bridge
{
/// <summary>
/// The guild stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
/// tick, fold each guild to a small signature, and emit `guild.update` only when it changes.
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
///
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
/// so joined" feed does not wait for the next sweep. A membership change also moves the board
/// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in
/// the next `guild.update`; per-member leave events would need a core tap and are a later
/// refinement (§10.1).
///
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
/// guild, would look like every guild being created at once.
/// </summary>
public static class BridgeSocial
{
private static Timer _timer;
// guild id -> last-emitted signature. An id absent here has never been emitted (or the cache
// was cleared on reconnect), so its next sweep counts as a change.
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
private static long _sweeps, _emitted, _removed, _joins;
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
EventSink.JoinGuild += OnJoinGuild;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
BridgeLink.Connected_Core += OnConnected;
Rearm();
}
private static void OnConnected()
{
_last.Clear();
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
GuildSweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
public static string Status()
{
return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
_sweeps, _emitted, _removed, _joins, _last.Count);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
GuildSweep();
}
private static void GuildSweep()
{
try
{
_sweeps++;
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
var seen = new HashSet<int>();
foreach (var bg in BaseGuild.List.Values)
{
var g = bg as Guild;
// Skip disbanded guilds (leader gone): they linger in the list until cleaned up,
// and treating them as absent lets the "gone" pass below emit guild.remove.
if (g == null || g.Disbanded)
continue;
seen.Add(g.Id);
var sig = Signature(g);
string prior;
if (_last.TryGetValue(g.Id, out prior) && prior == sig)
continue; // unchanged since last emit
_last[g.Id] = sig;
BridgeLink.Emit(WriteGuild(g));
_emitted++;
}
// Anything tracked last sweep but not seen now has disbanded or been removed.
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
foreach (var id in gone)
{
_last.Remove(id);
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
_removed++;
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message);
}
}
// The volatile fields that define a meaningful change: name, abbreviation, leader, member
// count, the member set (order-independent serial sum), and alliance.
private static string Signature(Guild g)
{
long memberSum = 0;
int count = 0;
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
{
var m = members[i];
if (m == null)
continue;
count++;
unchecked { memberSum += (uint)m.Serial.Value; }
}
}
var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value;
return String.Concat(
g.Name ?? "", "|",
g.Abbreviation ?? "", "|",
leaderSerial.ToString(), "|",
count.ToString(), "|",
memberSum.ToString(), "|",
g.Alliance == null ? "" : (g.AllianceName ?? ""));
}
private static string WriteGuild(Guild g)
{
int online = 0, count = 0;
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
{
var m = members[i];
if (m == null)
continue;
count++;
if (m.NetState != null)
online++;
}
}
var sb = BridgeJson.Begin("guild.update")
.Num("id", g.Id)
.Str("name", g.Name)
.Str("abbr", g.Abbreviation)
.Num("members", count)
.Num("online", online)
.Str("alliance", g.Alliance == null ? null : g.AllianceName);
sb.Actor("leader", g.Leader);
return sb.End();
}
// ---- real-time join ----
private static void OnJoinGuild(JoinGuildEventArgs e)
{
try
{
if (e == null || e.Mobile == null)
return;
var g = e.Guild as Guild;
var sb = BridgeJson.Begin("guild.join");
if (g != null)
sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation);
sb.Actor("who", e.Mobile);
BridgeLink.Emit(sb.End());
_joins++;
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message);
}
}
}
}

View File

@@ -11,7 +11,7 @@ namespace Server.Custom.Bridge
/// <summary>
/// The three polled streams, for state that has no EventSink: player vitals, house decay,
/// and money supply. All three run on the Core thread via repeating Timers, and the
/// measured cost (docs/PLAN.md §1) is why they can: at the seeded scale a full pass of all
/// measured cost (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1) is why they can: at the seeded scale a full pass of all
/// three is well under a millisecond.
///
/// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed

View File

@@ -9,7 +9,7 @@ namespace Server.Custom.Bridge
/// <summary>
/// Forwards IN-GAME uses of the write-plane verbs to the website as admin.audit
/// (origin=in-game), so the site's moderation log is complete regardless of whether an action
/// came from the website or a staff member in the game client. See docs/ADMIN_CONTROLS.md §5.5.
/// came from the website or a staff member in the game client. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
///
/// Two sources, mirroring how the shard records each:
/// - ban / kick: resolved with their target inside the stock generic command, which logs a

View File

@@ -11,7 +11,7 @@ git apply patches/<name>.patch
## Phase 7 — player-vendor sale (a coupled unit)
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See `docs/PLAN.md` §6.
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §6.
This is the one non-drop-in piece. Apply all three together:
@@ -56,4 +56,4 @@ Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Sc
## Note on shard repairs
The deletions and edits described in `docs/SHARD_PREREQS.md` are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
The deletions and edits described in [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.

View File

@@ -7,7 +7,7 @@ website ──WS (live feed) / REST (queries)──► sidecar ──loopback
(this) newline-JSON, bidirectional
```
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See `../docs/PLAN.md` §2.
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §2.
## Run
@@ -106,4 +106,4 @@ A shard `*.error` reply maps to HTTP 404 (unknown/not-found) or 400 (bad request
## Wire protocol
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in `../docs/PLAN.md` (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.

View File

@@ -20,7 +20,11 @@ use tracing_subscriber::EnvFilter;
/// Wire-protocol version between the website and the sidecar. Bump this whenever an event or
/// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
/// of failing in confusing ways.
pub const PROTOCOL_VERSION: u32 = 1;
///
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
pub const PROTOCOL_VERSION: u32 = 2;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -75,6 +79,7 @@ async fn main() -> anyhow::Result<()> {
let route_rpc = rpc.clone();
let event_store = store.clone();
let last_event_ts = last_event.clone();
let replay_handle = handle.clone(); // re-push external news to the shard on (re)connect
let mut total: u64 = 0;
tokio::spawn(async move {
while let Some(ev) = event_rx.recv().await {
@@ -132,10 +137,87 @@ async fn main() -> anyhow::Result<()> {
}
}
}
// Guild board (Protocol 2.0): guild.update folds in the latest roster (one row
// per guild id); guild.remove drops a disbanded guild.
"guild.update" => {
if let Some(id) = ev.value.get("id").and_then(|v| v.as_i64()) {
if let Err(e) = event_store
.upsert_guild(
id,
ev.value.get("name").and_then(|n| n.as_str()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert guild board");
}
}
}
"guild.remove" => {
if let Some(id) = ev.value.get("id").and_then(|v| v.as_i64()) {
if let Err(e) = event_store.delete_guild(id).await {
tracing::warn!(error = %e, "failed to remove guild board row");
}
}
}
// Governor board (Protocol 2.0): city.update folds in each city's latest
// governance state (one row per city).
"city.update" => {
if let Some(city) = ev.value.get("city").and_then(|c| c.as_str()) {
if let Err(e) = event_store.upsert_governor(city, &text, t).await {
tracing::warn!(error = %e, "failed to upsert governor board");
}
}
}
// House registry (Protocol 2.0): house.update folds in each house's latest state
// (one row per serial); house.remove drops a demolished/traded house.
"house.update" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store
.upsert_house(
serial,
ev.value.get("name").and_then(|n| n.as_str()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert house registry");
}
}
}
"house.remove" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store.delete_house(serial).await {
tracing::warn!(error = %e, "failed to remove house registry row");
}
}
}
_ => {}
}
}
// On a shard (re)connect, re-push the stored external news: the shard rebuilds
// TownCryerSystem.NewsEntries from scratch each boot and does not persist ours. Replay
// with announce=false so a restart does not re-proclaim every article at once. news.add
// is idempotent by id, so replaying to a still-populated shard is harmless.
if ev.kind == "server.hello" {
match event_store.news_all().await {
Ok(items) => {
for mut item in items {
if let Some(obj) = item.as_object_mut() {
obj.insert("announce".to_string(), serde_json::json!(false));
}
if !replay_handle.send(item.to_string()).await {
break; // shard went away mid-replay
}
}
}
Err(e) => tracing::warn!(error = %e, "news replay: could not read stored news"),
}
}
let _ = feed_tx.send(ev.value.to_string());
}
});

View File

@@ -2,7 +2,7 @@
//!
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
//! is never directly reachable from the website — it exposes no port. See docs/PLAN.md §2.
//! is never directly reachable from the website — it exposes no port. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2.
//!
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its

View File

@@ -104,6 +104,16 @@ impl Store {
Ok(row.map(|r| r.get::<String, _>("website_user_id")))
}
/// Drops the mirrored link row so event attribution stops immediately, without waiting on the
/// shard. Returns the number of rows removed (0 if the account was not linked here).
pub async fn record_unlink(&self, account: &str) -> anyhow::Result<u64> {
let res = sqlx::query("DELETE FROM links WHERE account = ?")
.bind(account)
.execute(&self.pool)
.await?;
Ok(res.rows_affected())
}
pub async fn cache_profile(
&self,
serial: &str,
@@ -177,6 +187,147 @@ impl Store {
.await?;
Ok(parse_json_column(rows))
}
// ---- guild board (Protocol 2.0) ----
/// Upserts one guild's latest state, keyed by guild id. Fed from `guild.update`; one row per
/// guild, always the most recent snapshot. This is the board the website reads on load.
pub async fn upsert_guild(
&self,
id: i64,
name: Option<&str>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO guilds (id, name, json, updated_t) VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(id)
.bind(name)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Drops one guild from the board. Fed from `guild.remove` (a disband or a removed guild).
pub async fn delete_guild(&self, id: i64) -> anyhow::Result<()> {
sqlx::query("DELETE FROM guilds WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// The full guild board: every guild's latest snapshot, ordered by name.
pub async fn guilds_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM guilds ORDER BY name, id")
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
// ---- governor board (Protocol 2.0) ----
/// Upserts one city's latest governance state, keyed by city name. Fed from `city.update`.
pub async fn upsert_governor(&self, city: &str, json: &str, t: i64) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO governors (city, json, updated_t) VALUES (?, ?, ?)
ON CONFLICT(city) DO UPDATE SET json = excluded.json, updated_t = excluded.updated_t",
)
.bind(city)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// The full governor board: every city's latest governance snapshot, ordered by city.
pub async fn governors_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM governors ORDER BY city")
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
// ---- house registry (Protocol 2.0) ----
/// Upserts one house's latest state, keyed by serial. Fed from `house.update`.
pub async fn upsert_house(
&self,
serial: &str,
name: Option<&str>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO houses (serial, name, json, updated_t) VALUES (?, ?, ?, ?)
ON CONFLICT(serial) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(serial)
.bind(name)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Drops one house from the registry. Fed from `house.remove` (demolished / traded away).
pub async fn delete_house(&self, serial: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM houses WHERE serial = ?")
.bind(serial)
.execute(&self.pool)
.await?;
Ok(())
}
/// The full house registry: every house's latest snapshot, ordered by name then serial.
pub async fn houses_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM houses ORDER BY name, serial")
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
// ---- Town Cryer news (Protocol 2.1) ----
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
/// website is the source of truth; this lets the sidecar replay the set to the shard on reconnect
/// (the shard does not persist NewsEntries across a reboot).
pub async fn upsert_news(&self, id: &str, json: &str, t: i64) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO news (id, json, updated_t) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET json = excluded.json, updated_t = excluded.updated_t",
)
.bind(id)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Removes one external news article.
pub async fn delete_news(&self, id: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM news WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Every stored external news article (as its `news.add` command), oldest first so a replay
/// re-inserts them in the same order the website added them.
pub async fn news_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM news ORDER BY updated_t")
.fetch_all(&self.pool)
.await?;
Ok(parse_json_column(rows))
}
}
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
@@ -215,4 +366,30 @@ CREATE TABLE IF NOT EXISTS champs (
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS guilds (
id INTEGER PRIMARY KEY,
name TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS governors (
city TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS houses (
serial TEXT PRIMARY KEY,
name TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS news (
id TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
"#;

View File

@@ -54,9 +54,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/vendors/:account", get(vendors))
// Inbound commands (correlated by code / id).
.route("/link/confirm", post(link_confirm))
.route("/link/:account", get(link_lookup))
// Account provisioning (Protocol 2.0). Create is correlated by reqId; the DELETE unlinks.
.route("/accounts/create", post(account_create))
.route("/link/:account", get(link_lookup).delete(link_delete))
.route("/towncrier", post(towncrier_add))
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
// Town Cryer news gump (Protocol 2.1). Add/replace an article; delete one.
.route("/news", post(news_add))
.route("/news/:id", axum::routing::delete(news_remove))
// Staff write plane (correlated by reqId). The shard enforces the real authorization;
// the website must gate these behind admin/moderator roles before calling.
.route("/admin/kick", post(admin_kick))
@@ -71,6 +76,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/history", get(history))
.route("/economy", get(economy))
.route("/champs", get(champs))
// World-state boards (Protocol 2.0), served from the store so they answer without the shard
// and survive an outage with the last-known snapshot (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §12.2).
.route("/guilds", get(guilds))
.route("/governors", get(governors))
.route("/online", get(online))
.route("/houses", get(houses))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new()
@@ -288,6 +299,138 @@ fn respond_admin(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
}
}
/// Like `respond`, but for the account-provisioning plane. Maps an `account.error` reply to a
/// status by its reason: a name clash is a 409, the per-IP cap is a 429, a disabled/protected/
/// refused action is a 403, an unknown target or "not linked" is a 404, anything else a 400.
fn respond_account(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
match result {
Ok(value) => {
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
if kind == "account.error" {
let reason = value
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("request rejected");
let code = if reason.contains("already exists") {
StatusCode::CONFLICT
} else if reason.contains("ip account limit") {
StatusCode::TOO_MANY_REQUESTS
} else if reason.contains("disabled")
|| reason.contains("protected")
|| reason.contains("refused")
{
StatusCode::FORBIDDEN
} else if reason.contains("unknown") || reason.contains("not linked") {
StatusCode::NOT_FOUND
} else {
StatusCode::BAD_REQUEST
};
(code, Json(value))
} else {
(StatusCode::OK, Json(value))
}
}
Err(RpcError::NoShard) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "shard not connected"})),
),
Err(RpcError::Timeout) => (
StatusCode::GATEWAY_TIMEOUT,
Json(json!({"error": "shard did not reply in time"})),
),
}
}
// ---- account-provisioning handlers ----
/// Body: {"actor","account","password","websiteUserId","ip"}. Creates and links a game account.
/// Correlated on a fresh reqId. The password is forwarded to the shard (loopback) but never logged
/// here and never appears in the reply; a successful create mirrors the link into the store.
async fn account_create(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
let mut obj = match body {
Value::Object(m) => m,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "body must be a JSON object"})),
)
}
};
// Required, non-empty. `ip` is validated on the shard (which owns the cap), not here.
for field in ["actor", "account", "password", "websiteUserId"] {
let present = obj
.get(field)
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !present {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("{field} is required") })),
);
}
}
let req_id = st.rpc.next_req_id();
obj.insert("kind".to_string(), json!("account.create"));
obj.insert("reqId".to_string(), json!(req_id));
let result = st.rpc.call(&st.shard, Value::Object(obj), &req_id).await;
// Mirror a successful create's link into the store, so events are attributable without the
// shard (same as link.confirm does).
if let Ok(value) = &result {
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
if let (Some(account), Some(web_id)) = (
value.get("account").and_then(|a| a.as_str()),
value.get("websiteUserId").and_then(|w| w.as_str()),
) {
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
let _ = st.store.record_link(account, web_id, t).await;
}
}
}
respond_account(result)
}
/// Unlinks a game account from its website user. Body: {"actor"}. Correlated on reqId; a success
/// also clears the sidecar's mirrored link row so attribution stops immediately.
async fn link_delete(
State(st): State<AppState>,
Path(account): Path<String>,
body: Option<Json<Value>>,
) -> impl IntoResponse {
let actor = body
.as_ref()
.and_then(|Json(b)| b.get("actor").and_then(|a| a.as_str()))
.unwrap_or_default()
.trim()
.to_string();
if actor.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "actor is required"})),
);
}
let req_id = st.rpc.next_req_id();
let cmd = json!({
"kind": "account.unlink", "reqId": req_id, "actor": actor, "account": account
});
let result = st.rpc.call(&st.shard, cmd, &req_id).await;
if let Ok(value) = &result {
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
let _ = st.store.record_unlink(&account).await;
}
}
respond_account(result)
}
// ---- admin write-plane handlers ----
/// Forwards a staff moderation command to the shard, correlated on a fresh reqId. Injects `kind`
@@ -521,6 +664,50 @@ async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) ->
respond(st.rpc.call(&st.shard, cmd, &id).await)
}
/// Body: {"id":"42","title":"...","body":"<html>","image":1614,"url":"...","announce":true}.
/// Adds/replaces a Town Cryer news article. Correlated on `id`. A success is stored so the sidecar
/// can replay the article to the shard on reconnect (NewsEntries is not persisted across a reboot).
async fn news_add(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
let id = body.get("id").and_then(|i| i.as_str()).unwrap_or_default();
let title_ok = body
.get("title")
.and_then(|t| t.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if id.is_empty() || !title_ok {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "id and title are required"})),
);
}
let mut cmd = body.clone();
cmd["kind"] = json!("news.add");
let id = id.to_string();
let result = st.rpc.call(&st.shard, cmd.clone(), &id).await;
// Persist the article (as its news.add command) so it can be replayed on shard reconnect.
if let Ok(value) = &result {
if value.get("kind").and_then(|k| k.as_str()) == Some("news.ok") {
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
let _ = st.store.upsert_news(&id, &cmd.to_string(), t).await;
}
}
respond(result)
}
async fn news_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
let cmd = json!({"kind":"news.remove","id":id});
let result = st.rpc.call(&st.shard, cmd, &id).await;
if let Ok(value) = &result {
if value.get("kind").and_then(|k| k.as_str()) == Some("news.ok") {
let _ = st.store.delete_news(&id).await;
}
}
respond(result)
}
// ---- history (from SQLite) ----
#[derive(Deserialize)]
@@ -566,6 +753,63 @@ async fn champs(State(st): State<AppState>) -> impl IntoResponse {
}
}
/// The guild board: every guild's latest roster snapshot (id/name/abbr/leader/members/alliance).
/// Served from the local board table, so it hydrates a fresh page or a restarted sidecar without a
/// shard round-trip. The live `guild.*` feed then keeps it current.
async fn guilds(State(st): State<AppState>) -> impl IntoResponse {
match st.store.guilds_all().await {
Ok(guilds) => (StatusCode::OK, Json(json!({"guilds": guilds}))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// The governor board: each city's latest governance snapshot (governor/elect/election phase).
/// Served from the local board table for the same reason as `/guilds`.
async fn governors(State(st): State<AppState>) -> impl IntoResponse {
match st.store.governors_all().await {
Ok(cities) => (StatusCode::OK, Json(json!({"cities": cities}))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// The house registry: every house's latest snapshot (owner/region/location/decay/value). Served
/// from the local board table, so it hydrates without the shard and survives an outage.
async fn houses(State(st): State<AppState>) -> impl IntoResponse {
match st.store.houses_all().await {
Ok(houses) => (StatusCode::OK, Json(json!({"houses": houses}))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// The current online population: total plus per-facet and per-region counts. This is the most
/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the
/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the
/// population time series. Returns `count: 0` if the shard has not reported one yet.
async fn online(State(st): State<AppState>) -> impl IntoResponse {
match st.store.recent(Some("presence.online"), 1).await {
Ok(mut events) => match events.pop() {
Some(latest) => (StatusCode::OK, Json(latest)),
None => (
StatusCode::OK,
Json(json!({"kind": "presence.online", "count": 0, "byFacet": {}, "byRegion": {}})),
),
},
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
// ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {

View File

@@ -2,7 +2,7 @@
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
These two scripts produced the measured budget in `docs/PLAN.md` §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
| File | Server path when testing | What |
|------|--------------------------|------|