34 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
4d21ef0b63 Merge pull request 'fix(ci): strip CR/LF from registry secrets so the push/release steps work' (#6) from fix/release-secret-newline into main
All checks were successful
Release sidecar / release (push) Successful in 8m19s
Reviewed-on: UOM/link#6
2026-07-14 17:10:43 +00:00
0550129f8e fix(ci): strip CR/LF from registry secrets before use
The release run built its push URL and auth header directly from
`secrets.REGISTRY_USER` / `REGISTRY_TOKEN`. A trailing newline in
REGISTRY_USER produced a malformed remote:

    warning: url contains a newline in its username component
    fatal: credential url cannot be parsed

Pass the secrets through `env:` and strip CR/LF (`tr -d '\r\n'`) before
building the URL (push step) and the `Authorization: token` header (release
step). Using env instead of inline `${{ }}` also stops a newline from
breaking the shell script itself. Verified the file still parses and the
trim turns `Whitlocktech\n` into a clean single-line URL.

Underlying cause is secret hygiene (the value was saved with a trailing
newline); this makes the workflow robust to it either way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 12:09:46 -05:00
09c59b256e Merge pull request 'fix(ci): sync Cargo.lock after version bump so --locked steps pass' (#5) from fix/release-locked-lockfile into main
Some checks failed
Release sidecar / release (push) Failing after 8m27s
Reviewed-on: UOM/link#5
2026-07-14 16:58:10 +00:00
45227b1a74 fix(ci): sync Cargo.lock after version bump so --locked steps pass
The release step rewrites the crate version in Cargo.toml (0.1.0 -> next),
which desyncs this crate's own entry in Cargo.lock. The following
`cargo test/build --locked` steps then abort:

    error: cannot update the lock file ... because --locked was passed

Run `cargo update --manifest-path sidecar/Cargo.toml --workspace` right
after the bump: it updates only the workspace member's version in the lock
and leaves every dependency pin untouched, so --locked still guarantees
reproducible deps. Verified locally — reproduced the exact failure, then
confirmed the sync makes `cargo build --locked` succeed with 6 dependencies
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 11:57:11 -05:00
78fcb7effb Merge pull request 'fix(ci): quote release workflow if: so the YAML parses' (#4) from fix/release-workflow-yaml into main
Some checks failed
Release sidecar / release (push) Failing after 2m15s
Reviewed-on: UOM/link#4
2026-07-14 16:51:50 +00:00
ddf14bdab0 fix(ci): quote release workflow if: so YAML parses
The job-level `if:` expression contains a colon (`chore(release):`), which
an unquoted YAML scalar misreads as a mapping value — Gitea rejected the
workflow with "yaml: line 59: mapping values are not allowed in this
context". Double-quote the whole `${{ }}` expression so it is treated as a
string. Verified the file parses end-to-end after the change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 11:51:22 -05:00
4badd15f91 Merge pull request 'feat(champ): stream champion-spawn state to the sidecar board' (#3) from feature/champ-spawns into main
Reviewed-on: UOM/link#3
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-14 16:47:12 +00:00
e30eec5e4d Merge branch 'main' into feature/champ-spawns 2026-07-14 16:46:55 +00:00
b96a867691 Merge pull request 'ci(sidecar): automated build + release workflow (Gitea Actions)' (#2) from ci/sidecar-release into main
Reviewed-on: UOM/link#2
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-14 16:46:07 +00:00
fa7f1f786b Merge branch 'main' into ci/sidecar-release 2026-07-14 16:44:48 +00:00
b258ee3e60 feat(champ): stream champion-spawn state to the sidecar board
Champion spawns have no ServUO EventSink, so add a fourth polled stream
(BridgeChamps) modeled on BridgeSweeps: enumerate every spawn each tick,
fold to a small record, and emit champ.update only on change. No core
patch — every field used is public.

Covers all three families via a `category` field:
  - champion: ChampionSpawn (type/level/kills/boss/cooldown ETA)
  - mini:     MiniChamp (type/level; auto-restarts, no kill counter)
  - sea:      BaseSeaChampion (a High Seas world-boss mobile, alive only
              while summoned; removed via champ.remove when slain)

Status folds to active/cooldown/dormant. A (re)connection clears the diff
cache so the next sweep re-emits the full board, rebuilding a sidecar that
restarted on its own. Transient entries leave via champ.remove.

Sidecar: a `champs` current-state table (one row per serial) fed by
champ.update (upsert) and champ.remove (delete), exposed at GET /champs as
the live board. New ChampSweepSeconds config (default 10s), wired into
[bridge reload/sweepnow/status. Documented in docs/INTEGRATION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 05:46:21 -05:00
9df337e186 ci(sidecar): add Gitea Actions release workflow
Auto-build and release the Rust sidecar on every push to main.

A language-agnostic "release engine" computes the next version from
conventional-commit subjects since the last v* tag (feat!/BREAKING ->
major, feat -> minor, fix|perf -> patch; first run ships the current
Cargo.toml version). An isolated "Rust adapter" runs cargo fmt --check /
test, builds x86_64-unknown-linux-gnu, and cross-builds
x86_64-pc-windows-gnu via MinGW (libsqlite3-sys is the only native dep).
It then commits the version bump ([skip ci]), tags vX.Y.Z, pushes, and
creates the Gitea release with the linux binary, windows .exe, and
SHA256SUMS.

Reuses the REGISTRY_USER / REGISTRY_TOKEN secrets; the token additionally
needs write:repository scope and main must accept a direct push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 14:40:05 -05:00
359bb937b2 style(sidecar): apply rustfmt
Format the sidecar source with `cargo fmt` so the new `cargo fmt --check`
CI gate passes on the first run. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 14:40:05 -05:00
5562e09fb0 Merge pull request 'feat(admin): staff write plane + help-page queue' (#1) from feature/admin-controls into main
Reviewed-on: UOM/link#1
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-13 08:39:43 +00:00
c515a86a87 docs(admin): shipped scope is Phase 1 + Phase 2; Phase 3 not planned
Records the owner decision to stop after the help-page queue: Phase 3
(mute/notes/teleport/save) will not be built. The Tier-B candidates remain
catalogued in §3 for the record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 02:58:04 -05:00
17e1c91fb3 feat(pages): help-page (support) queue — stream, snapshot, respond, close
Phase 2 of docs/ADMIN_CONTROLS.md: surface the in-game help-page queue to the
website.

- BridgePages.cs: the queue has no EventSink, so it is polled (PageSweepSeconds,
  default 5s) and diffed, keyed by sender serial (one page per player) ->
  page.new / page.updated / page.closed. Inbound pages.snapshot -> pages.list;
  page.respond delivers a staff reply to the player (online: a gump now; offline:
  queued for next login; shows as "Staff") and can close; page.close removes it.
- BridgeConfig/Bridge.cfg: PageSweepSeconds. BridgeBoot: reload re-arms the poll,
  status reports it.
- sidecar/src/web.rs: GET /pages, POST /pages/{id}/respond, POST /pages/{id}/close.
- INTEGRATION.md: page events (§4) and endpoints (§6).
- tools/scaffolding/BridgePageProbe.cs: gated headless verification.

Verified live (probe-seeded tickets): snapshot returns the queue, the poll emits
page.new for both and page.closed on removal, respond -> 200, close removes the
page, unknown page -> 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 02:54:19 -05:00
11169c52a6 feat(admin): forward in-game moderation to the website (bidirectional audit)
Phase C / §5.5: so the site's moderation log is complete regardless of origin,
in-game uses of the write-plane verbs are forwarded as admin.audit
(origin:"in-game").

- patches/commandlogging-event.patch: adds CommandLogging.OnWrite, raised in
  WriteLine before the m_Enabled guard so it fires even when file logging is
  off. Scripts-layer file -> dynamic build, no core rebuild.
- patches/BridgeModerationAudit.cs: subscriber. Taps OnWrite for resolved
  ban/kick (parsing the target from the log line) and EventSink.Command for
  [bcast. Lives in patches/ (not overlay/) because it references OnWrite,
  which only exists post-patch — same rule as BridgeVendorSale.cs.
- tools/scaffolding/BridgeAuditProbe.cs: gated headless verification.

Verified live: a genuine [bcast plus simulated ban/kick log lines produced
admin.audit frames with origin=in-game, actor, and the target parsed
(seed_010); a non-moderation line was correctly ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 02:12:30 -05:00
a0dbb80e1e feat(admin): sidecar REST routes for the write plane
Phase 1 (sidecar side): POST /admin/{kick,ban,unban,broadcast} forward to the
shard, correlated on a fresh reqId, with an admin-specific status mapping —
unknown target -> 404, protected target / plane-disabled -> 403, missing actor
/ bad body -> 400. actor is required and checked up front. Documents the
endpoints and the admin.audit event in INTEGRATION.md.

Verified end-to-end (real sidecar + booted shard): 200 on success, 403 on the
Owner floor, 404 unknown target, 400 missing actor, 401 no token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 02:00:17 -05:00
213f3fa2ac feat(admin): plugin write plane — admin.kick/ban/unban/broadcast
Phase 1 (plugin side) of docs/ADMIN_CONTROLS.md: a staff write plane so the
website can moderate the live shard.

- BridgeAdmin.cs: inbound admin.kick, admin.ban (timed + indefinite),
  admin.unban, admin.broadcast. Each requires an `actor`, refuses targets at
  or above AdminAccessFloor (default CoOwner — Owner-only shield), replies
  admin.ok/admin.error with the reqId echoed, and emits an admin.audit
  (origin=web) broadcast. Attribution is web:<actor> in the console log and
  the ban BanDealer tag. Kicking enumerates NetState.Instances so a
  character-select session is caught too.
- BridgeConfig/Bridge.cfg: AdminWriteEnabled (default OFF — opt-in),
  AdminAccessFloor, broadcast/reason length caps, ban duration clamp.
- tools/stub_sidecar_admin.ps1: live smoke-test harness; *.log gitignored.

Verified: compiles clean against ServUO (0 err/warn); live run on the seeded
shard confirms all four verbs, the audit stream, timed-ban fields, and the
Owner-floor refusal, with no exceptions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 01:55:11 -05:00
37 changed files with 4135 additions and 1539 deletions

View File

@@ -0,0 +1,258 @@
# Automated build + release for the uo-link Rust sidecar.
#
# Trigger: every push to `main` (i.e. every merged PR).
#
# Flow (two conceptual halves, kept separate on purpose):
#
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
# │ reads: latest v* git tag + conventional-commit subjects │
# │ produces: next version, changelog, and (at the end) the release │
# └───────────────────────────────────────────────────────────────────┘
# ┌── RUST ADAPTER (the only Rust-specific part) ─────────────────────┐
# │ consumes: the version │
# │ produces: the artifacts (linux bin, windows exe, SHA256SUMS) │
# └───────────────────────────────────────────────────────────────────┘
#
# To retarget this engine at a C#/Node/Docker/static project later, only the
# "Rust adapter" steps change — the plan + release steps consume just
# {version, changelog, artifacts} and know nothing about Rust.
#
# Version bump (conventional commits since the last v* tag):
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases the current Cargo.toml version as-is
#
# 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
# `write:repository` so it can push the bump commit + tag
# and create the release. Grant that scope to the token.
# Also: `main` must accept a direct push from that user (disable branch
# protection for it, or add it as an exception) — the bump commit lands on main.
#
# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
name: Release sidecar
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: release-sidecar
cancel-in-progress: false
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/link
WORKDIR: sidecar
BIN: uo-link-sidecar
LINUX_TARGET: x86_64-unknown-linux-gnu
WINDOWS_TARGET: x86_64-pc-windows-gnu
jobs:
release:
runs-on: ubuntu-latest
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
# Quoted because the expression contains a colon (`chore(release):`), which an
# unquoted YAML scalar would misparse as a mapping value.
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
steps:
- name: Check out full history (need tags + commit log for the bump)
uses: actions/checkout@v4
with:
fetch-depth: 0
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
- name: Plan the release (version + changelog)
id: plan
run: |
set -euo pipefail
mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true
CARGO_VERSION="$(grep -m1 '^version' "${WORKDIR}/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
RELEASE=true
if [ -z "$LAST_TAG" ]; then
VERSION="$CARGO_VERSION" # first release: ship what's in Cargo.toml
elif [ "$BUMP" = none ]; then
RELEASE=false # no feat/fix/breaking since last tag
VERSION="${LAST_TAG#v}"
else
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
fi
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "Tag v${VERSION} already exists — nothing to release."
RELEASE=false
fi
{
echo "## ${BIN} v${VERSION}"
echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes"
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
echo "$SUBJECTS" | sed 's/^/- /'
} > dist/CHANGELOG.md
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
- name: Install Rust toolchain, Windows target, and MinGW linker
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable
fi
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt
rustup target add "${WINDOWS_TARGET}"
- name: Set the crate version to match the release
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
# Replace only the [package] version (the first `version = "..."`).
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml"
grep -m1 '^version' "${WORKDIR}/Cargo.toml"
# Bumping the manifest version desyncs this crate's own entry in
# Cargo.lock, which would make the `--locked` fmt/test/build steps below
# fail ("cannot update the lock file ... --locked was passed"). Sync just
# the workspace member(s) into the lock — dependency pins are untouched.
cargo update --manifest-path "${WORKDIR}/Cargo.toml" --workspace
# ── RUST ADAPTER: gates ──────────────────────────────────────────────
- name: cargo fmt --check
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo fmt --check
- name: cargo test
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo test --locked
# ── RUST ADAPTER: build both targets ─────────────────────────────────
- name: cargo build --release (Linux)
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
run: cargo build --release --locked --target "${LINUX_TARGET}"
- name: cargo build --release (Windows, cross via MinGW)
if: ${{ steps.plan.outputs.release == 'true' }}
working-directory: sidecar
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
- name: Package artifacts and SHA256SUMS
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
- name: Commit version bump and push tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
TAG="${{ steps.plan.outputs.tag }}"
# Secrets can arrive with a trailing newline (depending on how they were
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
# be parsed"). Strip line breaks before building the URL. Passing them via
# env (not inline ${{ }}) also keeps a newline from breaking this script.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "uo-link-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git add "${WORKDIR}/Cargo.toml" "${WORKDIR}/Cargo.lock"
if ! git diff --cached --quiet; then
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
git push origin "HEAD:main"
else
echo "Version unchanged (first release) — no bump commit needed."
fi
git tag "${TAG}"
git push origin "${TAG}"
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
- name: Create Gitea release and upload assets
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
BODY="$(cat dist/CHANGELOG.md)"
# Same newline hygiene as the push step: a stray CR/LF in the token would
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
echo " uploaded ${f}"
done

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ obj/
*.dll *.dll
*.exe *.exe
*.pdb *.pdb
*.log

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. 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 ## Layout
| Path | What | | 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. | | `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`. | | `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. | | `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. | | [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. |
| `docs/PLAN.md` | Implementation plan, measured performance budget, and the full data catalog. | | [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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. | | [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. |
| `docs/SHARD_PREREQS.md` | Repairs the target shard needed before any of this could load. | | [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. | | `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. 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 | | Phase | State |
|------:|-------| |------:|-------|
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** | | 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** | | 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 `docs/PLAN.md` §12** | | 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 `docs/PLAN.md` §13** | | 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 `docs/PLAN.md` §14** | | 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 `docs/PLAN.md` §15** | | 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 `docs/PLAN.md` §16** | | 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 `docs/PLAN.md` §17** | | 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. 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). 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,371 +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. |
#### 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. |
---
## 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`.
### 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":...}, ... ] }
```
---
## 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,11 +14,40 @@ Port=7788
QueueCap=10000 QueueCap=10000
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs # 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 StatSweepSeconds=30
DecaySweepSeconds=60 DecaySweepSeconds=60
EconomySweepSeconds=300 EconomySweepSeconds=300
# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
# this interval to emit champ.update on any status/level/kills/boss change. The world holds
# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
ChampSweepSeconds=10
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
# 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. # Shown to a player when they run [link. The website page where they enter the code.
LinkUrl=https://yoursite/link LinkUrl=https://yoursite/link
@@ -29,6 +58,58 @@ TownCrierMaxLineLength=200
TownCrierMaxActive=20 TownCrierMaxActive=20
TownCrierMaxDurationSec=86400 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
# loopback socket and applies a hard floor below.
AdminWriteEnabled=false
# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
AdminAccessFloor=CoOwner
# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
AdminBroadcastMaxLength=300
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 # The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose: # (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed # 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. /// 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 /// 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. /// sidecar ever moves off-host, gate it behind a shared secret.
/// </summary> /// </summary>
public static class BridgeAccountLink public static class BridgeAccountLink
@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
return; return;
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand); CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm); BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
// Purge expired codes so an unconfirmed spam of [link cannot grow the table forever. // 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); 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 ---- // ---- inbound link.confirm ----
private static void OnLinkConfirm(Dictionary<string, object> o) 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

@@ -0,0 +1,364 @@
using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Network;
namespace Server.Custom.Bridge
{
/// <summary>
/// The staff write plane: moderation actions the website drives against the live shard.
/// Phase 1 verbs are admin.kick, admin.ban, admin.unban, admin.broadcast.
///
/// 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 (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
/// target at or above BridgeConfig.AdminAccessFloor (default CoOwner), so a compromised or
/// buggy sidecar can never ban, kick, or otherwise touch the Owner.
///
/// The whole plane is opt-in: nothing here acts unless BridgeConfig.AdminWriteEnabled is set.
/// Attribution rides on a required "actor" field (the website staff user); every applied
/// action logs to the console and emits an admin.audit event the website persists.
/// </summary>
public static class BridgeAdmin
{
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("admin.kick", OnKick);
BridgeBoot.RegisterHandler("admin.ban", OnBan);
BridgeBoot.RegisterHandler("admin.unban", OnUnban);
BridgeBoot.RegisterHandler("admin.broadcast", OnBroadcast);
}
// ---- admin.kick ----
/// <summary>Disconnects every live session of the target account. Target by serial or account.</summary>
private static void OnKick(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "kick";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
if (Protected(acct))
{
Err(reqId, action, "target is protected staff; refused");
return;
}
int kicked = KickAccountSessions(acct);
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Num("sessions", kicked)
.Str("reason", reason)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("target", acct.Username).Num("sessions", kicked);
BridgeLink.Emit(sb.End());
}
// ---- admin.ban ----
/// <summary>
/// Bans an account (offline-capable) and disconnects any live sessions. A positive
/// durationSec makes it a timed ban that auto-expires; zero/absent is indefinite. Mirrors
/// the in-game [ban path (KickCommand), but takes the duration explicitly instead of a gump.
/// </summary>
private static void OnBan(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "ban";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
if (Protected(acct))
{
Err(reqId, action, "target is protected staff; refused");
return;
}
int durationSec = BridgeJson.GetInt(o, "durationSec", 0);
if (durationSec < 0)
durationSec = 0;
if (durationSec > BridgeConfig.AdminBanMaxDurationSec)
durationSec = BridgeConfig.AdminBanMaxDurationSec;
if (durationSec > 0)
acct.SetBanTags(null, DateTime.UtcNow, TimeSpan.FromSeconds(durationSec));
else
acct.SetUnspecifiedBan(null); // clears any prior duration tags -> indefinite
// SetBanTags/SetUnspecifiedBan(null) clear the BanDealer tag; set our own attribution.
acct.SetTag("BanDealer", WebActor(actor));
acct.Banned = true;
int kicked = KickAccountSessions(acct);
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Num("durationSec", durationSec)
.Num("sessions", kicked)
.Str("reason", reason)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("target", acct.Username).Num("durationSec", durationSec).Num("sessions", kicked);
BridgeLink.Emit(sb.End());
}
// ---- admin.unban ----
private static void OnUnban(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "unban";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
acct.Banned = false;
acct.SetUnspecifiedBan(null); // clears BanTime/BanDuration/BanDealer tags
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Str("reason", reason)
.End());
Ok(reqId, action, acct.Username);
}
// ---- admin.broadcast ----
private static void OnBroadcast(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "broadcast";
if (!Ready(reqId, action, actor))
return;
var text = BridgeJson.GetString(o, "text");
if (String.IsNullOrEmpty(text))
{
Err(reqId, action, "missing text");
return;
}
if (text.Length > BridgeConfig.AdminBroadcastMaxLength)
text = text.Substring(0, BridgeConfig.AdminBroadcastMaxLength);
// Default to the staff-broadcast green; callers may override.
int hue = BridgeJson.GetInt(o, "hue", 0x35);
World.Broadcast(hue, false, text);
Log(actor, action, null, text);
BridgeLink.Emit(AuditBegin(action, actor, null)
.Num("hue", hue)
.Str("text", text)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action);
BridgeLink.Emit(sb.End());
}
// ---- shared prologue / replies ----
/// <summary>Common gate: the write plane must be enabled and an actor must be present.</summary>
private static bool Ready(string reqId, string action, string actor)
{
if (!BridgeConfig.AdminWriteEnabled)
{
Err(reqId, action, "admin write plane disabled");
return false;
}
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
{
Err(reqId, action, "missing actor");
return false;
}
return true;
}
private static void Ok(string reqId, string action, string target)
{
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action);
if (target != null) sb.Str("target", target);
BridgeLink.Emit(sb.End());
}
private static void Err(string reqId, string action, string reason)
{
var sb = BridgeJson.Begin("admin.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 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 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)
{
return BridgeJson.Begin("admin.audit")
.Str("origin", "web")
.Str("action", action)
.Str("actor", WebActor(actor))
.Str("target", target);
}
private static string WebActor(string actor)
{
return "web:" + actor;
}
/// <summary>Reads and length-clamps the optional reason string.</summary>
private static string Reason(Dictionary<string, object> o)
{
var reason = BridgeJson.GetString(o, "reason");
if (reason != null && reason.Length > BridgeConfig.AdminReasonMaxLength)
reason = reason.Substring(0, BridgeConfig.AdminReasonMaxLength);
return reason;
}
private static void Log(string actor, string action, string target, string detail)
{
Console.WriteLine("[Bridge][admin] {0} {1} target={2} detail={3}",
WebActor(actor), action, target ?? "-", detail ?? "-");
}
// ---- target resolution & floor ----
/// <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. Public so the
/// account plane (unlink) resolves targets the same way the moderation plane does.
/// </summary>
public static Account ResolveTargetAccount(Dictionary<string, object> o)
{
var serialStr = BridgeJson.GetString(o, "serial");
if (serialStr != null)
{
var m = ResolveSerial(serialStr);
return m == null ? null : m.Account as Account;
}
var acctName = BridgeJson.GetString(o, "account");
return acctName == null ? null : Accounts.GetAccount(acctName) as Account;
}
/// <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. Public
/// so the account plane (unlink) enforces the identical floor.
/// </summary>
public static bool Protected(Account acct)
{
var lvl = acct.AccessLevel;
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m != null && m.AccessLevel > lvl)
lvl = m.AccessLevel;
}
return lvl >= BridgeConfig.AdminAccessFloor;
}
/// <summary>
/// Disconnects every live NetState bound to this account. Enumerating NetState.Instances
/// (rather than walking the account's characters) also catches a session parked at
/// character-select, which has an account but no mobile yet. Snapshot first, since Dispose
/// mutates the instance set.
/// </summary>
private static int KickAccountSessions(Account acct)
{
var doomed = new List<NetState>();
foreach (var ns in NetState.Instances)
{
if (ns != null && ns.Account == acct)
doomed.Add(ns);
}
foreach (var ns in doomed)
ns.Dispose();
return doomed.Count;
}
private static Mobile ResolveSerial(string serialStr)
{
try
{
var s = serialStr.Trim();
int value;
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
value = Convert.ToInt32(s.Substring(2), 16);
else
value = Convert.ToInt32(s, 10);
return World.FindMobile(value);
}
catch
{
return null;
}
}
}
}

View File

@@ -159,6 +159,12 @@ namespace Server.Custom.Bridge
case "reload": case "reload":
BridgeConfig.Load(); BridgeConfig.Load();
BridgeSweeps.Rearm(); 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: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
break; break;
@@ -170,8 +176,18 @@ namespace Server.Custom.Bridge
case "sweepnow": case "sweepnow":
BridgeSweeps.SweepOnce(); 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: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); 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; break;
default: default:
@@ -181,6 +197,12 @@ namespace Server.Custom.Bridge
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped, BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors); BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); 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; break;
} }
} }

View File

@@ -0,0 +1,287 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Server.Engines.CannedEvil;
using Server.Engines.MiniChamps;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// The champion-spawn stream. Like the streams in <see cref="BridgeSweeps"/>, this is polled:
/// none of the three champion families expose an EventSink, so their whole lifecycle lives
/// inside a per-second SliceTimer and is invisible to a subscriber. Instead we enumerate them
/// each tick, fold each to a small record, and emit `champ.update` only when that record
/// changes. A 5-10s sweep is well within the site's tolerance and the world holds only a
/// handful of spawns, so the pass is trivially cheap.
///
/// Three families, distinguished by the `category` field:
/// champion - ChampionSpawn: the classic Felucca-style altar (type/level/kills/boss/cooldown)
/// mini - MiniChamp: the TerMur mini-champ controller (type/level, auto-restarts)
/// sea - BaseSeaChampion: a High Seas world boss Mobile, alive only while summoned
///
/// Status folds public fields into three values (no core patch needed):
/// active - running / alive
/// cooldown - stopped but a restart is pending (ChampionSpawn: RestartTime ahead; MiniChamp:
/// inactive, since it always re-arms a restart)
/// dormant - stopped with nothing scheduled (ChampionSpawn only; a GM must turn it on)
///
/// The sidecar keeps the latest record per serial as a live board. A permanent controller's
/// row lives as long as the item; a transient sea boss is removed with `champ.remove` when it
/// dies or despawns. A (re)connection clears the diff cache (see OnConnected) so the next
/// sweep re-emits every spawn in full, rebuilding a sidecar that restarted on its own.
/// </summary>
public static class BridgeChamps
{
private static Timer _timer;
// Last-emitted signature per tracked serial. A serial absent from this map has never been
// emitted (or the cache was cleared on reconnect), so its next sweep counts as a change.
// Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
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()
{
// Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
// independently of the shard rebuilds its state within one sweep.
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.ChampSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds),
ChampSweep);
}
public static void Stop()
{
if (_timer != null) { _timer.Stop(); _timer = null; }
}
public static string Status()
{
return String.Format("champs(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()
{
ChampSweep();
}
private static void ChampSweep()
{
try
{
_sweeps++;
if (!BridgeLink.Connected)
return; // nothing is listening; do not fill the queue with perishable snapshots
var seen = new HashSet<Serial>();
foreach (var s in World.Items.Values.OfType<ChampionSpawn>())
{
if (s.Deleted)
continue;
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
}
foreach (var s in World.Items.Values.OfType<MiniChamp>())
{
if (s.Deleted)
continue;
Track(seen, s.Serial, SigMini(s), WriteMini(s));
}
foreach (var b in World.Mobiles.Values.OfType<BaseSeaChampion>())
{
if (b.Deleted || !b.Alive)
continue;
Track(seen, b.Serial, SigSea(b), WriteSea(b));
}
// Anything tracked last sweep but not seen now has gone away (a controller deleted, a
// sea boss slain). Tell the sidecar to drop its board row.
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
foreach (var serial in gone)
{
_last.Remove(serial);
BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
_removed++;
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] champ sweep threw: {0}", ex.Message);
}
}
/// <summary>Records a spawn as seen and emits it only if its signature changed since last sweep.</summary>
private static void Track(HashSet<Serial> seen, Serial serial, string sig, string line)
{
seen.Add(serial);
string prior;
if (_last.TryGetValue(serial, out prior) && prior == sig)
return; // unchanged since last emit
_last[serial] = sig;
BridgeLink.Emit(line);
_emitted++;
}
// ---- ChampionSpawn (classic) ----
private static string StatusOf(ChampionSpawn s)
{
if (s.Active)
return "active";
if (s.RestartTime > DateTime.UtcNow)
return "cooldown";
return "dormant";
}
// The volatile fields that define a meaningful change. Kept in sync with WriteChampion so the
// site never misses a level, a kill-count tick, a boss pop, or a status/cooldown transition.
private static string SigChampion(ChampionSpawn s)
{
return String.Concat(
"champion|", StatusOf(s), "|",
s.Level.ToString(), "|",
s.Kills.ToString(), "|",
(s.Champion != null && !s.Champion.Deleted) ? "1" : "0", "|",
s.RestartTime.Ticks.ToString(), "|",
s.ExpireTime.Ticks.ToString());
}
private static string WriteChampion(ChampionSpawn s)
{
var status = StatusOf(s);
var bossUp = s.Champion != null && !s.Champion.Deleted;
// Prefer a staff-set display name, then the group, then the spawn type.
string name = !String.IsNullOrEmpty(s.SpawnName) ? s.SpawnName
: !String.IsNullOrEmpty(s.GroupName) ? s.GroupName
: s.Type.ToString();
var sb = BridgeJson.Begin("champ.update")
.Ser("serial", s.Serial)
.Str("category", "champion")
.Str("type", s.Type.ToString())
.Str("name", name)
.Str("status", status)
.Bool("active", s.Active)
.Num("level", s.Level)
.Num("rank", s.Rank)
.Num("kills", s.Kills)
.Num("maxKills", s.MaxKills)
.Bool("bossUp", bossUp)
.Bool("autoRestart", s.AutoRestart)
.Str("map", s.Map == null ? null : s.Map.Name)
.Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
if (bossUp)
sb.Str("boss", String.IsNullOrEmpty(s.Champion.Name) ? s.Champion.GetType().Name : s.Champion.Name);
// Cooldown ETA: when the spawn will auto-restart. Only meaningful while on cooldown.
if (status == "cooldown")
sb.Str("restartAt", s.RestartTime.ToUniversalTime().ToString("o"));
// Level-expiry ETA: when the current level times out if kills stall. Only while active.
if (s.Active)
sb.Str("expireAt", s.ExpireTime.ToUniversalTime().ToString("o"));
return sb.End();
}
// ---- MiniChamp (TerMur mini-champs) ----
// MiniChamp exposes no kills, no boss handle, and no restart-time getter. When inactive it has
// always re-armed a restart, so inactive folds to "cooldown" (there is no dormant state and no
// ETA to report).
private static string SigMini(MiniChamp s)
{
return String.Concat(
"mini|", (s.Active ? "active" : "cooldown"), "|", s.Level.ToString());
}
private static string WriteMini(MiniChamp s)
{
var status = s.Active ? "active" : "cooldown";
var info = MiniChampInfo.GetInfo(s.Type);
var sb = BridgeJson.Begin("champ.update")
.Ser("serial", s.Serial)
.Str("category", "mini")
.Str("type", s.Type.ToString())
.Str("name", s.Type.ToString())
.Str("status", status)
.Bool("active", s.Active)
.Num("level", s.Level)
.Bool("bossUp", false)
.Bool("autoRestart", true)
.Str("map", s.Map == null ? null : s.Map.Name)
.Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
if (info != null)
sb.Num("maxLevel", info.MaxLevel);
return sb.End();
}
// ---- BaseSeaChampion (High Seas world boss) ----
// A sea champion is a Mobile, not a controller: it exists only while summoned and alive, so it
// is always "active" on the board and leaves via champ.remove when slain. Position and health
// are tracked so the board can show a live "world boss here, N% hp".
private static string SigSea(BaseSeaChampion b)
{
return String.Concat(
"sea|", b.Hits.ToString(), "|", b.X.ToString(), "|", b.Y.ToString());
}
private static string WriteSea(BaseSeaChampion b)
{
string name = String.IsNullOrEmpty(b.Name) ? b.GetType().Name : b.Name;
return BridgeJson.Begin("champ.update")
.Ser("serial", b.Serial)
.Str("category", "sea")
.Str("type", b.GetType().Name)
.Str("name", name)
.Str("status", "active")
.Bool("active", true)
.Bool("bossUp", true)
.Str("boss", name)
.Num("hits", b.Hits)
.Num("hitsMax", b.HitsMax)
.Str("map", b.Map == null ? null : b.Map.Name)
.Num("x", b.X).Num("y", b.Y).Num("z", b.Z)
.End();
}
}
}

View File

@@ -2,6 +2,18 @@ using System;
namespace Server.Custom.Bridge 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> /// <summary>
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there /// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
/// reads as "Bridge.Port" here. /// reads as "Bridge.Port" here.
@@ -17,6 +29,12 @@ namespace Server.Custom.Bridge
public static int StatSweepSeconds { get; private set; } public static int StatSweepSeconds { get; private set; }
public static int DecaySweepSeconds { get; private set; } public static int DecaySweepSeconds { get; private set; }
public static int EconomySweepSeconds { get; private set; } 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; } public static string LinkUrl { get; private set; }
@@ -25,6 +43,25 @@ namespace Server.Custom.Bridge
public static int TownCrierMaxActive { get; private set; } public static int TownCrierMaxActive { get; private set; }
public static int TownCrierMaxDurationSec { 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 bool Enabled { get; private set; }
public static void Configure() public static void Configure()
@@ -44,6 +81,31 @@ namespace Server.Custom.Bridge
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30); StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60); DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300); EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
if (PageSweepSeconds < 1)
PageSweepSeconds = 1;
ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
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"); LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
@@ -52,15 +114,100 @@ namespace Server.Custom.Bridge
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20); TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400); 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) if (QueueCap < 16)
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>
/// Parses an AccessLevel name from config, case-insensitively, falling back to the given
/// default on anything unrecognized so a typo can never open the floor wider than intended.
/// </summary>
private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
{
AccessLevel parsed;
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
Enum.IsDefined(typeof(AccessLevel), parsed))
return parsed;
Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
return fallback;
} }
public static string Describe() public static string Describe()
{ {
return String.Format( return String.Format(
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s)", "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); Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
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> /// <summary>
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for /// 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. /// reflection serializer's.
/// ///
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so /// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
@@ -76,6 +76,46 @@ namespace Server.Custom.Bridge
return sb; 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> /// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
public static string End(this StringBuilder sb) 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,420 @@
using System;
using System.Collections.Generic;
using System.Text;
using Server.Accounting;
using Server.Engines.Help;
namespace Server.Custom.Bridge
{
/// <summary>
/// The in-game help-page (support ticket) queue, surfaced to the website.
///
/// A player who uses the Help button creates a <see cref="PageEntry"/> — sender, message,
/// type, location, and (once a staffer claims it) a handler. The queue lives in memory with
/// no EventSink, so — like the sweeps in <see cref="BridgeSweeps"/> — it is polled and diffed:
/// a page appearing emits <c>page.new</c>, one leaving emits <c>page.closed</c>, and a
/// handled-state change emits <c>page.updated</c>. The whole open queue is also available on
/// demand via the <c>pages.snapshot</c> request (the backfill a dashboard uses on connect).
///
/// A page is keyed by its sender's serial: the queue enforces one page per sender
/// (PageQueue.Contains), so the sender serial is a stable page id.
///
/// Inbound <c>page.respond</c> delivers a message to the player exactly as an in-game staff
/// response does (online: a gump now; offline: queued for next login), optionally closing the
/// page; <c>page.close</c> just removes it. Both run on the Core thread.
/// </summary>
public static class BridgePages
{
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static Timer _timer;
private static long _sweeps, _new, _closed, _updated;
private struct Seen
{
public long SentMs;
public bool Handled;
}
// sender serial -> last-seen page identity. Core-thread only.
private static readonly Dictionary<int, Seen> _seen = new Dictionary<int, Seen>();
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("pages.snapshot", OnSnapshot);
BridgeBoot.RegisterHandler("page.respond", OnRespond);
BridgeBoot.RegisterHandler("page.close", OnClose);
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
Baseline();
Rearm();
}
/// <summary>Stops and recreates the poll timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
if (_timer != null)
{
_timer.Stop();
_timer = null;
}
var iv = TimeSpan.FromSeconds(BridgeConfig.PageSweepSeconds);
_timer = Timer.DelayCall(iv, iv, Sweep);
}
public static string Status()
{
return String.Format(
"pages(sweeps={0} new={1} closed={2} updated={3} open={4})",
_sweeps, _new, _closed, _updated, _seen.Count);
}
/// <summary>Seeds _seen from the current queue without emitting, so a restart/reload does not
/// re-announce pages already open.</summary>
private static void Baseline()
{
_seen.Clear();
foreach (PageEntry e in PageQueue.List)
{
if (e == null || e.Sender == null)
continue;
_seen[e.Sender.Serial.Value] = new Seen { SentMs = ToMs(e.Sent), Handled = e.Handler != null };
}
}
// ---- poll ----
private static void Sweep()
{
try
{
_sweeps++;
var cur = new Dictionary<int, PageEntry>();
foreach (PageEntry e in PageQueue.List)
{
if (e == null || e.Sender == null)
continue;
cur[e.Sender.Serial.Value] = e;
}
// Closed: keys in _seen no longer present.
if (_seen.Count > 0)
{
List<int> gone = null;
foreach (var kv in _seen)
{
if (!cur.ContainsKey(kv.Key))
{
if (gone == null)
gone = new List<int>();
gone.Add(kv.Key);
}
}
if (gone != null)
{
foreach (var id in gone)
{
EmitClosed(id);
_seen.Remove(id);
}
}
}
// New / replaced / handled-state changed.
foreach (var kv in cur)
{
var e = kv.Value;
long sentMs = ToMs(e.Sent);
bool handled = e.Handler != null;
Seen prev;
if (!_seen.TryGetValue(kv.Key, out prev))
{
EmitNew(e);
}
else if (prev.SentMs != sentMs)
{
// Same sender, different page (they cancelled and re-paged within a tick).
EmitClosed(kv.Key);
EmitNew(e);
}
else if (prev.Handled != handled)
{
EmitUpdated(e);
}
_seen[kv.Key] = new Seen { SentMs = sentMs, Handled = handled };
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] page sweep threw: {0}", ex.Message);
}
}
// ---- outbound ----
private static void EmitNew(PageEntry e)
{
_new++;
var sb = BridgeJson.Begin("page.new").Str("pageId", PageId(e));
AppendPageTail(sb, e);
BridgeLink.Emit(sb.End());
}
private static void EmitUpdated(PageEntry e)
{
_updated++;
var sb = BridgeJson.Begin("page.updated").Str("pageId", PageId(e));
AppendPageTail(sb, e);
BridgeLink.Emit(sb.End());
}
private static void EmitClosed(int serial)
{
_closed++;
BridgeLink.Emit(BridgeJson.Begin("page.closed")
.Str("pageId", "0x" + serial.ToString("X"))
.End());
}
private static void OnSnapshot(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var sb = BridgeJson.Begin("pages.list");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Append(",\"pages\":[");
bool first = true;
foreach (PageEntry e in PageQueue.List)
{
if (e == null || e.Sender == null)
continue;
if (!first)
sb.Append(',');
first = false;
sb.Append("{\"pageId\":\"0x").Append(e.Sender.Serial.Value.ToString("X")).Append('"');
AppendPageTail(sb, e);
sb.Append('}');
}
sb.Append(']');
BridgeLink.Emit(sb.End());
}
/// <summary>Appends every page field except the opening pageId, each comma-prefixed, so it
/// works both after Begin(...) (events) and after a manual `{"pageId":..` (snapshot array).</summary>
private static void AppendPageTail(StringBuilder sb, PageEntry e)
{
sb.Append(",\"sender\":");
WriteSender(sb, e.Sender);
sb.Str("type", e.Type.ToString());
sb.Str("message", e.Message ?? "");
sb.Str("map", e.PageMap == null ? null : e.PageMap.Name);
sb.Num("x", e.PageLocation.X);
sb.Num("y", e.PageLocation.Y);
sb.Num("z", e.PageLocation.Z);
sb.Num("sentMs", ToMs(e.Sent));
sb.Bool("handled", e.Handler != null);
if (e.Handler != null)
sb.Str("handler", e.Handler.Name);
}
private static void WriteSender(StringBuilder sb, Mobile m)
{
if (m == null)
{
sb.Append("null");
return;
}
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
sb.Append(",\"name\":");
BridgeJson.Escape(sb, m.Name ?? "");
var acct = m.Account as Account;
if (acct != null)
{
sb.Append(",\"acct\":");
BridgeJson.Escape(sb, acct.Username);
var webId = BridgeAccountLink.WebIdFor(acct);
if (webId != null)
{
sb.Append(",\"webId\":");
BridgeJson.Escape(sb, webId);
}
}
sb.Append('}');
}
// ---- inbound ----
/// <summary>page.respond {reqId, pageId, message, close?}. Delivers a staff response to the
/// player and optionally closes the page.</summary>
private static void OnRespond(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var pageId = BridgeJson.GetString(o, "pageId");
var message = BridgeJson.GetString(o, "message");
bool close = GetBool(o, "close");
if (String.IsNullOrEmpty(message))
{
Err(reqId, "respond", pageId, "missing message");
return;
}
var e = Find(pageId);
if (e == null)
{
Err(reqId, "respond", pageId, "unknown page");
return;
}
try
{
// Same delivery as an in-game staff response: a null handler shows as "Staff".
// ResponseEntry queues for an offline sender; SendGump delivers now if online.
var re = new ResponseEntry(e.Sender, null, message);
re.SendGump();
if (close)
PageQueue.Remove(e);
Ok(reqId, "respond", pageId, close);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] page.respond threw: {0}", ex.Message);
Err(reqId, "respond", pageId, "internal error");
}
}
/// <summary>page.close {reqId, pageId}. Removes the page from the queue.</summary>
private static void OnClose(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var pageId = BridgeJson.GetString(o, "pageId");
var e = Find(pageId);
if (e == null)
{
Err(reqId, "close", pageId, "unknown page");
return;
}
try
{
PageQueue.Remove(e);
Ok(reqId, "close", pageId, true);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] page.close threw: {0}", ex.Message);
Err(reqId, "close", pageId, "internal error");
}
}
private static void Ok(string reqId, string action, string pageId, bool closed)
{
var sb = BridgeJson.Begin("page.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action);
if (pageId != null) sb.Str("pageId", pageId);
sb.Bool("closed", closed);
BridgeLink.Emit(sb.End());
}
private static void Err(string reqId, string action, string pageId, string reason)
{
var sb = BridgeJson.Begin("page.error");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action);
if (pageId != null) sb.Str("pageId", pageId);
sb.Str("reason", reason);
BridgeLink.Emit(sb.End());
}
// ---- helpers ----
private static PageEntry Find(string pageId)
{
int serial;
if (!TryParseSerial(pageId, out serial))
return null;
foreach (PageEntry e in PageQueue.List)
{
if (e != null && e.Sender != null && e.Sender.Serial.Value == serial)
return e;
}
return null;
}
private static string PageId(PageEntry e)
{
return "0x" + e.Sender.Serial.Value.ToString("X");
}
private static long ToMs(DateTime dt)
{
return (long)(dt.ToUniversalTime() - Epoch).TotalMilliseconds;
}
private static bool GetBool(Dictionary<string, object> o, string key)
{
object v;
if (o != null && o.TryGetValue(key, out v) && v is bool)
return (bool)v;
return false;
}
private static bool TryParseSerial(string s, out int value)
{
value = 0;
if (String.IsNullOrEmpty(s))
return false;
try
{
s = s.Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
value = Convert.ToInt32(s.Substring(2), 16);
else
value = Convert.ToInt32(s, 10);
return true;
}
catch
{
return false;
}
}
}
}

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 /// 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 /// 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> /// </summary>
public static class BridgeProfile public static class BridgeProfile
{ {
@@ -83,7 +83,7 @@ namespace Server.Custom.Bridge
} }
sb.Append(']'); 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\":["); sb.Append(",\"equipment\":[");
first = true; first = true;
foreach (var item in m.Items) foreach (var item in m.Items)
@@ -98,9 +98,55 @@ namespace Server.Custom.Bridge
} }
sb.Append(']'); sb.Append(']');
WriteTitles(sb, m);
return sb.End(); 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) private static bool IsGearLayer(Layer layer)
{ {
switch (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> /// <summary>
/// The three polled streams, for state that has no EventSink: player vitals, house decay, /// 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 /// 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. /// three is well under a millisecond.
/// ///
/// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed /// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed

View File

@@ -0,0 +1,133 @@
using System;
using Server.Accounting;
using Server.Commands;
using Server.Mobiles;
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 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
/// line via CommandLogging.WriteLine. We tap the new CommandLogging.OnWrite event and
/// parse the "... banning|kicking &lt;target&gt; ('acct')" line for action and target.
/// - broadcast: [bcast carries its message as command args and hits no target, so
/// EventSink.Command already sees it whole; we reshape it.
///
/// Not in overlay/: it references CommandLogging.OnWrite, which exists only after
/// patches/commandlogging-event.patch is applied. Shipping it in overlay/ would break the
/// build on an unpatched install — the same reason BridgeVendorSale.cs lives in patches/.
///
/// Runs on the Core thread (both sources raise synchronously in the command path). Every body
/// is wrapped: a bridge exception must never escape into a staff command.
/// </summary>
public static class BridgeModerationAudit
{
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
CommandLogging.OnWrite += OnCommandLog; // ban / kick (resolved, with target)
EventSink.Command += OnStaffCommand; // broadcast (carries its message)
Console.WriteLine("[Bridge] in-game moderation audit attached");
}
/// <summary>
/// The stock ban/kick commands log "&lt;level&gt; &lt;from&gt; ('acct') banning|kicking
/// &lt;target&gt; ('acct')" (Commands.cs KickCommand). Match the verb, take the target's
/// account from the trailing "('acct')", and forward. Non-moderation lines are ignored.
/// </summary>
private static void OnCommandLog(Mobile from, string text)
{
try
{
if (from == null || text == null)
return;
string action;
int at;
if ((at = text.IndexOf(" banning ", StringComparison.Ordinal)) >= 0)
action = "ban";
else if ((at = text.IndexOf(" kicking ", StringComparison.Ordinal)) >= 0)
action = "kick";
else
return;
var tail = text.Substring(at + 9); // past " banning " / " kicking "
Emit(action, from, ExtractAccount(tail), text);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] mod-audit log parse threw: {0}", ex.Message);
}
}
/// <summary>[bcast / [bc / [b — a staff broadcast. Its message is the command args.</summary>
private static void OnStaffCommand(CommandEventArgs e)
{
try
{
if (e == null || e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
return;
var cmd = e.Command;
if (cmd == null)
return;
cmd = cmd.ToLowerInvariant();
if (cmd != "bcast" && cmd != "bc" && cmd != "b")
return;
Emit("broadcast", e.Mobile, null, e.ArgString);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] mod-audit command threw: {0}", ex.Message);
}
}
/// <summary>Pulls the account from a CommandLogging.Format rendering's trailing "('account')".</summary>
private static string ExtractAccount(string formatted)
{
if (formatted == null)
return null;
int open = formatted.LastIndexOf("('", StringComparison.Ordinal);
if (open < 0)
return null;
int close = formatted.IndexOf("')", open, StringComparison.Ordinal);
if (close < 0)
return null;
return formatted.Substring(open + 2, close - (open + 2));
}
/// <summary>
/// Emits admin.audit with origin=in-game. The actor is the staff member's account name
/// (no "web:" prefix — that, plus the origin field, is how the website tells the two
/// sources apart). `detail` carries the raw context so nothing is lost if a target could
/// not be parsed.
/// </summary>
private static void Emit(string action, Mobile actor, string target, string detail)
{
var acct = actor.Account as Account;
var actorName = acct != null ? acct.Username : actor.Name;
BridgeLink.Emit(BridgeJson.Begin("admin.audit")
.Str("origin", "in-game")
.Str("action", action)
.Str("actor", actorName)
.Str("target", target)
.Str("detail", detail)
.End());
}
}
}

View File

@@ -11,7 +11,7 @@ git apply patches/<name>.patch
## Phase 7 — player-vendor sale (a coupled unit) ## 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: This is the one non-drop-in piece. Apply all three together:
@@ -32,10 +32,28 @@ Both patches are `git`-format and verified with `git apply --check` against stoc
Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`. Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
## In-game moderation audit (admin controls §5.5)
So the website's moderation log stays complete, in-game uses of the write-plane verbs are forwarded to it as `admin.audit` (`origin:"in-game"`). Broadcasts already surface through `EventSink.Command`, but resolved bans/kicks only carry their target inside the command's own `CommandLogging.WriteLine` call — which has no event to subscribe to. One small change fixes that:
| Item | Target | What |
|------|--------|------|
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | Adds a `public static event Action<Mobile,string> OnWrite`, raised in `WriteLine` **before** the `m_Enabled` guard so it fires even when file logging is off. |
| `BridgeModerationAudit.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber: taps `OnWrite` for ban/kick (parsing the target out of the log line) and `EventSink.Command` for `[bcast`, emitting `admin.audit`. **Not** in `overlay/` because it references `CommandLogging.OnWrite`, which does not exist until the patch is applied. |
```bash
cd <servuo root>
git apply --check patches/commandlogging-event.patch # dry run
git apply patches/commandlogging-event.patch
cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/BridgeModerationAudit.cs
```
`Logging.cs` is a **Scripts** file, so this is picked up by the dynamic script build — no core/solution rebuild needed (unlike the Phase 7 `EventSink.cs` patch). Verified end-to-end with `tools/scaffolding/BridgeAuditProbe.cs` (gated by `Bridge.AuditProbeOnStart`): a genuine `[bcast` plus simulated ban/kick log lines produced the expected `admin.audit` frames, target parsed, with non-moderation lines ignored.
## Note on `Scripts.csproj` ## Note on `Scripts.csproj`
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream. Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
## Note on shard repairs ## 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

@@ -0,0 +1,33 @@
--- a/Scripts/Commands/Logging.cs
+++ b/Scripts/Commands/Logging.cs
@@ -75,16 +75,27 @@
return o;
}
+ /// <summary>
+ /// Raised for every staff command log line — even when file logging is disabled — so an
+ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to
+ /// forward moderation actions (ban/kick, with the resolved target) to the website.
+ /// </summary>
+ public static event Action<Mobile, string> OnWrite;
+
public static void WriteLine(Mobile from, string format, params object[] args)
{
- if (!m_Enabled)
- return;
-
WriteLine(from, String.Format(format, args));
}
public static void WriteLine(Mobile from, string text)
{
+ var onWrite = OnWrite;
+ if (onWrite != null)
+ {
+ try { onWrite(from, text); }
+ catch { }
+ }
+
if (!m_Enabled)
return;

View File

@@ -7,7 +7,7 @@ website ──WS (live feed) / REST (queries)──► sidecar ──loopback
(this) newline-JSON, bidirectional (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 ## Run
@@ -106,4 +106,4 @@ A shard `*.error` reply maps to HTTP 404 (unknown/not-found) or 400 (bad request
## Wire protocol ## 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

@@ -142,7 +142,10 @@ fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
let text = fs::read_to_string(path)?; let text = fs::read_to_string(path)?;
let line = format!("auth_token = \"{token}\""); let line = format!("auth_token = \"{token}\"");
if text.lines().any(|l| l.trim_start().starts_with("auth_token")) { if text
.lines()
.any(|l| l.trim_start().starts_with("auth_token"))
{
let out: String = text let out: String = text
.lines() .lines()
.map(|l| { .map(|l| {

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 /// 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 /// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
/// of failing in confusing ways. /// 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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@@ -75,6 +79,7 @@ async fn main() -> anyhow::Result<()> {
let route_rpc = rpc.clone(); let route_rpc = rpc.clone();
let event_store = store.clone(); let event_store = store.clone();
let last_event_ts = last_event.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; let mut total: u64 = 0;
tokio::spawn(async move { tokio::spawn(async move {
while let Some(ev) = event_rx.recv().await { while let Some(ev) = event_rx.recv().await {
@@ -96,11 +101,121 @@ async fn main() -> anyhow::Result<()> {
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history. // Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
if ev.kind != "pong" { if ev.kind != "pong" {
let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms); let t = ev
.value
.get("t")
.and_then(|v| v.as_i64())
.unwrap_or_else(now_ms);
let text = ev.value.to_string(); let text = ev.value.to_string();
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await { if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
tracing::warn!(error = %e, "failed to persist event"); tracing::warn!(error = %e, "failed to persist event");
} }
// The champ board is a live projection: champ.update folds in the latest state (one
// row per spawn), champ.remove drops a spawn that despawned or was slain.
match ev.kind.as_str() {
"champ.update" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store
.upsert_champ(
serial,
ev.value.get("status").and_then(|s| s.as_str()),
ev.value.get("name").and_then(|n| n.as_str()),
&text,
t,
)
.await
{
tracing::warn!(error = %e, "failed to upsert champ board");
}
}
}
"champ.remove" => {
if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) {
if let Err(e) = event_store.delete_champ(serial).await {
tracing::warn!(error = %e, "failed to remove champ board row");
}
}
}
// 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()); let _ = feed_tx.send(ev.value.to_string());

View File

@@ -56,10 +56,7 @@ impl Rpc {
) -> Result<Value, RpcError> { ) -> Result<Value, RpcError> {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
self.pending self.pending.lock().await.insert(corr_val.to_string(), tx);
.lock()
.await
.insert(corr_val.to_string(), tx);
if !shard.send(command.to_string()).await { if !shard.send(command.to_string()).await {
self.pending.lock().await.remove(corr_val); self.pending.lock().await.remove(corr_val);

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 //! 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 //! 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 //! 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 //! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its

View File

@@ -21,8 +21,8 @@ pub struct Store {
impl Store { impl Store {
/// Opens (creating if absent) the SQLite database and ensures the schema exists. /// Opens (creating if absent) the SQLite database and ensures the schema exists.
pub async fn open(path: &str) -> anyhow::Result<Self> { pub async fn open(path: &str) -> anyhow::Result<Self> {
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))? let opts =
.create_if_missing(true); SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?.create_if_missing(true);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(4) .max_connections(4)
@@ -78,7 +78,12 @@ impl Store {
self.recent(Some("economy.supply"), limit).await self.recent(Some("economy.supply"), limit).await
} }
pub async fn record_link(&self, account: &str, website_user_id: &str, t: i64) -> anyhow::Result<()> { pub async fn record_link(
&self,
account: &str,
website_user_id: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query( sqlx::query(
"INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?) "INSERT INTO links (account, website_user_id, linked_t) VALUES (?, ?, ?)
ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t", ON CONFLICT(account) DO UPDATE SET website_user_id = excluded.website_user_id, linked_t = excluded.linked_t",
@@ -99,6 +104,16 @@ impl Store {
Ok(row.map(|r| r.get::<String, _>("website_user_id"))) 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( pub async fn cache_profile(
&self, &self,
serial: &str, serial: &str,
@@ -128,6 +143,191 @@ impl Store {
.await?; .await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok())) Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
} }
/// Upserts one champion-spawn's latest state, keyed by serial. Fed from the `champ.update`
/// stream; this table is the live board the website reads, so there is exactly one row per
/// spawn and it always holds the most recent snapshot.
pub async fn upsert_champ(
&self,
serial: &str,
status: Option<&str>,
name: Option<&str>,
json: &str,
t: i64,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO champs (serial, status, name, json, updated_t) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(serial) DO UPDATE SET status = excluded.status, name = excluded.name, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(serial)
.bind(status)
.bind(name)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// Drops one spawn from the board. Fed from the `champ.remove` stream: a controller that was
/// deleted, or a transient sea boss that was slain, leaves the board this way.
pub async fn delete_champ(&self, serial: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM champs WHERE serial = ?")
.bind(serial)
.execute(&self.pool)
.await?;
Ok(())
}
/// The full champion-spawn board: every spawn's latest snapshot. Ordered by name so the site
/// gets a stable list.
pub async fn champs_all(&self) -> anyhow::Result<Vec<Value>> {
let rows = sqlx::query("SELECT json FROM champs ORDER BY name, serial")
.fetch_all(&self.pool)
.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> { fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
@@ -158,4 +358,38 @@ CREATE TABLE IF NOT EXISTS profiles (
json TEXT NOT NULL, json TEXT NOT NULL,
updated_t INTEGER NOT NULL updated_t INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS champs (
serial TEXT PRIMARY KEY,
status TEXT,
name TEXT,
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,12 +54,34 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/vendors/:account", get(vendors)) .route("/vendors/:account", get(vendors))
// Inbound commands (correlated by code / id). // Inbound commands (correlated by code / id).
.route("/link/confirm", post(link_confirm)) .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", post(towncrier_add))
.route("/towncrier/:id", axum::routing::delete(towncrier_remove)) .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))
.route("/admin/ban", post(admin_ban))
.route("/admin/unban", post(admin_unban))
.route("/admin/broadcast", post(admin_broadcast))
// Help-page (support) queue: snapshot the open queue, respond to / close a page.
.route("/pages", get(pages_list))
.route("/pages/:id/respond", post(page_respond))
.route("/pages/:id/close", post(page_close))
// History, read from SQLite rather than the shard. // History, read from SQLite rather than the shard.
.route("/history", get(history)) .route("/history", get(history))
.route("/economy", get(economy)) .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)); .route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new() let app = Router::new()
@@ -113,7 +135,8 @@ fn iso_ms(ms: i64) -> Option<String> {
if ms <= 0 { if ms <= 0 {
return None; return None;
} }
chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) chrono::DateTime::from_timestamp_millis(ms)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
} }
// ---- gate: protocol check + auth ---- // ---- gate: protocol check + auth ----
@@ -164,8 +187,15 @@ async fn gate(State(st): State<AppState>, req: Request, next: Next) -> Response
fn extract_token(req: &Request) -> Option<String> { fn extract_token(req: &Request) -> Option<String> {
// Authorization: Bearer <token> // Authorization: Bearer <token>
if let Some(v) = req.headers().get("authorization").and_then(|h| h.to_str().ok()) { if let Some(v) = req
if let Some(rest) = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer ")) { .headers()
.get("authorization")
.and_then(|h| h.to_str().ok())
{
if let Some(rest) = v
.strip_prefix("Bearer ")
.or_else(|| v.strip_prefix("bearer "))
{
return Some(rest.trim().to_string()); return Some(rest.trim().to_string());
} }
} }
@@ -231,6 +261,273 @@ fn respond(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
} }
} }
/// Like `respond`, but for the admin write plane, where a rejection is not a not-found. Maps an
/// `admin.error` reply to a status by its reason: an unknown target is a 404, a floor/authorization
/// refusal (protected target, or the write plane being disabled) is a 403, anything else a 400.
fn respond_admin(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 == "admin.error" {
let reason = value
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("request rejected");
let code = if reason.contains("unknown") {
StatusCode::NOT_FOUND
} else if reason.contains("protected")
|| reason.contains("refused")
|| reason.contains("disabled")
{
StatusCode::FORBIDDEN
} 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"})),
),
}
}
/// 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`
/// and `reqId`, requiring the caller-supplied `actor` up front (the shard enforces it too). The
/// body's remaining fields (account/serial/durationSec/reason/text/hue) pass straight through.
async fn admin_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json<Value>) {
let mut obj = match body {
Value::Object(m) => m,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "body must be a JSON object"})),
)
}
};
let actor_ok = obj
.get("actor")
.and_then(|a| a.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !actor_ok {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "actor is required"})),
);
}
let req_id = st.rpc.next_req_id();
obj.insert("kind".to_string(), json!(kind));
obj.insert("reqId".to_string(), json!(req_id));
respond_admin(st.rpc.call(&st.shard, Value::Object(obj), &req_id).await)
}
/// Body: {"actor":"...","account":"..."|"serial":"0x.."}. Disconnects the target's live sessions.
async fn admin_kick(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
admin_call(&st, "admin.kick", body).await
}
/// Body: {"actor":"...","account":"...","durationSec":<opt>,"reason":<opt>}. 0/absent = indefinite.
async fn admin_ban(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
admin_call(&st, "admin.ban", body).await
}
/// Body: {"actor":"...","account":"..."}.
async fn admin_unban(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
admin_call(&st, "admin.unban", body).await
}
/// Body: {"actor":"...","text":"...","hue":<opt>}. Announces a system message to everyone online.
async fn admin_broadcast(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
admin_call(&st, "admin.broadcast", body).await
}
// ---- help-page queue handlers ----
/// The open help-page queue, correlated on reqId. Returns a pages.list.
async fn pages_list(State(st): State<AppState>) -> impl IntoResponse {
let req_id = st.rpc.next_req_id();
let cmd = json!({"kind": "pages.snapshot", "reqId": req_id});
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
}
/// Body: {"message":"...","close":<bool, optional>}. Delivers a staff response to the player.
async fn page_respond(
State(st): State<AppState>,
Path(id): Path<String>,
Json(body): Json<Value>,
) -> impl IntoResponse {
let message = body
.get("message")
.and_then(|m| m.as_str())
.unwrap_or_default();
if message.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "message is required"})),
);
}
let close = body.get("close").and_then(|c| c.as_bool()).unwrap_or(false);
let req_id = st.rpc.next_req_id();
let cmd = json!({
"kind": "page.respond", "reqId": req_id,
"pageId": id, "message": message, "close": close
});
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
}
/// Removes a page from the queue.
async fn page_close(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
let req_id = st.rpc.next_req_id();
let cmd = json!({"kind": "page.close", "reqId": req_id, "pageId": id});
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
}
// ---- query handlers ---- // ---- query handlers ----
async fn char_by_slot( async fn char_by_slot(
@@ -297,7 +594,10 @@ async fn vendors(State(st): State<AppState>, Path(account): Path<String>) -> imp
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`. /// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse { async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
let code = body.get("code").and_then(|c| c.as_str()).unwrap_or_default(); let code = body
.get("code")
.and_then(|c| c.as_str())
.unwrap_or_default();
let web_id = body let web_id = body
.get("websiteUserId") .get("websiteUserId")
.and_then(|w| w.as_str()) .and_then(|w| w.as_str())
@@ -359,14 +659,55 @@ async fn towncrier_add(State(st): State<AppState>, Json(body): Json<Value>) -> i
respond(st.rpc.call(&st.shard, cmd, &id).await) respond(st.rpc.call(&st.shard, cmd, &id).await)
} }
async fn towncrier_remove( async fn towncrier_remove(State(st): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
State(st): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let cmd = json!({"kind":"towncrier.remove","id":id}); let cmd = json!({"kind":"towncrier.remove","id":id});
respond(st.rpc.call(&st.shard, cmd, &id).await) 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) ---- // ---- history (from SQLite) ----
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -399,6 +740,76 @@ async fn economy(State(st): State<AppState>, Query(q): Query<HistoryQuery>) -> i
} }
} }
/// The champion-spawn board: every spawn's latest state (status/level/kills/boss/location and, when
/// relevant, the cooldown ETA). Served from the local board table, so it answers without touching
/// the shard and survives a shard outage with the last-known snapshot.
async fn champs(State(st): State<AppState>) -> impl IntoResponse {
match st.store.champs_all().await {
Ok(spawns) => (StatusCode::OK, Json(json!({"spawns": spawns}))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
),
}
}
/// 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 ---- // ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse { async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {

View File

@@ -0,0 +1,71 @@
using System;
using Server.Accounting;
using Server.Commands;
using Server.Mobiles;
namespace Server.Custom
{
/// <summary>
/// Exercises the in-game moderation-audit forwarding (BridgeModerationAudit) without a game
/// client, so the CommandLogging.OnWrite patch and the admin.audit normalizer can be verified
/// end-to-end from a stub sidecar.
///
/// - Broadcast is a *genuine* trigger: CommandSystem.Handle runs [bcast, which raises
/// EventSink.Command exactly as a staff keystroke would.
/// - Ban/kick can't complete headlessly (they arm a target cursor with no client to click),
/// so we call CommandLogging.WriteLine with the stock KickCommand line format — the same
/// call that command makes at Commands.cs:1211, which is the point we tap.
/// - A non-moderation log line confirms the normalizer ignores everything else.
///
/// Test scaffolding. Never deployed. Gated behind Bridge.AuditProbeOnStart (absent in a
/// shipped Bridge.cfg, so Config.Get returns false and it never runs in production).
/// </summary>
public static class BridgeAuditProbe
{
public static void Initialize()
{
if (Config.Get("Bridge.AuditProbeOnStart", false))
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
}
private static void Run()
{
try
{
var staffAcct = Accounting.Accounts.GetAccount("whitlocktech") as Account;
var targetAcct = Accounting.Accounts.GetAccount("seed_010") as Account;
var from = staffAcct == null ? null : staffAcct[0];
var target = targetAcct == null ? null : targetAcct[0];
if (from == null || target == null)
{
Console.WriteLine("[AuditProbe] need whitlocktech + seed_010 chars; seed the world first");
return;
}
Console.WriteLine("[AuditProbe] genuine broadcast via [bcast ...");
CommandSystem.Handle(from, CommandSystem.Prefix + "bcast in-game audit probe");
Console.WriteLine("[AuditProbe] simulating a resolved ban log line ...");
CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
from.AccessLevel, CommandLogging.Format(from), "banning", CommandLogging.Format(target));
Console.WriteLine("[AuditProbe] simulating a resolved kick log line ...");
CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
from.AccessLevel, CommandLogging.Format(from), "kicking", CommandLogging.Format(target));
Console.WriteLine("[AuditProbe] a non-moderation line (should be ignored) ...");
CommandLogging.WriteLine(from, "{0} {1} used command '{2}'",
from.AccessLevel, CommandLogging.Format(from), "Go 1 1 0");
Console.WriteLine("[AuditProbe] done");
}
catch (Exception ex)
{
Console.WriteLine("[AuditProbe] FAILED: " + ex);
}
}
}
}

View File

@@ -0,0 +1,61 @@
using System;
using Server.Accounting;
using Server.Engines.Help;
namespace Server.Custom
{
/// <summary>
/// Puts a couple of genuine PageEntry tickets into the help-page queue so BridgePages
/// (poll/stream + snapshot + respond/close) can be verified without a game client.
///
/// The enqueue is real (PageQueue.Enqueue). The only accommodation for the missing client:
/// each entry's InternalTimer would remove the page on its first tick because the sender has
/// no NetState (PageQueue.cs:167 treats "no NetState" as a logout), so we call PageEntry.Stop
/// to keep the ticket in the queue for the test. Everything the bridge does — detect, snapshot,
/// respond, close — then operates on real queue entries.
///
/// Test scaffolding. Never deployed. Gated behind Bridge.PageProbeOnStart.
/// </summary>
public static class BridgePageProbe
{
public static void Initialize()
{
if (Config.Get("Bridge.PageProbeOnStart", false))
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
}
private static void Run()
{
try
{
Enqueue("seed_030", "My quest is stuck, please help.", PageType.Stuck);
Enqueue("seed_031", "Found a bug with a player vendor.", PageType.Bug);
Console.WriteLine("[PageProbe] done");
}
catch (Exception ex)
{
Console.WriteLine("[PageProbe] FAILED: " + ex);
}
}
private static void Enqueue(string account, string message, PageType type)
{
var acct = Accounting.Accounts.GetAccount(account) as Account;
var sender = acct == null ? null : acct[0];
if (sender == null)
{
Console.WriteLine("[PageProbe] {0} has no character in slot 0; seed the world first", account);
return;
}
var entry = new PageEntry(sender, message, type);
PageQueue.Enqueue(entry);
entry.Stop(); // keep it in the queue despite the offline sender
Console.WriteLine("[PageProbe] enqueued {0} page for {1} (0x{2:X})",
type, account, sender.Serial.Value);
}
}
}

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. **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 | | File | Server path when testing | What |
|------|--------------------------|------| |------|--------------------------|------|

View File

@@ -0,0 +1,78 @@
param(
[int] $Port = 7788,
[string] $Log = "$PSScriptRoot\sc_admin.log"
)
# Phase-1 admin write-plane harness. Connects as the sidecar, waits for the shard,
# fires admin.* commands covering the happy paths and every guard, logs the replies.
# Requires Bridge.cfg AdminWriteEnabled=true and the seeded world (seed_00x accounts).
function Say($msg) {
for ($i = 0; $i -lt 5; $i++) {
try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
catch { Start-Sleep -Milliseconds 100 }
}
}
"" | Out-File -FilePath $Log -Encoding utf8
Say "[admin] starting on 127.0.0.1:$Port"
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
$bound = $false
for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
try { $listener.Start(); $bound = $true }
catch { Start-Sleep -Seconds 1 }
}
if (-not $bound) { Say "[admin] could not bind"; exit 1 }
Say "[admin] listening"
$client = $listener.AcceptTcpClient()
Say "[admin] === shard connected ==="
$stream = $client.GetStream()
$reader = New-Object System.IO.StreamReader($stream)
$writer = New-Object System.IO.StreamWriter($stream)
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 500
$requests = @(
# happy path, no target needed
'{"kind":"admin.broadcast","reqId":"a-bcast","actor":"whitlocktech","text":"uo-link admin test broadcast"}',
# ban an offline seed account (timed), then unban
'{"kind":"admin.ban","reqId":"a-ban","actor":"whitlocktech","account":"seed_001","durationSec":3600,"reason":"harness test"}',
'{"kind":"admin.unban","reqId":"a-unban","actor":"whitlocktech","account":"seed_001"}',
# kick an offline account -> should succeed with sessions:0
'{"kind":"admin.kick","reqId":"a-kick","actor":"whitlocktech","account":"seed_002"}',
# floor: whitlocktech is Owner -> must be refused
'{"kind":"admin.ban","reqId":"a-floor","actor":"whitlocktech","account":"whitlocktech"}',
# unknown target
'{"kind":"admin.ban","reqId":"a-unknown","actor":"whitlocktech","account":"does_not_exist"}',
# missing actor -> refused by the shared gate
'{"kind":"admin.ban","reqId":"a-noactor","account":"seed_003"}'
)
foreach ($r in $requests) {
$writer.WriteLine($r)
Say "[admin] -> $r"
Start-Sleep -Milliseconds 400
}
# Drain greedily: block on ReadLine with an idle timeout so a buffered burst is fully read
# (the DataAvailable-gated pattern drops the tail of a burst that a StreamReader pre-buffers).
$stream.ReadTimeout = 2500
try {
while ($true) {
$line = $reader.ReadLine()
if ($null -eq $line) { break }
Say "[admin] <- $line"
}
} catch {
Say "[admin] read window closed (idle)"
}
Say "[admin] done"
$client.Close()
$listener.Stop()