Five verbs an author sees -- creatures, an enhanced "boss", an oracle NPC, a
temporary gate, decoration -- and ONE command family underneath them, because
every one of them ends in the same sentence: an object exists, and this run
owns it. `world.spawn` / `world.despawn` / `world.owned` carry a `what`
discriminator, and the per-verb differences are fields rather than kinds.
The ownership registry is PERSISTED, and that is forced rather than chosen. A
spawned creature is in the world save, so it survives the restart that proves a
town-crier line gone -- which already rules out reconcile-by-boot-stamp. But the
record of which run owns which serial has nowhere else to live: in memory it is
lost in the restart the creatures survive, and only in the website's ledger it
is not held here at all, so `world.despawn` would delete whatever serial it was
handed and "never touches a creature it did not create" would have no mechanism
behind it. So the Bridge gains its second persisted file beside
`Participation.bin` -- written by the same world save as the objects it
describes, so the two cannot get out of step.
The oracle is ours rather than `XmlSpawner2.XmlDialog`'s, and that engine is the
reason for both halves of the decision. Its `SpeechEntry` is the evidence the
shape is right -- `Text` plus comma-separated `Keywords`, a keyword-less entry
as the greeting, a proximity range, a conversation lock. It is also why not to
build on it: `SpeechEntry` carries an `Action` string, XmlSpawner's
command-scripting language, which would leave an arbitrary-command field one
step from an event author. `Mobile.OnMovement` (delivered to every mobile in
range -- the `HandlesOnMovement` filter applies only to Items) and
`Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals and are all it needs.
Every `Bridge.EventsMax*` REFUSES rather than clamps, on `LeaseMaxDurationSec`'s
argument from 11b: the shard's bound exists for the case where the website is
wrong. `Bridge.EventsEnabled` gates all of it -- spawning is the same consent
11b introduced that switch for, not a third one.
Decoration carries an `itemId`, because `Static` accounts for 5031 of the tree's
decoration placements under 1992 different graphics: for that class the graphic
IS the identity. Never applied to a `BaseAddon`, whose own ItemID is not what a
player sees. Containers are refused outright -- teardown would delete whatever a
player had left inside.
`tools/scaffolding` gains `worldgone <serial>`, which deletes an object behind
the registry's back. It is the one outcome the rig cannot reach by asking the
bridge -- every bridge verb that removes an object also drops its row -- and it
is what a player's sword does every time they kill an event creature.
Verified on a real ServUO 57.4 world (206k items, 42k mobiles) against the
release sidecar: all five verbs place; every ceiling refuses; a container and an
unknown type refuse; one run cannot despawn another's object; the registry and
its objects both survive a save and a clean restart (`pruned: 0`); a creature
deleted behind the registry's back comes back `gone` rather than `removed`; and
a five-second gate is collected by the shard's own deadline with `world.expired`
on the wire.
Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Protocol 6 amended in place. Two mechanisms behind one new default-off gate,
`Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling
the admin plane is consenting to staff moderation from a screen a human is
looking at, and this is consenting to the world being changed and watched on a
schedule, unattended.
BridgeLeases: a live config value held for a bounded time, with the deadline
honoured on the shard whether or not the website is heard from again, and a
compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is
a free restore.
BridgeParticipation: presence in a declared area plus kill credit inside it,
keyed by character serial, persisted in the world save. The Bridge's first
persisted state, because a run spans hours and an in-memory tally would regress
every attendee's score after one restart. Its snapshot is also the first handler
that DEFERS, which makes `bridge.busy` reachable for the first time.
And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame
with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so
the object carried two `kind` fields and every JSON parser takes the last. The
sidecar answered 200 instead of 425. Renamed `busyKind`.
Co-Authored-By: Claude <noreply@anthropic.com>
Protocol 2 could say how many members a guild had, not who they were, and there
is no EventSink for leaving a guild — so PROTOCOL_2.md §10.1 deferred the whole
membership half. This closes it.
The sweep now holds each guild's member serial **set** instead of folding it into
the signature as a sum. That buys two things. A set comparison cannot collide,
where a sum could: one member joining and another leaving between two passes
offset each other and the guild looked unchanged. And a set can be *differenced*,
which is what makes a per-member `guild.leave` possible without a core tap —
departures are simply the prior set minus the current one.
A changed set also re-emits `guild.roster`, the full member list. That is what
lets the departure events stay advisory: a consumer building a "so-and-so left"
feed wants them, but a consumer holding a membership table only needs the roster,
so nothing downstream has to replay deltas to stay correct. On a guild's first
sweep there is no prior set, so nothing is reported as leaving — an unknown
roster becoming known is not 155 people leaving at once.
A roster is the only fat frame this plugin emits — measured at roughly 69 bytes
per member against a real 155-member guild — and the sidecar reads a line with no
length bound. So members per frame are capped (default 500, about 35 KB), and a
guild over the cap is split into frames carrying `seq`, `more` and `total`. Every
realistic guild emits exactly one frame with `seq` 0 and `more` false, which is
the same shape as if chunking did not exist. Verified against the real sidecar
with the cap forced down to 50, which produced 50/50/50/5 across four frames.
The reconnect baseline is spread rather than fired in one pass. `OnConnected`
clears the diff caches, so every guild looks changed at once, and building
hundreds of fat frames in a single Core-thread tick is exactly the stall this
bridge exists to avoid. At most GuildRosterGuildsPerTick guilds emit a roster per
sweep; a guild over budget keeps its old member set, so it still reads as changed
next pass. The sweep re-arms itself after 2s while a baseline is draining, so
catch-up takes seconds rather than one full sweep interval per batch.
BridgeJson gained the array writer it never had — there was no way to express a
list of objects at all. Every field helper emits a leading `,"name":`, so Actor
is split into a bare-object writer that both the single and array forms use.
overlay.toml protocol -> 4, in this commit rather than a later one: CI folds it
into the release manifest and the installer refuses to pair an overlay and a
sidecar that disagree, so a bump landing separately from the emitters would
silently fail to compose into a bundle.
Verified on a live ServUO shard against the real Rust sidecar (not a stub): 155
members seeded from real PlayerMobiles, four roster frames reassembled to 153
entries on the board after two members were removed, two guild.leave frames with
the correct serials, and the departed serials absent from the re-emitted roster.
Refs: docs/website/TEAMS.md Part 12 Phase 1
Co-Authored-By: Claude <noreply@anthropic.com>
Protocol 3.0 §8. Every player vendor's shop name, owner, location and priced
inventory, so the website can offer the search the in-game Vendor Search gump
offers — from outside the game, and honouring the same per-player opt-out.
It cannot be an RPC. rpc.rs correlates a reply on the FIRST frame carrying a
matching reqId, so a chunked reply sharing one reqId would deliver chunk 1 to the
HTTP caller and leak chunks 2..N onto the broadcast feed; a whole-world snapshot
would not fit in one frame inside the 10 s timeout either. So it is a diff sweep
on the broadcast stream, one authoritative frame per vendor.
The one genuinely new pattern here is an amortized round-robin: every other sweep
walks its whole collection per tick, which is fine for tens of houses and is not
fine for a world of shops whose inventories recurse into containers.
MarketSweepBatch (25) vendors are inventoried per tick from a persistent cursor,
so per-tick cost is bounded by the batch rather than by world size.
VendorSearch.GetItemName is never called: it builds an ObjectPropertyList,
serialises it and byte-parses the packet per item. The frame carries itemId, hue,
amount, price, the plain item.Name field and item.LabelNumber; the website
resolves names against its own cliloc table. (It would not work anyway — every
current client ships its cliloc files compressed and ServUO's Ultima.StringList
cannot read them, so the in-game gump has the same gap.)
Measured on the live shard (27 vendors x 40 listings, 209k items / 43k mobiles):
15.4 ms for the first cold tick of 25 vendors, 3.4 ms for the next, 0.3 ms in
steady state. `[bridge status` now reports lastMs/maxMs and a tick over 50 ms
warns, naming the knob — the batch cap is a claim about that number and an
operator tuning it was otherwise tuning blind.
- location is ONE nested object, not flat map/x/y/region, so the website's single
market.location visibility rule can hide a vendor's whereabouts on both the
live frame and the stored read model. Flat keys would need five rules.
- Owner is flat ownerSerial/ownerName, never BridgeJson.Actor, which would add
acct and webId. Same argument points.board makes.
- pv.VendorSearch is honoured, so a shop hidden in game is hidden on the site;
the seen-set removal then emits vendor.listing.remove.
- Container-priced items carry child:true, exactly as DoSearch reports them.
- Over MarketMaxListings (250) the frame says truncated and carries the real
total, so the site shows "250 of 3,104" rather than a partial shop as complete.
Co-Authored-By: Claude <noreply@anthropic.com>
Protocol 3.0 §7 (docs/link/v3.md). ServUO carries ~25 separate point currencies
— Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city
loyalties, the Doom/Khaldun/Kotl treasure systems — every one a standing players
build over months, and none of them visible outside an in-game gump until now.
BridgePoints.cs
- A diff sweep shaped like BridgeHousing: ServerStarted arms the timer, a
sidecar connect clears the diff state so a fresh sidecar gets every board,
and each pass emits only the systems whose top N or participant count moved.
One ~600 B frame per system rather than one 12 KB frame, matching
champ.update / guild.update. No points.remove — the system set is fixed at
startup by PointsSystem.Configure, the same argument city.update makes.
- Selection is a single bounded pass into a fixed N-element array kept sorted
by insertion, NOT OrderByDescending().Take(N). PlayerTable is a plain List
and ten of the ~25 systems have AutoAdd = true, so they hold a row for every
character ever created: the naive version is ~25 full sorts on the Core
thread, which BRIDGE_PLUGIN_PLAN.md §1 measured as the second thing in the
bridge capable of blowing a frame budget.
- Which systems publish defaults to the shard's OWN answer — ShowOnLoyaltyGump
— rather than a list here that would drift; Bridge.cfg PointsSystems=
overrides it, and an unrecognised name is logged rather than dropped.
- Entries are written inline as {serial, name}, never via BridgeJson.Actor. A
board is the widest-audience surface the bridge has, so acct/webId
deliberately do not cross the wire; the site resolves serial → user from its
own link mirror.
char.profile gains a points block, the titles precedent from PROTOCOL_2.md §10.3
- Never uses PointsSystem.GetEntry/GetPoints: both MUTATE THE WORLD, since
GetEntry(create: false) still calls AddEntry when the system has AutoAdd
(PointsSystem.cs:207). Using them would have appended up to ten rows to the
points save file every time anyone opened a character sheet. Hand-rolled
read-only scan instead.
- rank is off by default (PointsProfileRank). A points lookup stops at the
character's own row; a rank must count every row that beats them, in every
system, on every profile build.
Verified by running it, not by reading it: the whole Scripts tree (6,207 files)
compiles clean against real ServUO 57.4 assemblies, and a boot against the local
shard with a 43,011-mobile world emitted five live boards. That run caught a bug
no fake shard could — ServUO's uncapped idiom is MaxPoints = double.MaxValue,
and (long) on it is an UNCHECKED conversion yielding long.MinValue, so the first
sweep published "maxPoints": -9223372036854775808 for three of the five boards.
Cap()/Score() now normalise anything unrepresentable, and maxPoints: 0 is the
documented "uncapped" value — which on a real shard is the common case, not an
edge case. Re-verified after the fix: 0 for the uncapped systems, 15000 and
10000 for the two that genuinely cap.
Co-Authored-By: Claude <noreply@anthropic.com>
Protocol 3.0 §5 (docs/link/v3.md). One frame describing how this shard is
actually configured — expansion, which optional systems are on, skill/stat
caps, account and house limits, champion scroll rules, the save/restart
schedule — so the website's rules page cannot drift from the server.
Modelled on BridgeBoot.EmitHello, not on the diff sweeps: the ruleset changes
only when an operator edits a .cfg, so there is nothing to poll. It subscribes
Connected_Core, so a sidecar that comes up second still learns the ruleset,
and `[bridge reload` re-emits for an operator who just edited a file.
The frame is built from an EXPLICIT ALLOWLIST of Config.Get calls. Config.Entries
is never enumerated — that would sweep in every key on the server, secrets
included — and Server.cfg, Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg,
Compiler.cfg, Reports.cfg and Client.cfg are named as excluded both here and in
a code comment. The one connection detail published is Bridge.PublicConnectAddress,
blank by default, which an operator sets deliberately for this purpose.
`rev` is FNV-1a over the body so an unchanged reconnect is a site-side no-op.
String.GetHashCode() is deliberately not used: it is seeded per process, so it
would change on every restart and defeat the diff.
Verified by compiling the full ServUO Scripts tree (6,205 files, net48, EJ) with
this overlay substituted for the deployed Bridge copy — clean.
Co-Authored-By: Claude <noreply@anthropic.com>
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
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>
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
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 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
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>
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>