Commit Graph

68 Commits

Author SHA1 Message Date
63a7dc4374 feat(bridge): lease deadlines and the participation ledger (Phase 11b)
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>
2026-09-04 19:31:20 -05:00
d2a12c46e2 Merge pull request 'feat(bridge): protocol 6 — an idempotency key, and champ.boss.killed (Phase 11a)' (#21) from feature/protocol-v6-idempotency into edge
Reviewed-on: #21
2026-09-04 23:06:18 +00:00
7aa7bc8032 feat(bridge): protocol 6 — an idempotency key, and champ.boss.killed (Phase 11a)
A command carrying an `idempotencyKey` is now executed at most once: a repeat is
answered with the original reply rather than re-run. That is the precondition
every world verb in Phase 12 is waiting on, and it is what let `uo.broadcast`
stop being un-retryable.

The gate sits in BridgeBoot's inbound dispatch, not in each handler, so it covers
every kind including ones a later protocol adds. A command with no key behaves
exactly as it did before, which leaves the admin screens unchanged.

Four rules, each a decision rather than an implementation detail: reserve on
receipt (so a handler that defers is covered, answering `bridge.busy` to a repeat
in flight); a key that has begun is never released, not even when the handler
throws; a replay is stamped with the REPEAT's correlation id, because the
sidecar's reqId is fresh per call and replaying the original would hang the retry;
and the bound is loud, because an evicted key is the guarantee's one hole.

`champ.boss.killed` rides along because a bump costs a release, a bundle and an
operator update on every shard. It fires from EventSink.CreatureDeath, detected
by type so a boss that popped and died inside one sweep is still reported, and it
carries the damage table that exists at the death and nowhere else.

overlay.toml protocol = 6, in this commit rather than a later one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 14:56:49 -05:00
827de04471 Merge pull request 'feat(bridge): protocol 5 — cutover 2a of 7 (edgemain)' (#20) from edge into main
All checks were successful
Release overlay / release (push) Successful in 23s
Reviewed-on: #20
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v1.1.0
2026-09-01 13:55:02 +00:00
28b878c850 Merge pull request 'fix(bridge): make the market sweep notice a vendor running out of gold' (#19) from fix/vendor-fee-signature into edge
Reviewed-on: #19
2026-09-01 12:32:13 +00:00
1dd490b483 fix(bridge): make the market sweep notice a vendor running out of gold
`BridgeMarket.Signature()` diffs shop name, owner, map, coordinates and the
item/price list -- the things a LISTING is made of. Protocol 5 added a `fees`
block to the frame and the change detector never learned about it.

So a vendor quietly running down its gold altered nothing the sweep compared,
emitted no frame, and `uo.vendor.expiring` -- the notification whose entire
subject is a vendor running out of gold -- could fire only by coincidence: when
somebody happened to reprice an item on a shop that was already broke. Proved on
the engagement Phase 11b live rig by setting a vendor's held gold to zero and
watching no frame follow.

The signature carries the DERIVED values, `exempt` and `periodsRemaining`, not
the raw ones. An integer division moves only when the shard's own answer to "is
this vendor in danger" moves; `HoldGold` changes on every sale and `NextPayTime`
on every tick, and keying on either would re-emit a fat listing frame for a shop
whose listings had not changed.

Emit CADENCE, not frame shape: no field added, PROTOCOL_VERSION untouched, and
`overlay.toml` unchanged. The general form is worth carrying forward -- a
sweep-based kind has a change detector, and a field added to the frame but not to
the detector ships correct and arrives never.

Also adds `tools/scaffolding/BridgeRigDriver.cs`: the shard driven from outside
the game over a polled command file. A walk asserts what happened BETWEEN two
steps, so the steps have to be separated by the observer rather than by a
hard-coded delay -- and ServUO's console takes a fixed verb set, so `[p5probe`
cannot be typed at a headless shard at all. Never deployed; `deploy.ps1` copies
only `overlay/`. The README gains the two ServUO facts the walk cost a rebuild
each to learn: a condemned house cannot be refreshed, and only a clean shutdown
emits.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:12:46 -05:00
a144c12c46 Merge pull request 'feat(bridge): protocol 5 — decay schedule, vendor fee state, and a login result' (#18) from feature/protocol-v5-enrichments into edge
Reviewed-on: #18
2026-09-01 00:27:47 +00:00
b818f6cf37 feat(bridge): protocol 5 — decay schedule, vendor fee state, and a login result
Three emitter changes and the overlay's protocol declaration, in one PR because
"The bridge is a contract": overlay.toml must be bumped in the same change as the
emitters or the next bundle silently fails to compose.

BridgeSweeps — house.decay gains ownerName and a nested `schedule`
{dynamicDecay, nextStage, decayPeriodSec, estimatedCollapse}.

estimatedCollapse is emitted only where ServUO can actually know it. Dynamic decay
(Core.ML) draws each stage's duration at RANDOM when the stage is entered, so
NextDecayStage is exact for the next transition and nothing beyond it is known —
collapse becomes exact only at IDOC, where the next transition IS the collapse.
Static decay is a pure function of LastRefreshed and DecayPeriod, so it is exact at
every stage. Emitting it anywhere else would publish a guess as a fact, and on the
website's side that becomes a dated promise in someone's mail.

BridgeMarket — vendor.listing gains ownerAcct and a nested `fees` block.

ownerAcct is the one that matters structurally: the frame has carried ownerName
since v3, but a character name joins to nothing — only the game account is the
website's link key. The fees block resolves PlayerVendor.PayTimer's dismissal rule
(pay > totalGold => Destroy) on the shard, because both halves of that comparison
differ between ServUO's two vendor systems and re-deriving them downstream would be
a second implementation of a rule that lives in core.

No daysRemaining: under the old vendor system a pay period is a UO day
(Clock.MinutesPerUODay, about two real hours), so the obvious name would be wrong
by a factor of twelve on exactly the shards least likely to notice. periodsRemaining
plus the interval, and dismissalAt as an instant. A commission vendor has no pay
timer at all and reports exempt with no schedule — "never dismissed" is not the same
as "dismissed in 400 days".

BridgeEvents — a new account.login.result kind.

EventSink.AccountLogin is a veto hook that fires BEFORE the auth decision, and
AccountLoginEventArgs constructs with Accepted = true, so the existing
account.login.attempt fires on successful logins too and cannot carry a verdict. A
security rule built on it would have mailed "someone tried to get into your account"
every time the player logged in.

The verdict is read one Core slice later via DelayCall(Zero). That needs no core
patch AND does not depend on handler subscription order, which ServUO does not define
and a shard's own scripts can change. reason is omitted on an accept, because
ALRReason's zero value is Invalid and would read as a failure reason. The address is
resolved inside the handler, since AccountLogin_ReplyRej disposes the NetState before
the deferred read runs. The password is never read, logged or emitted.

tools/scaffolding/BridgeProtocol5Probe.cs drives all three on a live shard, and the
README records the two traps it took to get there — both of which produce SILENCE
rather than an error, so each looks exactly like a broken emitter:

  * An in-process login probe can never produce accepted:true. AccountHandler calls
    acct.HasAccess(e.State) BEFORE it checks the password, and a null NetState fails
    that. Only a real socket proves the accepted half — and it is the better test
    anyway, since it also produces the real ip.
  * Forcing a decay stage on a house that cannot decay emits nothing at all. Only
    Condemned and ManualRefresh houses decay; an AutoRefresh one — and the owner's
    NEWEST house is always AutoRefresh — has a DecayLevel getter that calls
    ResetDynamicDecay() and reports Ageless, wiping the forced stage before the sweep
    reads it.

Verified on the local rig against the release sidecar: a house walked
Fairly -> Greatly -> IDOC carried estimatedCollapse on the IDOC frame and only there;
every vendor's periodsRemaining matched funds/chargePerPeriod, including one at 0
whose dismissalAt equals its next tick; a real socket login gave
accepted:false reason:BadPass and then accepted:true. Compiles clean against ServUO
57.4 reference assemblies.

Docs: RunicGateway/docs link/v5.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 19:19:34 -05:00
badc1702de Merge pull request 'chore(tools): build guilds, and walk houses into IDOC where the site can see it' (#17) from chore/demo-guilds-and-idoc into main
All checks were successful
Release overlay / release (push) Successful in 7s
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-25 15:03:35 +00:00
fc4ebf0f5a chore(tools): build guilds, and walk houses into IDOC where the site can see it
Two things the screenshot rig needed and the dressing pass could not give it
(runicgateway.com PLAN.md §13 phase 9).

GUILDS. The world had none — the guild board on the site was two rows of week-old
cache for guilds that had been deleted, and the website's Teams reconcile from that
board, so Teams was empty too. There is nothing to rename here: a guild has to exist
before it can be called something, so this builds four of them out of characters the
seeder already made, with a leader, two officers per guild and an alliance across the
first two. Idempotent by name, and a character already in a guild is never moved.

IDOC. "Houses in danger" reads a column the ingest only writes when the plugin reports
a house CHANGING stage; the registry frame carries the stage too, but the ingest leaves
that column to the transition feed so the two cannot clobber each other. A house that is
already collapsing when the site connects is therefore invisible: the sweep baselines it
at IDOC and no transition is ever emitted. The staging is now two passes — prime a few
houses at a middle stage at boot, collapse them 150 seconds later — so the site watches
it happen. Where too few houses can decay at all, their owners' accounts are backdated,
which is the same lever the seeder pulled and the same one a real shard pulls when
somebody stops playing.

Also: "Bridge Test Shop", left over from a hand-run smoke test, now gets a name like
every other vendor.

Both of the site-side asymmetries above are recorded as product observations in the
file rather than patched from here.

Test scaffolding, in tools/, never deployed — deploy.ps1 copies overlay/ only.
Verified against the local ServUO tree: four guilds and their rosters reached the
website over a real sidecar, and two houses reached "Houses in danger".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 09:35:53 -05:00
8cf995f27f Merge pull request 'chore(tools): dress a seeded world so it can be screenshotted' (#16) from chore/demo-world-dressing into main
All checks were successful
Release overlay / release (push) Successful in -57s
Reviewed-on: #16
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-25 00:34:55 +00:00
158c0596d8 chore(tools): dress a seeded world so it can be screenshotted
BridgeSeeder builds a world at realistic scale, which is what the bridge needed;
it never needed the world to look like anything. So a vendor is `seed vendor`
trading as `Seed Shop 810`, a character is `Seed004A` and a house sign reads
`Seed House 12` — and every one of those strings travels the whole bridge and
lands on the marketplace, the guild roster and the housing pages of the website.
That is fine for a protocol test and wrong for the marketing site's screenshots
(runicgateway.com PLAN.md §13 phase 9, D42/D46).

BridgeDemoDress renames them in place and seeds nothing: prices, listing counts,
decay stages, fame and skills stay exactly as the seeder left them, so the data
keeps its provenance and only the strings a human reads change. Names come from
fixed tables hashed off each object's serial, so a re-run reproduces the same
world and screenshots can be retaken later and still match.

It also does two things the screenshots needed and nothing else provides:

- stages a few condemned houses back into the last decay levels, because decay
  is a live process and "Houses in danger" is empty by the time anyone looks
- sets a known password on seed_000, because logging a character in is the only
  way to make the online roster non-empty and the seeder assigns a random GUID.
  The password is read from Bridge.cfg, never compiled in.

Test scaffolding, in tools/, never deployed — deploy.ps1 copies overlay/ only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 19:22:46 -05:00
0d9ac7fda8 Merge pull request 'ci(release): show the error body, retry the POST, and sweep for orphan tags' (#15) from ci/release-post-retry-and-error-body into main
All checks were successful
Release overlay / release (push) Successful in 9s
Reviewed-on: #15
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 19:42:41 +00:00
8195454201 ci(release): show the error body, retry the POST, and sweep for orphan tags
installer#22's release run built every artifact, pushed its tag, then took a 500
from POST /releases one second later and exited 22 -- leaving the tag orphaned
with no binaries. Re-running published it unchanged, so the 500 was a race with
the tag push rather than a bad request.

This repo's release step has the same two gaps verbatim, and it is where the
whole problem was first seen.

`curl -sSf` prints no response body on an error status, so such a failure leaves
only "curl: (22) ... error: 500" in the log and the cause has to be inferred
from timestamps. Every call now captures the body and prints it on failure.

Nothing retried, so a transient 5xx became a permanent orphan. The POST now
retries five times with a 5/10/15/20s backoff. 4xx is deliberately not retried:
a bad token or a malformed body will not improve by being sent again.

The asset upload gets the same treatment. That matters more here than anywhere
else: this release ships an overlay tarball and a SHA256SUMS, and a release
whose checksums do not cover the tarball they advertise is worse than no
release, because that file is the trust anchor and the installer verifies
against it.

The third gap is the one this repo proves. The orphan-tag recovery in the plan
step is VERSION-SCOPED -- it computes VERSION from the newest tag plus the bump,
then only checks refs/tags/v${VERSION}. That recovers an orphan on the very next
run and is useless afterwards, because once any releasable commit lands the next
run computes a NEW version and never looks at the old tag again.

v0.1.0 was the proof, and the proof is pointed: the commit that ADDED that
recovery was itself typed "fix(release): preflight credentials and recover the
orphaned v0.1.0 tag", so it bumped the version to v0.1.1 -- and the run that
introduced the recovery stepped straight past the tag it was written to rescue.
The tag stayed orphaned from 2026-08-04 until today.

So the plan step now sweeps every v* tag and warns about any without a release.
It warns rather than recovers, deliberately: publishing an old version would
mean building today's tree and shipping it under a tag whose tree it is not,
which is worse than the inconsistency it fixes. It never fails the run either --
a sweep that can break a good release is a sweep someone will delete.

v0.1.0 itself is deleted, on the org lead's decision. Nothing referenced it: it
is three releases behind, and no published bundle names it -- not even
bundle-2026.08.04, because the tag never had a release for a bundle to point at.
It was 724262548b, the merge of #7, recorded here
so the tag can be recreated if that turns out to be wrong.

Verified by extracting every run block from the YAML: bash -n clean across all
of them, the YAML parses, no empty template token, the asset loop still the
tarball-and-checksums pair rather than link's three binaries, the retry loop
exercised against a stubbed curl across seven cases, and the sweep run against
the real repositories -- reporting v0.1.0 before the deletion and clean after.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no artifact, so a release here would be an empty one. That is
the same rule the fix commit above tripped over.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:43:24 -05:00
79cc611ee0 Merge pull request 'feat(bridge)!: protocol 4 — guild rosters and per-member leaves (Teams cutover 1/6)' (#14) from edge into main
All checks were successful
Release overlay / release (push) Successful in 13s
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v1.0.0
2026-08-19 08:54:43 +00:00
d57d9aad84 Merge pull request 'feat(bridge): carry guild rank on roster members' (#13) from feat/protocol4-guild-rank into edge
Reviewed-on: #13
2026-08-17 22:49:10 +00:00
8c6db9f0d5 feat(bridge): carry guild rank on roster members
Protocol 4 is still on `edge` and unreleased, so this amends it in place rather
than bumping: `PROTOCOL_VERSION` and `overlay.toml` both stay at 4. A bump is
only owed once a protocol has reached `main`.

Phase 1 shipped the roster member as the standard actor object, which carries no
guild rank. The consequence surfaced in Teams phase 2: the website could only
learn leadership from the board's single `leader` field, so `getTeamLeaders()`
could return exactly one member — while a UO guild routinely has several at rank
4, and TEAMS.md §2.5 treats multiple leaders as the normal case.

Roster members now carry `rank` (0-4, 4 being Leader per RankDefinition.Ranks)
plus `rankCliloc`, or `rankName` when a custom rank definition uses a literal
string instead of a cliloc. Only the raw rank goes on the wire: ServUO names the
five standard ranks with cliloc ids and ships no text for them, so this shard
cannot produce "Warlord" without a client-file table it does not have. The
website module has one, and resolving a game term is its job in any case.

`withGuildRank` is a parameter on `Actors()` rather than a change to the shared
actor writer. Rank is a property of a mobile's membership of THIS guild, not of
the mobile, and every other actor this bridge writes is a bystander, a killer or
a governor, where guild rank is meaningless. `WriteActor` is split into a
fields-only writer so both forms share one definition of an actor.

## The trap this found

**`PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at
GameMaster or above, whatever their actual rank.** It is a gameplay convenience
so staff can operate a guild stone, and it is emphatically not a claim about who
leads the guild -- but it is what the only public accessor returns, and the true
value sits in a private field. Emitting it verbatim would have published every
staff member in a guild as a guild leader on a public website.

Staff are therefore written with no rank fields at all. A staff account that
genuinely leads its guild shows as an unranked member, which is a visible gap
rather than a false claim -- the right way round, given the name on that roster
reaches a public page.

## Verification

This repo has no CI build, so compiling is not evidence. Run against the local
ServUO tree with a throwaway probe that synthesised a guild from real
PlayerMobiles across the rank ladder, with one account promoted to GameMaster.
The emitted frame:

  tester    rank 4  cliloc 1062959   (Leader)
  Seed000A  rank 4  cliloc 1062959   (Leader)  <- two at once, the point of this
  Seed000B  rank 3  cliloc 1062960   (Warlord)
  Seed000C  rank 2  cliloc 1062961   (Emissary)
  Seed001A  rank 1  cliloc 1062962   (Member)
  Seed001B  no rank fields                     <- GameMaster, stored rank 0,
                                                  getter reported rank 4

The probe printed stored vs reported rank per member, so the getter's substitution
is recorded rather than inferred: `Seed001B storedRank=0 reportedRank=4
access=GameMaster`. The line parsed as valid JSON.

`dotnet build Scripts.csproj` clean, 0 warnings. Probe deleted, tree rebuilt, and
`deploy.ps1 -Verify` reports 0 changes against the overlay. The shard was killed
without a world save, so the synthetic guild did not persist (Guilds.bin still 0
bytes).

**The sidecar needs no change.** It treats roster members as opaque values and
never reads a field inside one -- `accumulate_roster` moves them and
`upsert_guild_roster` stores them, both by value. That is the forwarder design
paying off.

Refs docs/link/v4.md §2.3

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 17:34:51 -05:00
65562eea40 Merge pull request 'feat(bridge)!: guild rosters and per-member leaves, on protocol 4' (#12) from feat/teams-phase1-guild-roster into edge
Reviewed-on: #12
2026-08-17 19:28:40 +00:00
cc4f58317e feat(bridge)!: guild rosters and per-member leaves, on protocol 4
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>
2026-08-17 12:52:07 -05:00
0eda2d3a97 Merge pull request 'docs: make the installer the documented way to deploy the overlay' (#11) from docs/installer-first-setup into main
All checks were successful
Release overlay / release (push) Successful in 5s
Reviewed-on: #11
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 21:32:13 +00:00
48b16dc70e docs: make the installer the documented way to deploy the overlay
"## Deploy" led with deploy.ps1 and mentioned the installer only
afterwards, which is backwards now that the installer is released.

- Deploy leads with the installer, with the by-hand overlay copy
  (INSTALL.md Appendix A2) as the supported alternative.
- deploy.ps1 gets its own subsection as the developer path: it deploys
  from a working tree, which is the one thing the installer cannot do,
  and it installs no sidecar and checks no protocol pairing.
- CONTRIBUTING: note that changes reach shards through a release, so a
  change that only works when deploy.ps1 copies it does not ship.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:05:56 -05:00
c045bdd566 Merge pull request 'feat(patches): declare the patch tier in tier.json and the manifest' (#10) from feat/patch-tier-metadata into main
All checks were successful
Release overlay / release (push) Successful in 11s
Reviewed-on: #10
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.2.0
2026-08-05 01:08:31 +00:00
8828382e41 feat(patches): declare the patch tier in tier.json and the manifest
A .patch file does not carry enough for an installer to run the tier safely.
The installer additionally needs to know which patches form one all-or-nothing
unit (the two vendor-sale patches are useless apart), which companion .cs may
only be copied once that unit has landed, whether the change needs a core
solution rebuild or just ServUO's dynamic script build, and what capability an
operator loses by declining. None of that is derivable from the diffs.

patches/tier.json declares it, and release.yml folds it into manifest.json as
`patch_tier` — so a new or changed patch regenerates release metadata rather
than requiring an installer release, which is the same rule §7.1 already
applies to the bundle. The staged copy is removed from patches/ so the tarball
carries exactly one statement of the table.

The release gate now checks the table in both directions: every .patch
described by exactly one feature, every named patch and companion present,
every declared target equal to the file the diff actually edits, and every
rebuild kind one the installer understands. All four were previously invisible
until someone ran the tier on a live shard.

Refs: docs/installer/PLAN.md §2.2, §7.0

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 19:30:20 -05:00
7fa8953ffa Merge pull request 'ci(release): recompose the installer bundle after publishing' (#9) from ci/dispatch-bundle into main
All checks were successful
Release overlay / release (push) Successful in 7s
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 16:18:27 +00:00
4720a214a2 ci(release): recompose the installer bundle after publishing
Phase 0 item 3 of docs/installer/PLAN.md wired up from this side. The installer
does not resolve "latest" at run time — it installs the exact combination named
by a published bundle manifest (PLAN.md §7.1), so until now a new overlay release
was invisible to operators until the installer repo's nightly cron noticed it.

Adds a final step that POSTs to RunicGateway/installer's bundle workflow-dispatch
endpoint. That job re-reads this tarball's manifest.json and checks its declared `protocol`
against the sidecar's PROTOCOL_VERSION before publishing anything (gate 1) — the
check this repo cannot perform for itself, since the C# plugin announces no
version on the wire. It replaces the TODO the header has carried since #7, which
was deliberately left unimplemented while there was nothing to dispatch.

Dispatch, don't wait (PLAN.md §7.3): Gitea's dispatch endpoint returns no run
handle, so there is nothing to poll — a waiting step would have to guess which
run is its own while holding a runner idle. The bundle job runs its own gates
regardless of who started it.

A dispatch failure is a warning, never a failure of this job. By the time this
step runs the release is published and correct, so failing the run would
misreport that; the installer's nightly cron recomposes from whatever the latest
releases actually are, making a dropped dispatch cost latency rather than
correctness. That also means REGISTRY_TOKEN having write on the installer repo
is a nicety, not a new hard requirement — noted in the header.

Verified the workflow still parses and that the new step is last, gated on
release=='true', and contains no path that can exit non-zero.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:13:47 -05:00
3a52abbd77 Merge pull request 'fix(release): preflight credentials and recover the orphaned v0.1.0 tag' (#8) from fix/release-credential-preflight into main
All checks were successful
Release overlay / release (push) Successful in 10s
Reviewed-on: #8
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
v0.1.1
2026-08-04 15:26:54 +00:00
eebc74ac8d fix(release): preflight credentials and recover the orphaned v0.1.0 tag
The first release run tagged the repo and then failed, leaving v0.1.0 with
no release behind it and no way to ever get one.

REGISTRY_USER and REGISTRY_TOKEN are not configured on this repo, but the
tag push SUCCEEDED anyway: actions/checkout leaves an
`http.<host>.extraheader` credential in the local git config, so
`git remote set-url` to a URL with empty credentials still authenticated
through that leftover header. The release API call had no such fallback and
returned 401 (visible in the run log as `REGISTRY_USER:` / `REGISTRY_TOKEN:`
with empty values). So the run got exactly far enough to do the one thing
that is hard to undo.

Worse, that state was self-perpetuating. The plan step treated any existing
tag as "nothing to release", so every subsequent push to main would see
v0.1.0, set RELEASE=false, and stand down — the release would never appear,
and no amount of re-running would fix it.

Two fixes:

  A credential preflight, before anything is built or pushed, gated on the
  run intending to publish so a docs:/chore:-only merge still passes on a
  repo without secrets. It names the missing secrets and the scope they
  need, rather than failing at whichever step happens to use them first.

  Orphan-tag recovery. The plan step now asks the API whether a release
  exists for the tag: 200 means stand down, 404 means an earlier run died
  after tagging, so reuse the tag and publish the release it is missing.
  This deliberately overrides the RELEASE=false the bump logic just decided
  — with the tag already in place there are no releasable commits after it,
  which is precisely why the stuck state could not clear itself.

  Anything other than 200/404 (network failure, bad token) is refused
  rather than guessed, because assuming "no release" would republish over a
  good one.

  The tag step reuses an existing tag instead of failing on `git tag`, and a
  recovery run's changelog summarizes what the tag contains
  (previous-tag..this-tag) instead of the empty range after it.

Once REGISTRY_USER / REGISTRY_TOKEN are set, the next push to main will
finish the release that the first run started — v0.1.0, from the same
commit it already points at.

Verified against the live repo state: the plan step now reports
release=true reuse_tag=true for the orphaned v0.1.0 and renders the correct
changelog; a tag that does have a release (checked against link's v0.3.0)
still stands down; a fresh repo still takes the seed path; and the
preflight fails loudly on empty secrets and passes on populated ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 10:15:13 -05:00
724262548b Merge pull request 'ci(release): publish the overlay as a release tarball with a manifest' (#7) from ci/overlay-release-workflow into main
Some checks failed
Release overlay / release (push) Failing after 10s
Reviewed-on: #7
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 14:26:46 +00:00
ebbfab51fc ci(release): publish the overlay as a release tarball with a manifest
Phase 0 item 1 of the installer plan (docs/installer/PLAN.md §5). The
installer deploys the plugin from a release tarball rather than from git,
because the shard host gets neither git nor Gitea credentials — but this
repo published no releases at all, so there was nothing for it to fetch.
`link` was the only repo with a release workflow.

Reuses link/.gitea/workflows/release.yml's conventional-commit engine, as
that file's own header anticipated: the plan and release steps consume only
{version, changelog, artifacts}. Three things had to change, each forced by
this repo rather than chosen:

  No build. The plugin ships as C# source and ServUO compiles it at boot;
  it needs ServUO reference assemblies, so nothing here can be compiled in
  CI. The build gates are replaced by structural ones that assert what can
  honestly be asserted without a ServUO tree: Bridge.cfg and the Bridge
  scripts are present, Scripts.csproj (the silent-build-bug fix) is present,
  every .patch parses as a unified diff via `git apply --stat`, and each
  patch's companion .cs exists. Each of those has a way of shipping broken
  and only failing on an operator's live shard.

  No bump commit, so no push to main. link writes the version into
  Cargo.toml because the binary embeds it; a tarball embeds nothing but the
  manifest CI generates, so the git tag is the version. This workflow
  therefore never needs main to accept a direct push — no branch-protection
  exception for it.

  A manifest. The tarball carries manifest.json: version, commit, declared
  protocol version, ServUO compatibility, and a SHA256 per shipped file.

The manifest matters more than it looks. The plugin announces no version on
the wire and none is queryable before ServUO boots (PLAN.md §2.6), so its
declared protocol version is the ONLY thing that lets the installer's bundle
CI verify sidecar/overlay agreement before an operator installs the pair
(PLAN.md §7.1 gate 1). That declaration lives in the new overlay.toml
alongside the ServUO compatibility values, so it is one commented line to
maintain rather than a literal buried in a workflow — currently protocol 3,
per docs/link/v3.md.

Tarball layout uses a FIXED top-level directory (runicgateway-overlay/)
rather than a versioned one, so the installer can find overlay/, patches/
and manifest.json at known paths instead of parsing the version it is trying
to read. tar's member order, mtime and ownership are pinned, so a given tree
produces a byte-identical tarball and its checksum changes only when the
contents do.

Verified locally against the real tree before pushing: YAML parses, all six
run blocks pass bash -n, the plan step produces v0.1.0 from actual history,
the gates pass (22 bridge scripts, all three patches parse), the manifest
renders with protocol=3 and 30 file hashes, and two consecutive builds of
the tarball produce the same SHA256.

One real bug caught by running it rather than reading it: sha256sum marks
binary mode by prefixing the path with `*` instead of the two-space
text-mode separator, which would have put a leading `*` on every key in the
manifest. The capture now tolerates both.

Not included: the workflow-dispatch call into the installer's bundle CI
(PLAN.md §7.2). That is Phase 0 item 3 and there is nothing to dispatch yet;
the insertion point is marked in the header. A step that 404s on every
release is worse than no step.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 09:23:56 -05:00
968b526fac Merge pull request 'feat(bridge)!: Protocol 3.0 cutover — world.ruleset, points.board, vendor.listing' (#6) from edge into main
Reviewed-on: #6
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:33:27 +00:00
7215ae5fe1 Merge pull request 'feat(bridge): publish the player-vendor market index as vendor.listing' (#5) from feat/vendor-listing into edge
Reviewed-on: #5
2026-07-29 20:06:43 +00:00
48d57e6278 feat(bridge): publish the player-vendor market index as vendor.listing
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>
2026-07-29 09:51:00 -05:00
a38afe4c90 Merge pull request 'feat(bridge): publish points/loyalty leaderboards as points.board' (#4) from feat/points-board into edge
Reviewed-on: #4
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:54:09 +00:00
ed8f568d94 feat(bridge): publish points/loyalty leaderboards as points.board
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>
2026-07-28 21:04:08 -05:00
a7a383e6d9 Merge pull request 'feat(bridge): emit world.ruleset, the shard's published ruleset' (#3) from feat/bridge-ruleset into edge
Reviewed-on: #3
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 20:37:05 +00:00
3fb4b7dc9f feat(bridge): emit world.ruleset, the shard's published ruleset
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>
2026-07-28 11:14:36 -05:00
64a89f97af Merge pull request 'docs: scrub machine path from deploy examples' (#1) from chore/scrub-paths into main
Reviewed-on: #1
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-20 09:31:48 +00:00
c72ed6619e Merge branch 'main' into chore/scrub-paths 2026-07-20 09:31:16 +00:00
76b0b3e66a Merge pull request 'chore: add open-source governance files (GPLv3 + contributing docs)' (#2) from chore/open-source-governance into main
Reviewed-on: #2
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-19 00:30:15 +00:00
Claude
4e8be1a086 chore: add open-source governance files (GPLv3 + contributing docs)
Add standard open-source project files:
- LICENSE.md — GNU GPL v3.0 or later (verbatim)
- CONTRIBUTING.md — setup, workflow, and required AI-usage disclosure
- CONTRIBUTORS.md — maintainers, contributors, AI-assistance policy
- CODE_OF_CONDUCT.md — Contributor Covenant 2.1
- SECURITY.md — private vulnerability reporting
- .gitea/ISSUE_TEMPLATE/* + PULL_REQUEST_TEMPLATE.md
- README: License section (Copyright (C) 2026 Runic Gateway)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
2026-07-18 19:11:02 -05:00
Claude
5c6b1bb299 docs: scrub machine path from deploy examples
Replace the personal ServUO checkout path (C:\Users\...\servuo) with a
<servuo> placeholder in the README and deploy.ps1 usage examples.
2026-07-18 02:33:04 -05:00
Claude
ea12ac3f94 servuo-plugins: add README and .gitignore
Repo-level README (adapted from the link repo's plugin docs) plus a
Sidecar & deployment section describing the runtime protocol relationship
with the Rust sidecar in RunicGateway/link.
2026-07-18 00:57:33 -05:00
e9856212ce Merge pull request 'docs: move docs to RunicGateway/docs, repoint all references' (#10) from chore/extract-docs into main
Reviewed-on: RunicGateway/link#10
2026-07-18 05:42:04 +00:00
0fd6b91f22 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
5cade1aa9d Merge pull request 'Protocol 2.0 + 2.1 — account provisioning, world-state streams, Town Cryer news' (#7) from feat/protocol2-account-provisioning into main
Reviewed-on: UOM/link#7
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-17 16:37:27 +00:00
5e0b42b948 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
be442d36a0 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
a837edd5ee 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
1244eb6c4f 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
dd39d524c6 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