The progress table and CLILOCS.md carried #TBD placeholders until the PRs
existed; they now point at website #115 and docs #70.
Also corrects "all four Cliloc.* files" to eight (chs, cht, deu, enu, esp,
fra, jpn, kor) in both v3.md §8.6 and CLILOCS.md — the compression marker was
checked against every one of them, and understating the sample weakens the
claim it supports.
Co-Authored-By: Claude <noreply@anthropic.com>
Protocol 3.0 §8.6 resolved as its own website-only change, landing ahead of
§8 so the marketplace ships with real item names. Matching documentation for
website #TBD.
NEW website/CLILOCS.md — operator-facing: why the conversion step exists, how
to convert, how to configure the path, the refresh contract, what gets stored
and how names are applied.
link/v3.md §8.6 rewritten. Two things in the original recommendation turned
out to be wrong, and both are recorded because the reasoning generalises:
1. The committed db/data/clilocs.json artifact predates the Part C
corrections (§6.1) and violates both — no committed snapshot of derived
content, and nothing EA-derived ever shipped. UO's strings are EA's,
exactly as the creature sprites are.
2. "scripts/buildClilocs.js reads the UO client's Cliloc.enu" is not
possible. EVERY current client ships its cliloc files compressed (first
DWORD's high byte 0x8E, the Mythic container); the plain layout is what
those files looked like before that change, and parsing one as the other
does not fail cleanly — it yields ~19k records with negative ids, 1,722
distinct keys out of 19,508, one 62 KB "string", and a truncation
somewhere in the middle. ServUO's own Ultima.StringList cannot read it
either, so VendorSearch.GetItemName is already inert on such a shard and
the work could not be pushed to the plugin.
That second point also retires an open question in §8.2: the warning never to
call GetItemName in the market sweep costs us nothing we could otherwise have
had, because the in-game Vendor Search gump has the same gap.
Three traps found by building it are recorded: StringList.SaveStringList
RE-COMPRESSES on save (its output is byte-identical to its compressed input,
because its purpose is round-tripping a file back into the client); trimming a
text line before splitting silently drops the ~half of a table that is empty
strings; and Number('') is 0, not NaN.
Also updated:
- Progress and §9 sequencing tables: order 5 split into 5a (this, website
only) and 5b (the four-repo wire change).
- website/BACKEND_DESIGN.md — shard_clilocs / shard_cliloc_meta, the three
admin routes, and why there is no staged-approval flow and no public route.
- link/INTEGRATION.md — the char.profile field note now says explicitly not to
expect the shard to resolve clilocs, and points at CLILOCS.md.
- §10 documentation obligations list CLILOCS.md.
Documentation only. Every claim was written after the corresponding behaviour
was observed running: the compressed-format finding and the parse failures
come from the real client files on this machine, and the counts (123,490
parsed → 67,496 stored) and timings from importing them into the live MariaDB.
PROJECT_TREE.md files are deliberately untouched — they are CI-generated by
the sync-project-tree workflow and say so in their header.
Co-Authored-By: Claude <noreply@anthropic.com>
Protocol 3.0 §7 lands across servuo-plugins, link and website; this is the
matching documentation.
INTEGRATION.md
- points.board in the event catalog: one frame per system, never a delta, no
points.remove (the shard's system set is fixed at startup). Four gotchas
called out, all of them things a consumer gets wrong by default:
1. maxPoints: 0 means UNCAPPED, not "zero allowed" — and on a real shard
it is the COMMON case, so anything rendering points/maxPoints must
special-case it or divide by zero on the happy path.
2. nameString is usually null, with a cliloc in nameNumber — so
humanising the system key is the primary display path, not a fallback.
3. players counts players actually holding points, not table size: ten of
the ~25 systems keep a zero-point row per character ever created, so
the raw count would report the shard's whole census.
4. Entries carry serial + name only, never acct/webId.
- The char.profile `points` block, and why `rank` is absent by default.
- GET /points and /points/:system, including why 404 (never published) and
200-with-empty-top (published, nobody scored) are different answers.
v3.md
- B/2 marked done in both the progress table and §9.
- NEW §7.5, "what the run against a real shard changed" — the same record §6.1
and §6.2 keep. Four corrections the plan could not have anticipated from
reading PointsSystem.cs, the sharpest being that (long)double.MaxValue is an
unchecked conversion yielding long.MinValue, which published
"maxPoints": -9223372036854775808 on the first live sweep. Also records that
GetEntry/GetPoints mutate the world on AutoAdd systems and so cannot be used
in a read model, and the one deliberate deviation from §7.4: the visibility
field rule must key on the wire's `name`, not the descriptive
`characterName`, or it is silently inert.
BACKEND_DESIGN.md — shard_points_boards (including why the top-N list stays in
the payload and why listing orders by COALESCE(name, system)), plus the two new
public routes.
PLAN.md — 3.0 phasing brought current: the spawn atlas and points.board added to
what has shipped, and the Points* keys noted in the config-key paragraph.
PROJECT_TREE.md files are deliberately untouched — they are CI-generated and say
so.
Co-Authored-By: Claude <noreply@anthropic.com>
Docs half of website #113 (Protocol 3.0 Part C, second website PR).
Carries the atlas rewrite that missed #67: that PR merged before the "derive
from the tree on every boot" commit was pushed, so `edge` currently describes
the build/import-artifact design that was rejected in review, not what shipped
in website #112. It lands here.
New in SPAWN_ATLAS.md: the six public routes and five admin ones, and three
behaviours that read as bugs unless they are written down — an unreadable tree
answers 200 with status "unavailable" rather than 500 (refresh reports outcomes
so boot is never blocked by a bad tree, and the contract is preserved at the
API), setting the ServUO path deliberately does not import, and `points` is a
count while `spawners` is the list.
Also the delay-unit trap: XmlSpawner stores MinDelay/MaxDelay in minutes OR
seconds per record, decided by that record's own DelayInSec flag, so a `5` is
five minutes on one spawner and five seconds on the next. Both are plausible
respawn times, which is what makes it silent. 170 of 6,455 stock spawners are
second-flagged. And PARSER_VERSION, which exists because hashing the tree alone
would strand an install whose maps never change on whatever an older parser
derived.
BACKEND_DESIGN.md gains the routes, the router-map entry, and the parser-version
rule. v3.md marks Part C done and records in 6.3 what the API half found.
api-route-inventory.json refreshed from the live manifest — it had drifted to
200 routes before this PR (real count was 204) and is now 215.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
Follows the redesign in website #112. Two decisions from the original §6 were
rejected in review and replaced; the docs now describe what was actually built.
**The committed artifact is gone.** A shard's maps change over its life, so a
snapshot in the repo silently drifts from the world players actually see. The
ServUO tree is the single source of truth and the atlas is re-derived on every
server boot, hash-gated so an unchanged tree costs one read pass and no write.
**Nothing may name a facet.** The first implementation carried a lookup table of
the six stock UO facets. A shard may add facets, replace them outright, or rename
them when its maps are updated, and a built-in list mishandles all three
silently. Reconciliation is now by matching against the facet set discovered from
the shard's own data, with an unmatched name keeping its own rather than being
forced into a wrong bucket.
## Changes
- **`website/SPAWN_ATLAS.md`** rewritten: the two ideas that shape the design,
how to configure the tree path, the boot flow as a decision tree, the
approve/reject flow, and the code layout. The artwork policy is unchanged and
still explicit — no art ever ships, operators extract their own from their own
client files.
- **`link/v3.md` §6.1 (new)** records the two rejected decisions plus the two
boot-path contracts. The old "what real data changed" list becomes §6.2. §6's
now-superseded passages — the artifact bullet, the payload budget, the operator
re-run story — are marked rather than deleted, so the reasoning stays legible.
- **`website/BACKEND_DESIGN.md`** documents `shard_atlas_pending` and the two
contracts that make it safe: a facet removal is staged for a human, and the
boot refresh can never block startup.
The two contracts are the part worth reviewing. Losing a facet is
indistinguishable at boot from a half-copied or mid-update tree, so it is staged
rather than applied; and no failure mode of the atlas — missing path, unreadable
mount, malformed file, database error — is allowed to stop the site coming up.
---
- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
Protocol 3.0 order 3 (Part C), docs half of website #112. Part C is website-only
— no plugin, no sidecar, no new kinds, no wire change.
## New: website/SPAWN_ATLAS.md
The operator-facing reference: the build/import split and why it exists (build
needs a ServUO tree, import does not, and the container has the artifact but not
the tree), the re-run story, the artifact format, the placement transform, and
the three quirks in the source data that are silent when unhandled.
Also documents the artwork policy explicitly: **the project ships no creature
art and no extraction tooling.** Sprites live in the operator's own client
.mul/.uop files and are theirs, not ours to redistribute. `art` is nullable and
NULL on every fresh import; an operator who wants art extracts it themselves into
the gitignored uploads/atlas/ and maps slugs in a gitignored art map. Text-only
is the normal, supported state — not a degraded one.
## New: v3.md §6.1 — what the build against real data changed
Six corrections, kept as a diff rather than edited into §6 in place, because
each is a trap the next person would otherwise re-enter:
1. **Six facets, not thirteen.** Eodon.xml and the other named-area files carry
TerMur/Trammel points; the facet comes from each record's `<Map>`.
2. **The XML dependency call resolved: hand-rolled, zero deps.** §6 left
fast-xml-parser vs a tokenizer open.
3. **Facet names disagree between sources** — Locations says `Ter Mur`, `<Map>`
says `TerMur`. Unreconciled the landmark fallback never fires there and every
unregioned Ter Mur/Tokuno spawn silently reads "Wilderness".
4. **Spawn type tokens carry XmlSpawner directives** (`Fairy,{RND,4,8}`,
`alchemist/z/-50`). Taken literally they invent creatures that do not exist
and split real ones in two. 71 of 845 affected; 800 remain after stripping.
5. **The artifact is 1.41 MB, not "well under 1 MB"** — down from 4.40 MB via
three encodings. Getting under 1 MB would mean dropping the spawner name.
6. **DELETE, not TRUNCATE** — TRUNCATE is DDL in MariaDB and implicitly commits,
which would defeat the all-or-nothing reload the design asked for.
§6 also now records that Part C ships as two website PRs: the parsing half is
where the correctness risk lives and should not be reviewed inside a 10k-line
diff alongside routes and React.
## BACKEND_DESIGN.md
The seven atlas tables, the import-owned contract, the four column choices that
are traps (`spawn_range`/`grp` reserved words, DELETE vs TRUNCATE, explicit point
ids, plain INDEX not FULLTEXT), and the distinction between the configured
champion roster and the live champ.update feed.
PROJECT_TREE.md is left alone — it is auto-generated by the sync-project-tree
workflow.
---
- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
Protocol 3.0 order 2 (v3.md §5) is built across all four repos; this is its
documentation half, plus the running progress record the plan was missing.
v3.md
- A progress table at the top and a State column on §9's sequencing table, so
"what has landed" is answerable without reading four git logs. Part A (order
1) and world.ruleset (order 2) are marked done; the spawn atlas is next.
- §5 gains the implementation notes worth keeping, chiefly: where a system's
on/off state is DERIVED rather than configured, read the system's own static
instead of inventing a .cfg key (Shadowguard has no Enabled key — it's the
TOL expansion gate; Factions is `!ViceVsVirtueSystem.Enabled` by
construction in stock ServUO). Also that the plugin CAN be compile-verified
despite the "no standalone build" caveat, and how.
INTEGRATION.md
- The world.ruleset catalog entry and GET /ruleset, with the two things
consumers get wrong: caps are in TENTHS (1000 = 100.0), and `connect` exists
only if the operator set Bridge.PublicConnectAddress — the shard's real
listen address is never published.
- §2 now says plainly that v3 has NOT been bumped yet and what that means:
sidecars on `edge` report 2 while already carrying some v3 kinds, so do not
infer feature availability from the version during this window.
PROTOCOL_2.md §10.4
- The deferred "which PvP system does this shard run?" is answered (VvV on,
Factions off — and mutually exclusive by construction), and world.systems is
marked superseded by world.ruleset, which carries the systems block it asked
for. No orphan kind is left behind.
BACKEND_DESIGN.md — the shard_ruleset table (why it is stored whole rather than
normalized, and why no row means null rather than {}) and the public route.
PROJECT_TREE.md is deliberately untouched: sync-project-tree regenerates it on
push to main, so it updates itself at the v3 cutover.
Co-Authored-By: Claude <noreply@anthropic.com>
The live five-rung smoke test of the visibility framework found that Part
A enforced it on the SSE path and on /guilds + /governors, but not on the
remaining public REST reads - so one event was projected live and served
verbatim from stored history.
link/v3.md gains 3.6.1 with the full list (the anonymous acct/webId leak
on /feed, the flattened ownerAcct on /idoc, the dead `houses` field
rules, /feed ignoring live config, the empty-allowlist fall-through, and
the Date-to-{} projection bug), plus the rule it leaves behind: a read
path that returns shard data and does not project is a bug, and every new
Part B/C surface must gate its kind set on live config rather than on
PUBLIC_KINDS.
3.5 also corrected: the table is NOT seeded on boot. An absent row means
"use the compiled default", which keeps the defaults in one place instead
of duplicating them into a seeder that could drift.
BACKEND_DESIGN.md 6.5 records the same as a security contract: rule 1
locks a field by meaning rather than spelling; PUBLIC_KINDS is a
module-load constant and must not answer per-caller questions;
projectFeature walks arrays and plain objects only.
SHARD_VISIBILITY.md gets the admin-facing version - that stored history
answers the same way the live stream does, and that turning live updates
off stops the push, not the reading.
Co-Authored-By: Claude <noreply@anthropic.com>
Surveys the live ServUO tree against everything the bridge already
surfaces and records the full gap list (17 items), then specs the four
features scoped for 3.0.
3.0 has three scope areas:
- A: the visibility framework. Admin-configurable, per-feature and
per-field audience control over all ten shard-derived surfaces (the
four new ones plus the six that already ship), on an
anonymous -> logged_in -> player -> staff -> admin ladder. Every
default reproduces today's behavior, so the retrofit is a no-op until
an admin changes something. Two rules an admin cannot override: acct
and webId are admin-only always, and an unmapped event kind is never
broadcast below admin. This also fixes a verified leak - guild leader
acct/webId are readable today on the anonymous /public/shard/guilds.
- B: three new wire streams - world.ruleset, points.board, and
vendor.listing/vendor.listing.remove.
- C: the spawn atlas, built from static ServUO data files with no wire
involvement.
Visibility lives entirely on the website; the sidecar stays a dumb
forwarder that defines no access parameters and advertises no
capabilities.
PROTOCOL_VERSION goes 2 -> 3 once, at the end: every part PRs into an
edge branch per repo, and the coordinated edge -> main merge is the
cutover. A schema migration moves uo_link_config.protocol so operators
don't have to.
Co-Authored-By: Claude <noreply@anthropic.com>
Add auto-generated project-tree snapshots for the website, link, and
Android-app repos under docs/<repo>/PROJECT_TREE.md, and link them from
the README (adding a previously-missing android/ section). These files
are maintained going forward by the sync-project-tree CI workflow in each
source repo, which opens a PR here whenever the tracked layout changes.
Co-Authored-By: Claude <noreply@anthropic.com>
- Repoint doc-to-doc references from the old docs/ prefix to the co-located
sibling filenames (docs live under link/ here now).
- Replace the personal ServUO checkout path (C:\Users\...\servuo) with a
<servuo> placeholder throughout.
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>
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>
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>
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>
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>
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>
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>
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>
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
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
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
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
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
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
docs/INTEGRATION.md is the API reference for building the front end against the
sidecar: base URL, auth (Bearer / X-Api-Key / ?token=), protocol versioning, the
rich /health, the WebSocket live feed with a full event catalog, every REST query
and command (char/roster/vendors/link/towncrier/history/economy), the status-code
table, a worked character-page example, and current caveats. Payloads are the real
shapes captured during testing. Linked from the top-level README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The one non-drop-in piece. Player-vendor purchases raise no EventSink, so the
sale is invisible to subscription. Two git-format core patches add a
PlayerVendorSale event and raise it at the committed sale in
PlayerVendorBuyGump.OnResponse (right after HoldGold +=), where buyer, vendor
owner, item, price, and commission are all in scope. The subscriber
BridgeVendorSale emits vendor.sale.
All three are a coupled unit. The subscriber references PlayerVendorSaleEventArgs,
which does not exist until the EventSink patch is applied, so it lives in patches/
not overlay/ -- shipping it in overlay would break the build on any unpatched
install. patches/README.md documents applying the unit; both patches verified
with git apply --check against stock ServUO 57.4. This is the first phase that
rebuilds the core (ServUO.exe), not just Scripts.dll.
vendor.sale carries buyer and vendor-owner accounts, both present and distinct,
which is the pair that flags gold-laundering when they match -- richer than the
ownerless NPC ValidVendor* events, and on a committed sale rather than a
validation stage.
Verified with a probe firing the event on real seeded-vendor data: vendor.sale
emitted with buyerAcct=seed_001, ownerAcct=seed_000, Longsword, price 69819. The
probe proves the event, args, subscriber, and payload; the literal gump call site
firing on a real purchase needs a live buyer with a NetState and is confirmed by
an in-game buy. Evidence in docs/PLAN.md §17.
This completes every phase on the ServUO side. Phases 0-6 are drop-in (overlay/);
7 is patches/. Cheat signals are folded into existing streams (fastwalk, audit,
vendor.sale), not a separate phase. Remaining work is the Rust sidecar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BridgeTownCrier handles inbound towncrier.add / towncrier.remove, pushing
website-published news into GlobalTownCrierEntryList so every town crier
announces it until it expires. Both run on the Core thread (required: AddEntry
mutates a shared list and the criers send packets). An id maps to the created
TownCrierEntry so a later remove can pull it, and re-adding an id replaces the
prior entry.
Caps are enforced before touching the shared list -- line count, line length,
active-entry count, duration -- as defense in depth on top of the loopback trust
boundary: a buggy or compromised sidecar still cannot flood the criers or pin a
message forever. Config: Bridge.TownCrierMax{Lines,LineLength,Active,DurationSec}.
Verified with a sending stub and a probe that logs the actual crier list. Replies
and game state agree: add n1 -> towncrier.ok and the entry appears with the exact
lines; add n2 (8 lines over the cap of 6) -> towncrier.error and never enters the
list; remove n1 -> towncrier.ok and the entry is gone; remove unknown ->
towncrier.error. Evidence in docs/PLAN.md §16.
Adds BridgeJson.GetStringList for JSON string arrays, tools/stub_sidecar_crier.ps1,
and tools/scaffolding/BridgeCrierProbe.cs. This closes the pure-plugin inbound
work; only the PlayerVendorSale core edit (Phase 7) remains on the ServUO side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BridgeAccountLink ties a game account to a website account. [link mints a
one-time, 5-minute code from an unambiguous alphabet (no O/0/I/1), holds it in a
Core-thread dict keyed to the account, and emits link.request. The website
relays the code back through the sidecar as link.confirm; the shard validates,
writes the WebsiteUserId account tag, and replies link.ok. A bad or expired code
gets link.error.
The tag persists to accounts.xml in ServUO's standard <tags> format, read by
LoadTags at boot, so a link survives restarts with no new persistence layer.
mob.login now carries webId when the account is linked, so the sidecar can
attribute a session to a site user without a lookup.
Safeguards: one-time codes; only the newest code per account is valid; per-account
30s rate limit against code spam; a 1-minute purge bounds the code table; the
websiteUserId is trusted only because the socket is loopback-only. The tag reaches
memory on confirm but disk only on the next save — a hard crash between loses it,
and the player just re-runs [link.
Verified end to end with a smart stub that reads the emitted code and confirms
it: link.request -> link.confirm -> link.ok, a bad code -> link.error, and the
tag observed in accounts.xml after a save. Evidence in docs/PLAN.md §15.
The [link command body is exposed as RequestLink(Mobile) so it can be driven in
tests without a client. Adds tools/stub_sidecar_link.ps1 and
tools/scaffolding/BridgeLinkProbe.cs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BridgeProfile builds the read-models the website consumes; BridgeRequests
registers the inbound handlers. The sidecar asks, the shard answers on the Core
thread (inbound lines are marshaled through Timer.DelayCall before a handler
runs), so all of these read live world state safely.
- char.request: resolve by serial, or by account + slot, and reply with a full
profile (stats, all trained skills, worn equipment with flattened AOS mods,
resists). Works for offline characters since a logged-off mobile stays
resident until Delete.
- account.roster: light per-character summary, offline chars included.
- vendor.snapshot: every player vendor owned by an account, with held gold and
priced listings.
Each request may carry a reqId the reply echoes so the sidecar can correlate.
An unresolvable request gets a bridge.error reply rather than silence, so the
website can show a real failure instead of hanging.
Verified against the real world with a sending stub: all five requests answered,
both char lookup paths (account+slot and serial) returning the identical profile,
vendor.snapshot returning seed_000's two vendors and 80 listings, and the bad
account returning bridge.error. Two real-data findings noted in docs/PLAN.md §14:
a GM character can have skill base > cap (the website must not assume otherwise),
and the mod-flattening path still wants a genuinely kitted character to exercise
against real suffix gear.
Adds tools/stub_sidecar_request.ps1 (sends requests) and a hardened
tools/stub_sidecar.ps1 (survives reaping/rebind).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BridgeSweeps runs three repeating Core-thread timers for the state that has no
EventSink. Cost measured in Phase 1 is why they can run on the main thread: a
full pass of all three is well under a millisecond at the seeded scale.
- Vitals: online players only. Small and volatile; the sidecar diffs snapshots.
Offline characters do not move, so they are served on demand as full profiles
instead, not swept.
- House decay: emits only on a level transition. A silent baseline on
ServerStarted records every house's current stage, so a restart does not
re-announce them. Payload carries from/to, coords, nested ban location,
region, sign name, owner serial+account, and built/refreshed timestamps, all
null-guarded.
- Economy supply: periodic sum of every account's currency as a snapshot. The
level; AccountGoldChange and the vendor events are the flow.
All three re-arm on `[bridge reload`; `[bridge sweepnow` runs one of each on
demand; `[bridge status` reports sweep counters. Sweeps skip emitting while the
sidecar is disconnected, since their state is perishable and re-emitted next
tick anyway (unlike events, which queue through an outage).
Verified on the seeded world with 8s intervals: baseline recorded 29 houses
silently, a probe bumped one Somewhat->Fairly, and the next sweep emitted exactly
one house.decay and none for the other 28. Economy emitted a supply snapshot per
interval. Vitals emitted nothing, correctly, since all seeded characters are
offline. Evidence in docs/PLAN.md §13.
Adds tools/scaffolding/BridgeSweepProbe.cs (never deployed) to force a decay
transition on demand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BridgeEvents subscribes the streams selected for tracking, economy, and cheat
detection: Login/Logout/AccountLogin, AccountGoldChange, ValidVendorPurchase/
Sell, PlacePlayerVendor, SkillGain, FameChange, KarmaChange, QuestComplete,
PlayerDeath, PlayerMurdered, OnKilledBy, FastWalk, OnPropertyChanged, Command,
and Before/AfterWorldSave.
Every handler runs on the Core thread inside the path that raised it, so each is
wrapped to never throw, does only Emit (which enqueues and returns), and never
mutates the args. Three of these are veto hooks and are read strictly:
AccountLogin (Accepted/RejectReason, and a plaintext Password we never emit),
FastWalk (Blocked), and the login decision path generally.
Testing on the live shard found that SkillGain fires for NPCs, hard: the first
boot emitted 115 skill.gain events in four seconds, all spawned creatures
grinding Meditation, zero players. That is the general rule here — most "player"
events also fire for NPCs — so SkillGain, FameChange, KarmaChange, and OnKilledBy
all filter to players on the Core thread before the socket. Gold, fame, karma,
and the save boundaries were fired through their real code paths and observed at
the stub sidecar; gold.change round-trips the platinum->gold conversion and
persists across restarts. Evidence in docs/PLAN.md §12.
Adds tools/scaffolding/BridgeEventProbe.cs (never deployed) which triggers those
events through real world mutations rather than synthetic Invoke calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BridgeLink owns a TcpClient to 127.0.0.1 and nothing else touches it. Emit() is
called from the Core thread; it enqueues onto a bounded drop-oldest queue and
returns. A link thread drains the queue and reconnects with backoff; a reader
thread parses inbound lines and marshals each to the Core thread via
Timer.DelayCall. An absent, slow, or wedged sidecar therefore cannot stall the
shard, which is the property the rest of the bridge depends on.
Outbound JSON is written by hand into a StringBuilder because it runs on the
Core thread for every event and the measured budget assumes that cost. Inbound
uses JavaScriptSerializer: commands arrive at human rates, so correctness beats
speed, and parsing happens off the Core thread anyway. That needs a
System.Web.Extensions reference.
server.hello is emitted per connection rather than once at ServerStarted. A
sidecar that restarts independently would otherwise never learn which shard it
is attached to. It carries a bootId, stable across reconnects and fresh on every
shard restart, so the sidecar can tell "I reconnected" from "the shard
restarted" and keep or discard its cache accordingly.
Two defects found by testing and fixed before commit:
- Backoff ceiling was 30s, so a sidecar restart cost up to half a minute of
buffering on a loopback socket. Now 5s.
- A stale reader could kill a fresh connection: reader.Join(1s) can time out,
and the old thread's finally block then set the shared _dead flag, possibly
tearing down the connection that had replaced it. Connections now carry an
epoch and a reader only marks dead the one it owned.
Acceptance evidence recorded in docs/PLAN.md §11: boots with no sidecar, buffers
through the outage and drains on connect, round-trips ping/pong on the Core
thread, survives unknown kinds and malformed JSON, and reconnects unattended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ScriptCompiler.Compile() runs `dotnet build Scripts/Scripts.csproj -c Release`
with no Platform, so MSBuild defaults to AnyCPU. Scripts.csproj gated both
OutputPath and DefineConstants on Configuration|Platform == Release|x64, so
under the server's own build the DLL landed in Scripts/bin/Release/ (while the
core loads Scripts.dll from the base directory) and TRACE;NEWTIMERS;ServUO went
undefined (XmlSpawner compiled its non-ServUO branches).
Compile() also never checks the build's exit code before Assembly.LoadFrom, so
the failure was silent and the stale DLL reloaded. Runtime script compilation
had had no effect since 2026-05-30.
Condition both property groups on Configuration alone. Server.csproj is left
alone: nothing under Server/ uses those symbols, and giving it OutputPath=..\
would make the boot-time build try to overwrite the running ServUO.exe.
Verified end-to-end: a plain boot now logs "Core: Compiling scripts... / Build
succeeded." and loads 206208 items, 42771 mobiles.
Also adds the implementation plan, the measured performance budget, the test
scaffolding used to produce it (seeder + probe, both default-off), and the
record of shard repairs that had to precede any of this.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>