22 Commits

Author SHA1 Message Date
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>
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>
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>
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
17 changed files with 2444 additions and 53 deletions

View File

@@ -51,18 +51,22 @@
#
# Prerequisites (Settings → Actions → Secrets on RunicGateway/servuo-plugins):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
# tag and create the release.
# tag and create the release. The final step also dispatches
# RunicGateway/installer's bundle workflow, so the token
# ideally has write there too — a nicety, not a requirement:
# without it the step warns and that repo's nightly cron
# picks the release up instead.
# REGISTRY_USER — the Gitea username that token belongs to.
#
# These are checked by an explicit preflight step rather than left to fail
# wherever they happen to be used first — see the comment on that step for why
# an absent token does NOT simply fail the tag push.
#
# TODO (Phase 0 item 3): once the installer repo's bundle workflow exists, append
# a final step here that POSTs to its workflow-dispatch endpoint, so a new
# overlay release recomposes the bundle immediately instead of waiting for the
# nightly cron (PLAN.md §7.2). Deliberately absent until there is something to
# dispatch — a step that 404s every release is worse than no step.
# The final step POSTs to the installer repo's bundle workflow, so a new overlay
# release recomposes the compat matrix immediately instead of waiting for that
# repo's nightly cron (PLAN.md §7.2). It was deliberately absent until Phase 0
# item 3 landed something to dispatch — a step that 404s on every release is
# worse than no step.
name: Release overlay
@@ -84,6 +88,9 @@ env:
# house style set by link (pre-1.0; the release version is independent of the
# protocol version, which lives in overlay.toml).
SEED_VERSION: "0.1.0"
# Notified after a release so the installer's compat matrix picks up this
# overlay immediately rather than at its next nightly run (PLAN.md §7.2).
INSTALLER_REPO: RunicGateway/installer
jobs:
release:
@@ -169,6 +176,39 @@ jobs:
fi
fi
# ── Orphan sweep ────────────────────────────────────────────────
#
# The check above is VERSION-SCOPED: it only ever asks about the one
# version this run computed. That is enough to recover an orphan on
# the very next run, and useless afterwards — once any releasable
# commit lands, the next run computes a NEW version, never looks at
# the old tag again, and the orphan becomes permanent and silent.
#
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
# commit that ADDED the recovery above was itself typed
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
# v0.1.1 — and the run that introduced the recovery stepped straight
# past the tag it was written to rescue. That tag is still orphaned.
#
# So every v* tag is checked, and anything missing a release is
# WARNED about. Deliberately not recovered: 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.
# A human decides whether to recover or drop it.
#
# Never fails the run. A sweep that can break a good release is a
# sweep someone will delete.
ORPHANS=""
for T in $(git tag -l 'v*' --sort=-v:refname); do
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
done
if [ -n "${ORPHANS}" ]; then
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
@@ -256,6 +296,10 @@ jobs:
# needing the target files present.
# • each patch's companion .cs must exist, since it references symbols
# the patch introduces and is meaningless without it (PLAN.md §2.2).
# • patches/tier.json must describe every .patch and nothing but. That
# table is what tells the installer which patches form one unit, which
# companion follows which, and whether a CORE rebuild is needed — a
# patch added without it would be shipped and silently never offered.
- name: Validate the overlay and patch tier
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
@@ -276,11 +320,46 @@ jobs:
git apply --stat "$p" || fail "${p} is not a parseable unified diff"
done
# Companion files that can only be copied after their patch lands.
for f in patches/BridgeVendorSale.cs patches/BridgeModerationAudit.cs; do
[ -f "$f" ] || fail "${f} is missing (a patch's companion source)"
# The tier table, checked in BOTH directions. A patch missing from
# tier.json ships but is never offered to an operator; a tier.json
# entry naming a file that is not there makes the installer report a
# feature it cannot apply. Neither surfaces until someone runs the
# tier on a live shard, so both fail the release here instead.
[ -f patches/tier.json ] || fail "patches/tier.json is missing (the patch-tier declaration)"
jq -e . patches/tier.json >/dev/null || fail "patches/tier.json is not valid JSON"
DESCRIBED="$(jq -r '.features[].patches[].file' patches/tier.json | LC_ALL=C sort)"
PRESENT="$(cd patches && ls *.patch | LC_ALL=C sort)"
if [ "$DESCRIBED" != "$PRESENT" ]; then
echo "described by tier.json:"; echo "$DESCRIBED" | sed 's/^/ /'
echo "present in patches/:"; echo "$PRESENT" | sed 's/^/ /'
fail "patches/tier.json and patches/*.patch disagree — every patch must be described by exactly one feature"
fi
# Each patch's declared target must be the file its diff actually
# edits. The installer cross-checks the same pair at install time and
# refuses on a mismatch, so catching it here saves an operator the run.
while IFS=$'\t' read -r PFILE PTARGET; do
DIFF_TARGET="$(sed -n 's|^+++ b/||p' "patches/${PFILE}" | head -1 | tr -d '\r')"
[ "$DIFF_TARGET" = "$PTARGET" ] \
|| fail "patches/${PFILE} edits ${DIFF_TARGET} but tier.json declares ${PTARGET}"
done < <(jq -r '.features[].patches[] | [.file, .target] | @tsv' patches/tier.json)
# Companions can only be copied after their feature's patches land, so
# they live here rather than in overlay/ — and a missing one turns a
# successfully patched shard into one that does not compile.
for f in $(jq -r '.features[].companions[].file' patches/tier.json); do
[ -f "patches/${f}" ] || fail "patches/${f} is missing (a feature's companion source)"
done
for r in $(jq -r '.features[].rebuild' patches/tier.json); do
case "$r" in
core|scripts) ;;
*) fail "tier.json declares rebuild=\"${r}\"; only \"core\" or \"scripts\" are understood" ;;
esac
done
echo "patch tier: $(jq -r '.features | length' patches/tier.json) feature(s), $(echo "$PRESENT" | wc -l) patch(es)"
[ -f overlay.toml ] || fail "overlay.toml is missing (protocol + ServUO declarations)"
# ── OVERLAY ADAPTER: stage, manifest, package ────────────────────────
@@ -303,6 +382,12 @@ jobs:
cp -r overlay "${STAGE}/overlay"
cp -r patches "${STAGE}/patches"
# tier.json is folded into manifest.json below, so the staged copy is
# removed: shipping it twice would give the tarball two statements of
# the same table, one of which nothing reads and both of which are
# free to drift.
rm -f "${STAGE}/patches/tier.json"
# Declarations from overlay.toml. Read, don't hardcode — the point of
# that file is that the protocol number lives in one place.
PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')"
@@ -313,6 +398,18 @@ jobs:
[ -n "$PATCHED_AGAINST" ] || { echo "::error::could not read patches_verified_against from overlay.toml"; exit 1; }
echo "==> protocol=${PROTOCOL} min_servuo=${MIN_SERVUO} patches_verified_against=${PATCHED_AGAINST}"
# The patch tier, folded in verbatim minus its comment block. Paths are
# rewritten to be relative to the tarball root (`patches/<file>`), which
# is where the installer will find them after extraction — tier.json
# names them relative to patches/ because that is where a maintainer
# editing it is looking.
TIER="$(jq '
del(._comment)
| .features |= map(
.patches |= map(.file |= "patches/" + .)
| .companions |= map(.file |= "patches/" + .)
)' patches/tier.json)"
# Per-file SHA256 of everything shipped, as a {path: sha} object. The
# installer records these in install.json so a later `doctor` can tell
# "operator edited a deployed file" from "the overlay drifted".
@@ -336,6 +433,7 @@ jobs:
--argjson protocol "${PROTOCOL}" \
--arg min_servuo "${MIN_SERVUO}" \
--arg patched_against "${PATCHED_AGAINST}" \
--argjson tier "${TIER}" \
--argjson files "${FILES}" \
'{
component: $component,
@@ -347,6 +445,7 @@ jobs:
min_version: $min_servuo,
patches_verified_against: $patched_against
},
patch_tier: $tier,
files: $files
}' > "${STAGE}/manifest.json"
@@ -411,17 +510,114 @@ jobs:
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
# installer#22's release run failed exactly here: it landed one second
# after the tag push and Gitea answered 500, having not finished
# processing the pushed tag. Re-running published the same artifacts
# untouched, so it was a race, not a bad request — but the tag sat
# orphaned until a human noticed.
#
# Two things made that worse than it needed to be.
#
# 1. `curl -sSf` prints NO response body on an error status, so all the
# log carried was "curl: (22) ... error: 500" and the cause had to be
# inferred from timestamps. Capture the body and print it.
# 2. Nothing retried, so a transient 5xx became a permanent orphan.
#
# 4xx is deliberately NOT retried: a bad token or a malformed body does
# not improve by being sent again, and retrying only turns a clear
# failure into a slow one.
REL_ID=""
for attempt in 1 2 3 4 5; do
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
-d "${PAYLOAD}" || echo 000)"
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
REL_ID="$(jq -r '.id' /tmp/rel.json)"
break
fi
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
echo "--- response body ---"
cat /tmp/rel.json || true
echo
echo "---------------------"
case "$HTTP" in
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
esac
if [ "$attempt" = 5 ]; then
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
exit 1
fi
sleep $(( attempt * 5 ))
done
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
echo "::error::Release created but no id came back; refusing to upload assets blind."
exit 1
fi
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${TARBALL}" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
# Same treatment. An upload that fails quietly leaves a release whose
# SHA256SUMS does not cover every artifact it advertises, which is
# worse than no release at all -- that file is the trust anchor.
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
-F "attachment=@dist/${f}" || echo 000)"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "::error::uploading ${f} returned HTTP ${HTTP}"
cat /tmp/asset.json || true
exit 1
fi
echo " uploaded ${f}"
done
# ── Recompose the installer's bundle manifest ────────────────────────
# The installer does not resolve "latest" at run time — it deploys the
# exact overlay named by a published bundle (docs/installer/PLAN.md §7.1).
# An overlay release that nobody recomposes around is therefore a release
# no operator will ever be offered. This tells the installer repo to
# rebuild that manifest now rather than leaving the new version invisible
# until its nightly cron.
#
# That job re-reads this tarball's manifest.json and checks its declared
# `protocol` against the sidecar's PROTOCOL_VERSION before publishing
# anything (PLAN.md §7.1, gate 1) — which is the check this repo cannot
# perform for itself, since the C# plugin announces no version on the wire.
#
# DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint
# returns no run handle, so there is nothing to poll: a waiting step would
# have to guess which run is its own and hold a runner idle to do it.
#
# A failure here is a WARNING, never a failure of this job. The release is
# already published and correct by this point, and failing the run would
# misreport that. The installer's nightly cron recomposes from whatever the
# latest releases actually are, so a dropped dispatch costs latency, not
# correctness.
- name: Ask the installer repo to recompose its bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ref":"main"}' \
"https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)"
case "$HTTP" in
20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;;
403|404)
echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;;
*)
echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;;
esac

View File

@@ -33,6 +33,13 @@ under `overlay/` (or `patches/` for changes to stock ServUO files) and deploy:
.\deploy.ps1 -ServerPath C:\path\to\servuo
```
`deploy.ps1` deploys from *this working tree*, which is what you want while
developing. It is not how a shard is set up: operators run the
[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer),
which syncs the released overlay tarball and installs the sidecar alongside it.
Changes here reach shards through a [release](README.md#releases), so a change
that only works when `deploy.ps1` copies it is a change that does not ship.
- `overlay/` — copied over an install (the only thing `deploy.ps1` deploys).
- `patches/` — unified diffs against stock ServUO for files we must modify.
- `tools/` — never deployed: test scaffolding and stub sidecars.

View File

@@ -26,7 +26,7 @@ integration guide, protocol spec, research — with full history preserved).
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
| `tools/` | Never deployed. Test scaffolding (C# probes + PowerShell stub sidecars) and anything else that must not reach a server. |
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
| `deploy.ps1` | **Developer tool** — copies `overlay/` from this working tree into a server root. `-Verify` diffs instead of writing. Operators use the [installer](https://gitea.whitlocktech.com/RunicGateway/installer); see [Deploy](#deploy). |
| `overlay.toml` | Release metadata: the wire-protocol version this overlay speaks, and its ServUO compatibility. Read by CI into the release manifest — see [Releases](#releases). |
| `.gitea/workflows/release.yml` | Publishes `runicgateway-overlay-<ver>.tar.gz` on every merge to `main`. |
| [INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md) | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. |
@@ -39,14 +39,17 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
## Sidecar & deployment
The Rust sidecar is the other half of the bridge and lives in **[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**.
The two are deployed **together** but built **independently**:
The two are deployed **together** — by the
[installer](https://gitea.whitlocktech.com/RunicGateway/installer), in one run — but built
**independently**:
- **This plugin** is deployed as *source* `deploy.ps1` copies `overlay/` into the ServUO server
root, and ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)).
There is **no CI build** — it cannot be compiled standalone without the ServUO reference
assemblies. CI does publish a *source* tarball for the installer to fetch; see
- **This plugin** is deployed as *source*: `overlay/` is copied into the ServUO server root and
ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)). There is
**no CI build** — it cannot be compiled standalone without the ServUO reference assemblies. CI
publishes a *source* tarball, which is what the installer fetches and syncs; see
[Releases](#releases).
- **The sidecar** is a standalone Rust binary, released from its own repo.
- **The sidecar** is a standalone Rust binary, released from its own repo and installed from that
release.
The **only** coupling is the loopback JSON protocol (the shard dials out to the sidecar on
`127.0.0.1`). Compatibility is a **protocol** concern, not a build-order one: keep the event/command
@@ -57,14 +60,32 @@ without the sidecar running.
## Deploy
**On a shard, use the [Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer).**
One binary syncs this overlay from the release tarball below, offers the patch tier, installs the
uo-link sidecar as a service, and prints the values your website needs — cross-platform, with a
`doctor` afterwards to tell a copied file from a working bridge:
```bash
sudo ./runicgateway-installer-linux-x86_64 install
```
Guide: [installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md).
To place the overlay yourself instead — a host that cannot run the binary, or you want to see every
file land — [Appendix A2](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#a2-deploy-the-plugin-overlay)
is the same copy done by hand, and stays supported.
### `deploy.ps1` — the developer path
`deploy.ps1` deploys from a **working tree**, which is what you want while writing plugin code and
is the one thing the installer cannot do (it deploys from a release):
```powershell
.\deploy.ps1 -ServerPath <servuo> -Verify # show what would change
.\deploy.ps1 -ServerPath <servuo> # write
```
`deploy.ps1` is the **developer-facing** tool and stays that way. Operators get the
[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer), which does the
same sync cross-platform from the release tarball below.
It is Windows-only and stays developer-facing; it never installs the sidecar, registers a service,
or checks the protocol pairing. Nothing shipped to an operator depends on it.
## Releases

View File

@@ -23,8 +23,8 @@
# manual duty: when the protocol changes, bump it here in the same PR that
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
#
# Current: 3 — see docs/link/v3.md (world.ruleset, points.board, vendor.listing).
protocol = 3
# Current: 5 — see docs/link/v5.md (house.decay scheduling, vendor.listing fees, account.login.result).
protocol = 5
# ── ServUO compatibility ─────────────────────────────────────────────────────
#

View File

@@ -34,6 +34,18 @@ PageSweepSeconds=5
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
GuildSweepSeconds=60
# Members per guild.roster frame (Protocol 4). A roster is the only fat frame the bridge emits
# (~69 bytes per member) and the sidecar reads a line with no length bound, so this caps it; a
# guild over the cap is split across continuation frames carrying seq/more. 500 members is ~35 KB,
# past any realistic guild, so the split path is an edge case rather than the norm.
GuildRosterMembersPerLine=500
# Guilds that may emit a roster in one sweep. Every guild looks changed right after a sidecar
# reconnect, and building hundreds of fat frames in a single Core-thread pass is exactly the stall
# the bridge exists to avoid. The sweep re-arms itself every 2s while a baseline is draining, so
# lowering this slows the catch-up without making the site wait a full sweep interval per batch.
GuildRosterGuildsPerTick=25
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).

View File

@@ -38,6 +38,10 @@ namespace Server.Custom.Bridge
public static int PointsSweepSeconds { get; private set; }
public static int MarketSweepSeconds { get; private set; }
// ---- guild rosters (Protocol 4) ----
public static int GuildRosterMembersPerLine { get; private set; }
public static int GuildRosterGuildsPerTick { get; private set; }
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
public static bool MarketEnabled { get; private set; }
public static int MarketSweepBatch { get; private set; }
@@ -114,6 +118,24 @@ namespace Server.Custom.Bridge
if (GuildSweepSeconds < 1)
GuildSweepSeconds = 1;
// A roster line is the only fat frame this plugin emits — measured at roughly 69 bytes
// per member — and the sidecar reads a line with no length bound. The cap turns an
// unbounded frame into a bounded one; a guild above it is split across continuation
// lines. 500 members is ~35 KB, comfortably past any real guild, so the split path is
// an edge case rather than the norm.
GuildRosterMembersPerLine = Config.Get("Bridge.GuildRosterMembersPerLine", 500);
if (GuildRosterMembersPerLine < 16)
GuildRosterMembersPerLine = 16;
// How many guilds may emit a roster in a single sweep. Every guild re-emits after a
// reconnect (the diff caches are cleared), and building a few hundred fat JSON frames in
// one Core-thread pass is exactly the stall this bridge exists to avoid. The sweep
// re-arms itself promptly while a baseline is still draining, so this throttles the work
// without making the site wait a full sweep interval per batch.
GuildRosterGuildsPerTick = Config.Get("Bridge.GuildRosterGuildsPerTick", 25);
if (GuildRosterGuildsPerTick < 1)
GuildRosterGuildsPerTick = 1;
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
if (CitySweepSeconds < 1)
CitySweepSeconds = 1;

View File

@@ -170,9 +170,53 @@ namespace Server.Custom.Bridge
.Str("acct", e.Username)
.Str("ip", address)
.End());
EmitLoginResult(e, address);
});
}
/// <summary>
/// Protocol 5. The RESULT of the login above, which the attempt itself cannot carry.
///
/// Why a second kind rather than two more fields: PacketHandlers.AccountLogin invokes this
/// sink and only THEN branches on e.Accepted, and the decision is made by the handlers
/// themselves -- Server.Misc.AccountHandler is the one that validates the password and
/// sets Accepted/RejectReason. Inside our own handler the verdict therefore does not exist
/// yet: Accepted is still its constructor default of `true` for a password that is about
/// to be rejected. Anything built on the attempt alone fires on every SUCCESSFUL login
/// too, which is the wrong way round for a security notice -- it would tell a player
/// "someone tried to get into your account" every time they logged in themselves.
///
/// Reading it one Core slice later, via DelayCall(Zero), is what makes the verdict final
/// without a core patch and without depending on handler subscription ORDER, which
/// ServUO does not define and which a shard's own scripts can change.
///
/// On holding the args object: it carries the plaintext Password, so it is deliberately
/// alive for one extra slice and no longer, and exactly two properties are read off it.
/// The password is never read, never logged and never emitted -- the same rule the
/// attempt emitter above states.
/// </summary>
private static void EmitLoginResult(AccountLoginEventArgs e, string address)
{
// The NetState is disposed by AccountLogin_ReplyRej before this runs, which is why the
// address is passed in already resolved rather than re-read from e.State.
Timer.DelayCall(TimeSpan.Zero, () =>
Guard("account.login.result", () =>
{
var sb = BridgeJson.Begin("account.login.result")
.Str("acct", e.Username)
.Str("ip", address)
.Bool("accepted", e.Accepted);
// ALRReason is only meaningful on a rejection; on an accept it is still the
// enum's zero value (Invalid), which would read as a failure reason if emitted.
if (!e.Accepted)
sb.Str("reason", e.RejectReason.ToString());
BridgeLink.Emit(sb.End());
}));
}
// ---- economy ----
private static void OnGoldChange(AccountGoldChangeEventArgs e)

View File

@@ -85,14 +85,153 @@ namespace Server.Custom.Bridge
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
{
sb.Append(",\"").Append(name).Append("\":");
if (m == null)
{
sb.Append("null");
WriteActor(sb, m);
return sb;
}
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
/// <summary>
/// Writes a named array of actor objects — a guild roster (Protocol 4) being the first
/// caller. Every other outbound helper here emits a leading `,"name":`, so an array
/// element needs the bare object; that is why <see cref="WriteActor"/> exists separately
/// rather than <see cref="Actor"/> being reused.
///
/// `count` bounds how many are written, because a roster frame must stay a bounded line
/// (Bridge.GuildRosterMembersPerLine). A null entry in the sequence is skipped rather
/// than written as null, so the array is always a list of real members and a caller can
/// trust its length.
///
/// `withGuildRank` adds each member's guild rank to their object. It is a parameter
/// rather than always-on because rank is a property of a mobile's membership of THIS
/// guild, not of the mobile — every other actor this bridge writes is a bystander,
/// a killer, a governor, and guild rank is meaningless on all of them.
/// </summary>
public static StringBuilder Actors(
this StringBuilder sb, string name, IList<Mobile> mobiles, int start, int count,
bool withGuildRank = false)
{
sb.Append(",\"").Append(name).Append("\":[");
if (mobiles != null)
{
var end = Math.Min(start + count, mobiles.Count);
bool first = true;
for (int i = start; i < end; i++)
{
var m = mobiles[i];
if (m == null)
continue;
if (!first)
sb.Append(',');
if (withGuildRank)
WriteGuildMember(sb, m);
else
WriteActor(sb, m);
first = false;
}
}
sb.Append(']');
return sb;
}
/// <summary>
/// A roster member: the standard actor object plus the member's rank in their guild.
///
/// **Only the raw rank is emitted, never a resolved label.** ServUO names the five
/// standard ranks with cliloc ids (10629591062963) and ships no text for them, so the
/// shard cannot produce "Warlord" without a client-file table it does not have. The
/// website module does have one, and resolving a game term is its job in any case.
///
/// `rank` is the numeric rank, 04, with 4 being Leader (`RankDefinition.Ranks`). A
/// custom rank definition may carry a literal string instead of a cliloc, so `rankName`
/// is written when there is one and `rankCliloc` when there is not; a shard that has
/// replaced the rank table therefore keeps its own naming rather than being flattened
/// into the stock five.
///
/// A member with no readable rank — a mobile that is not a PlayerMobile, or one whose
/// GuildRank is null — is written with no rank fields at all rather than a fabricated
/// default. Absent means "not known", and a consumer that treated a missing rank as 0
/// would silently demote them.
///
/// **Staff are deliberately written with no rank, and this is not a rounding error.**
/// `PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at GameMaster or
/// above, whatever their actual rank — a gameplay convenience so staff can operate a
/// guild stone, and emphatically not a claim about who leads the guild. The true value
/// is in a private field with no accessor, so the only honest options are "Leader" and
/// "not known", and publishing a staff member as a guild leader on a public roster is
/// the worse of the two by a wide margin. A staff account that genuinely leads its guild
/// shows as an unranked member, which is a visible gap rather than a false claim.
/// </summary>
private static void WriteGuildMember(StringBuilder sb, Mobile m)
{
if (m == null)
{
sb.Append("null");
return;
}
sb.Append('{');
WriteActorFields(sb, m);
var pm = m as Server.Mobiles.PlayerMobile;
var rank = pm == null || pm.AccessLevel >= AccessLevel.GameMaster ? null : pm.GuildRank;
if (rank != null)
{
sb.Append(",\"rank\":").Append(rank.Rank);
if (!string.IsNullOrEmpty(rank.Name.String))
{
sb.Append(",\"rankName\":");
Escape(sb, rank.Name.String);
}
else if (rank.Name.Number > 0)
{
sb.Append(",\"rankCliloc\":").Append(rank.Name.Number);
}
}
sb.Append('}');
}
/// <summary>
/// One bare actor object, with no leading field name: serial, name, account (when there
/// is one), the linked webId (when the account is linked), and the player flag. A `null`
/// mobile writes null.
///
/// `acct` and `webId` are the site-identity fields, and they are emitted here
/// unconditionally by design — the sidecar is a forwarder, and deciding who may see them
/// is the website's job (it projects per the shard visibility rungs). Note that `acct` is
/// genuinely optional: a PlayerMobile can have no Account at all.
/// </summary>
private static void WriteActor(StringBuilder sb, Mobile m)
{
if (m == null)
{
sb.Append("null");
return;
}
sb.Append('{');
WriteActorFields(sb, m);
sb.Append('}');
}
/// <summary>
/// The actor fields, with no braces, so a caller can add its own.
///
/// Split out for <see cref="WriteGuildMember"/>, which is the same object plus guild
/// rank. Note the first field is written WITHOUT a leading comma and every later one
/// with, so this must be the first thing inside its object.
/// </summary>
private static void WriteActorFields(StringBuilder sb, Mobile m)
{
sb.Append("\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
sb.Append(",\"name\":");
Escape(sb, m.Name ?? "");
@@ -112,8 +251,6 @@ namespace Server.Custom.Bridge
}
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
sb.Append('}');
return sb;
}
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Text;
using Server.Accounting;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
@@ -455,6 +456,20 @@ namespace Server.Custom.Bridge
sb.Append(vendor.Map == null ? "" : vendor.Map.Name).Append('|');
sb.Append(vendor.X).Append(',').Append(vendor.Y).Append('|');
// The FEE STATE, and it belongs here for a reason found on a live rig: a vendor
// quietly running out of gold changes none of the fields above, so without this the
// sweep sees no change, emits nothing, and `uo.vendor.expiring` -- the warning whose
// entire subject is a vendor running out of gold -- can only fire by coincidence,
// when somebody happens to reprice an item on a shop that is already broke.
//
// The DERIVED values, not the raw ones. `periodsRemaining` is an integer division, so
// it moves only when the shard's own answer to "is this vendor in danger" moves --
// near-zero extra frame volume -- while `HoldGold` changes on every sale and
// `NextPayTime` on every tick, which would re-emit a fat listing frame for a shop
// whose listings did not change. Protocol-neutral: the fields already ship in
// `AppendFees`, and this changes only WHEN a frame is sent.
AppendFeeSignature(sb, vendor);
var limit = Math.Min(_items.Count, BridgeConfig.MarketMaxListings);
sb.Append(_items.Count).Append('|');
@@ -506,8 +521,16 @@ namespace Server.Custom.Bridge
{
sb.Ser("ownerSerial", owner.Serial);
sb.Str("ownerName", owner.Name);
// Protocol 5. Without this the listing names an owner the website cannot resolve to
// a person: ownerName is a character name, and only the account is the link key.
var acct = owner.Account as Account;
if (acct != null)
sb.Str("ownerAcct", acct.Username);
}
AppendFees(sb, vendor);
sb.Append(",\"location\":{\"map\":");
Text(sb, vendor.Map == null ? null : vendor.Map.Name);
sb.Append(",\"x\":").Append(vendor.X);
@@ -587,5 +610,96 @@ namespace Server.Custom.Bridge
return sb.End();
}
/// <summary>
/// Protocol 5. The vendor's fee state, which is what makes "your vendor is about to be
/// dismissed" a thing the website can say BEFORE it happens instead of after.
///
/// The dismissal rule is PlayerVendor.PayTimer.OnTick: at every tick the charge is
/// compared with the funds, and `if (pay > totalGold) Destroy()`. Both halves of that
/// comparison differ between ServUO's two vendor systems, so both are resolved here
/// rather than left for the sidecar or the website to guess at:
///
/// | charge | funds | interval
/// NewVendorSystem | ChargePerRealWorldDay | HoldGold | 1 real day
/// old system | ChargePerDay | BankAccount + HoldGold | 1 UO day
///
/// Two consequences worth stating, because both are easy to get wrong downstream:
///
/// * A field called `daysRemaining` would be WRONG on an old-system shard, where a pay
/// period is a UO day (Clock.MinutesPerUODay, roughly two real hours) rather than a
/// real one. So this emits `periodsRemaining` plus the interval that gives it meaning,
/// and resolves the arithmetic into `dismissalAt` -- an instant, which needs no units.
/// * A commission vendor (IsCommission) has no PayTimer at all and is never dismissed
/// for fees. It reports exempt:true and no schedule, rather than a misleading
/// "infinite days".
///
/// `dismissalAt` assumes no further sales or deposits, exactly as a bank balance
/// projection does. Unlike a dynamic-decay house, though, there is no randomness in it:
/// given the current funds it is the exact tick the vendor is destroyed on.
/// </summary>
/// <summary>
/// The fee state as the change-detector sees it: exempt, and how many pay ticks the
/// vendor survives. Kept beside `AppendFees` so the two cannot drift -- a fee field
/// that becomes decision-relevant has to be added in both places, and this comment is
/// where the next person is told so.
/// </summary>
private static void AppendFeeSignature(StringBuilder sb, PlayerVendor vendor)
{
if (vendor == null || vendor.IsCommission)
{
sb.Append("exempt|");
return;
}
int charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
int funds = BaseHouse.NewVendorSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
// Mirrors AppendFees: a free vendor never runs out, and reports no periods at all.
sb.Append(charge > 0 ? (funds / charge).ToString() : "free").Append('|');
}
private static void AppendFees(StringBuilder sb, PlayerVendor vendor)
{
sb.Append(",\"fees\":{");
if (vendor.IsCommission)
{
sb.Append("\"exempt\":true}");
return;
}
bool newSystem = BaseHouse.NewVendorSystem;
int charge = newSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
int funds = newSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
sb.Append("\"exempt\":false");
sb.Append(",\"newVendorSystem\":").Append(newSystem ? "true" : "false");
sb.Append(",\"chargePerPeriod\":").Append(charge);
sb.Append(",\"funds\":").Append(funds);
sb.Append(",\"holdGold\":").Append(vendor.HoldGold);
sb.Append(",\"bankAccount\":").Append(vendor.BankAccount);
var interval = newSystem ? TimeSpan.FromDays(1.0) : TimeSpan.FromMinutes(Clock.MinutesPerUODay);
sb.Append(",\"payIntervalSec\":").Append((long)interval.TotalSeconds);
var nextPay = vendor.NextPayTime.ToUniversalTime();
sb.Append(",\"nextPayAt\":");
Text(sb, nextPay.ToString("o"));
// A free vendor (no priced stock under the old system can reach charge 0) never runs out.
if (charge > 0)
{
// Ticks it survives before the one that finds pay > totalGold.
long periods = funds / charge;
sb.Append(",\"periodsRemaining\":").Append(periods);
sb.Append(",\"dismissalAt\":");
Text(sb, nextPay.AddSeconds(periods * interval.TotalSeconds).ToString("o"));
}
sb.Append('}');
}
}
}

View File

@@ -15,10 +15,14 @@ namespace Server.Custom.Bridge
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
///
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
/// so joined" feed does not wait for the next sweep. A membership change also moves the board
/// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in
/// the next `guild.update`; per-member leave events would need a core tap and are a later
/// refinement (§10.1).
/// so joined" feed does not wait for the next sweep.
///
/// Protocol 4 adds the membership half that §10.1 deferred. The sweep holds each guild's
/// member serial **set** rather than a sum of it, so a change is detected by set comparison
/// (no hash collisions, unlike the old sum where two offsetting changes could cancel) and the
/// departures are recoverable by difference — which is what makes a per-member `guild.leave`
/// possible without a core tap. A changed set also re-emits `guild.roster`, the full member
/// list, so the board self-corrects and nothing downstream has to replay deltas to stay right.
///
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
@@ -32,7 +36,16 @@ namespace Server.Custom.Bridge
// was cleared on reconnect), so its next sweep counts as a change.
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
private static long _sweeps, _emitted, _removed, _joins;
// guild id -> last-emitted member serial set (Protocol 4). Held rather than summed so a
// departure can be recovered as a set difference; see the class remarks.
private static readonly Dictionary<int, HashSet<int>> _members =
new Dictionary<int, HashSet<int>>();
private static long _sweeps, _emitted, _removed, _joins, _rosters, _leaves;
// Set while a post-reconnect baseline is still draining, so the sweep re-arms promptly
// instead of leaving the site a sweep interval behind. See GuildSweep.
private static bool _draining;
public static void Initialize()
{
@@ -52,6 +65,7 @@ namespace Server.Custom.Bridge
private static void OnConnected()
{
_last.Clear();
_members.Clear();
}
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
@@ -72,8 +86,9 @@ namespace Server.Custom.Bridge
public static string Status()
{
return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
_sweeps, _emitted, _removed, _joins, _last.Count);
return String.Format(
"guilds(sweeps={0} emitted={1} removed={2} joins={3} rosters={4} leaves={5} tracked={6} draining={7})",
_sweeps, _emitted, _removed, _joins, _rosters, _leaves, _last.Count, _draining);
}
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
@@ -93,6 +108,12 @@ namespace Server.Custom.Bridge
var seen = new HashSet<int>();
// Guilds whose roster this sweep is still allowed to emit. Every guild looks changed
// right after a reconnect, and a roster is this plugin's only fat frame, so the
// baseline is spread over several passes rather than built in one Core-thread tick.
var rosterBudget = BridgeConfig.GuildRosterGuildsPerTick;
var deferred = false;
foreach (var bg in BaseGuild.List.Values)
{
var g = bg as Guild;
@@ -104,25 +125,84 @@ namespace Server.Custom.Bridge
seen.Add(g.Id);
var current = MemberSerials(g);
HashSet<int> priorMembers;
var known = _members.TryGetValue(g.Id, out priorMembers);
var membersChanged = !known || !priorMembers.SetEquals(current);
var sig = Signature(g);
string prior;
if (_last.TryGetValue(g.Id, out prior) && prior == sig)
var sigChanged = !_last.TryGetValue(g.Id, out prior) || prior != sig;
if (!sigChanged && !membersChanged)
continue; // unchanged since last emit
if (sigChanged)
{
_last[g.Id] = sig;
BridgeLink.Emit(WriteGuild(g));
_emitted++;
}
if (!membersChanged)
continue;
// Over budget: leave _members untouched so this guild is still "changed" next
// pass and gets its roster then. The guild.update above has already gone, so the
// board's counts are current either way.
if (rosterBudget <= 0)
{
deferred = true;
continue;
}
rosterBudget--;
// Departures, per member, before the roster that supersedes them: a consumer
// building a "so-and-so left" feed needs the individual events, while a consumer
// holding the membership table only needs the roster. On the very first sweep for
// a guild there is no prior set, so nothing is reported as having left — an
// unknown roster becoming known is not 155 people leaving.
if (known)
{
foreach (var serial in priorMembers)
{
if (current.Contains(serial))
continue;
BridgeLink.Emit(BridgeJson.Begin("guild.leave")
.Num("id", g.Id)
.Str("name", g.Name)
.Ser("who", (Serial)serial)
.End());
_leaves++;
}
}
EmitRoster(g);
_members[g.Id] = current;
}
// Anything tracked last sweep but not seen now has disbanded or been removed.
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
foreach (var id in gone)
{
_last.Remove(id);
_members.Remove(id);
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
_removed++;
}
// Re-arm promptly while a baseline is still draining. Without this the remaining
// guilds would each wait a full GuildSweepSeconds, so a 200-guild shard would take
// hours to publish its rosters after a reconnect instead of seconds. The sweep is
// idempotent, so an extra pass that finds nothing changed costs a few field reads.
_draining = deferred;
if (deferred)
Timer.DelayCall(TimeSpan.FromSeconds(2.0), GuildSweep);
}
catch (Exception ex)
{
@@ -130,14 +210,15 @@ namespace Server.Custom.Bridge
}
}
// The volatile fields that define a meaningful change: name, abbreviation, leader, member
// count, the member set (order-independent serial sum), and alliance.
private static string Signature(Guild g)
/// <summary>
/// The guild's live member serials. Held per guild between sweeps so a membership change
/// yields both the fact that it changed and *who* left (Protocol 4).
/// </summary>
private static HashSet<int> MemberSerials(Guild g)
{
long memberSum = 0;
int count = 0;
var set = new HashSet<int>();
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
@@ -145,8 +226,28 @@ namespace Server.Custom.Bridge
var m = members[i];
if (m == null)
continue;
set.Add(m.Serial.Value);
}
}
return set;
}
// The volatile fields that define a meaningful change to the *board row*: name, abbreviation,
// leader, member count and alliance. Membership is no longer folded in here as a serial sum —
// the sweep compares the real member set instead, which cannot collide the way a sum can when
// one member joins and another leaves between two passes.
private static string Signature(Guild g)
{
int count = 0;
var members = g.Members;
if (members != null)
{
for (int i = 0; i < members.Count; i++)
{
if (members[i] != null)
count++;
unchecked { memberSum += (uint)m.Serial.Value; }
}
}
@@ -157,7 +258,6 @@ namespace Server.Custom.Bridge
g.Abbreviation ?? "", "|",
leaderSerial.ToString(), "|",
count.ToString(), "|",
memberSum.ToString(), "|",
g.Alliance == null ? "" : (g.AllianceName ?? ""));
}
@@ -191,6 +291,55 @@ namespace Server.Custom.Bridge
return sb.End();
}
/// <summary>
/// Emits the guild's full member list as one or more `guild.roster` frames (Protocol 4).
///
/// A roster is the only fat frame this plugin produces — roughly 69 bytes per member — and
/// the sidecar reads a line with no length bound, so the member count per line is capped
/// (Bridge.GuildRosterMembersPerLine). A guild over the cap is split, and each frame
/// carries `seq` plus `more` so a consumer can tell a complete roster from a partial one:
/// `seq` 0 begins a roster and replaces whatever was held, and `more` false ends it. A
/// guild inside the cap — every realistic one — emits exactly one frame with `seq` 0 and
/// `more` false, which is the same shape as if chunking did not exist.
/// </summary>
private static void EmitRoster(Guild g)
{
var members = g.Members;
var total = members == null ? 0 : members.Count;
var perLine = BridgeConfig.GuildRosterMembersPerLine;
var seq = 0;
var start = 0;
// do/while, not while: a guild with no members must still emit one empty roster frame,
// or a consumer could never learn that a roster it holds has emptied.
do
{
var more = start + perLine < total;
var sb = BridgeJson.Begin("guild.roster")
.Num("id", g.Id)
.Str("name", g.Name)
.Str("abbr", g.Abbreviation)
.Num("total", total)
.Num("seq", seq)
.Bool("more", more);
// `withGuildRank` — the roster is the one place a member's rank in THIS guild is
// meaningful, and the only frame that carries it. Leadership is rank 4
// (RankDefinition.Ranks), and a guild can have several members at it, which is why
// the board's single `leader` field was never enough to answer "who leads this".
sb.Actors("members", members, start, perLine, withGuildRank: true);
BridgeLink.Emit(sb.End());
_rosters++;
start += perLine;
seq++;
}
while (start < total);
}
// ---- real-time join ----
private static void OnJoinGuild(JoinGuildEventArgs e)

View File

@@ -215,11 +215,14 @@ namespace Server.Custom.Bridge
if (owner != null)
{
sb.Ser("ownerSerial", owner.Serial);
sb.Str("ownerName", owner.Name);
var acct = owner.Account as Account;
if (acct != null)
sb.Str("ownerAcct", acct.Username);
}
AppendDecaySchedule(sb, house, to);
// Where a player would physically stand to see it.
var ban = house.BanLocation;
sb.Append(",\"ban\":{\"x\":").Append(ban.X)
@@ -232,6 +235,67 @@ namespace Server.Custom.Bridge
return sb.End();
}
/// <summary>
/// Protocol 5. The three scheduling fields, and the reason they are not all always present.
///
/// ServUO has two decay implementations and they differ in how KNOWABLE the future is:
///
/// * Dynamic decay (DynamicDecay.Enabled, i.e. Core.ML) draws each stage's duration at
/// RANDOM when the stage is entered (BaseHouse.SetDynamicDecay ->
/// DynamicDecay.GetRandomDuration). So NextDecayStage is exact for the NEXT transition
/// and nothing beyond it is known at all. Collapse becomes exact only once the house is
/// already at IDOC, because then the next transition IS the collapse.
/// * Static decay (GetOldDecayLevel) is a pure function of LastRefreshed and DecayPeriod,
/// so collapse is exact at EVERY stage -- there is no randomness to wait out.
///
/// Emitting estimatedCollapse from a dynamic-decay house at, say, Fairly would therefore be
/// publishing a guess as a fact, which on the website's side becomes a dated promise in a
/// player's mail. It is omitted rather than approximated: the website's `required: false`
/// declaration already permits its absence, and an absent field is honest where a wrong
/// date is not.
/// </summary>
private static void AppendDecaySchedule(StringBuilder sb, BaseHouse house, DecayLevel to)
{
// ONE nested object rather than four sibling keys, for the same reason vendor.listing
// nests `location`: the website's visibility projection matches literal JSON keys, so a
// nested group is one admin rule that can hide the whole schedule, where four flat keys
// would be four rules that drift apart.
sb.Append(",\"schedule\":{");
// The stage clock. Only dynamic decay keeps one; static decay leaves it at MinValue.
bool dynamic = DynamicDecay.Enabled;
var next = house.NextDecayStage;
sb.Append("\"dynamicDecay\":").Append(dynamic ? "true" : "false");
if (dynamic && next > DateTime.MinValue)
sb.Str("nextStage", next.ToUniversalTime().ToString("o"));
// Total seconds from a full refresh to collapse. Constant per house type, but it is what
// lets a reader turn lastRefreshed into a percentage without knowing ServUO's tables.
var period = house.DecayPeriod;
if (period > TimeSpan.Zero)
sb.Num("decayPeriodSec", (long)period.TotalSeconds);
DateTime collapse;
bool knowable = true;
if (!dynamic)
collapse = house.LastRefreshed.ToUniversalTime() + period;
else if (to == DecayLevel.IDOC && next > DateTime.MinValue)
collapse = next.ToUniversalTime();
else
{
collapse = DateTime.MinValue;
knowable = false;
}
if (knowable)
sb.Str("estimatedCollapse", collapse.ToString("o"));
sb.Append('}');
}
// ---- economy supply ----
/// <summary>

View File

@@ -9,6 +9,14 @@ git apply --check patches/<name>.patch # dry run
git apply patches/<name>.patch
```
## `tier.json` — adding or changing a patch
A `.patch` file does not say enough on its own. The Runic Gateway installer's patch tier also has to know which patches form **one all-or-nothing unit**, which companion `.cs` may only be copied once that unit has landed, whether the change needs a **core** solution rebuild or just the dynamic script build, and what the operator loses by declining. None of that is derivable from a diff, so it is declared in [`tier.json`](tier.json).
**Adding a patch means adding it there in the same PR.** The release workflow checks the table in both directions — every `.patch` described by exactly one feature, every named patch and companion present, every `target` equal to the file the diff actually edits — so a patch without an entry fails the release rather than shipping a tier that silently never offers it.
`tier.json` is folded into the tarball's `manifest.json` as `patch_tier` and removed from the staged `patches/` directory, so the artifact carries exactly one copy of the table and it is the one the installer reads. Installers older than this key ignore it; an installer newer than the overlay it is deploying falls back to a built-in copy. See `docs/installer/PLAN.md` §2.2 and §7.0.
## Phase 7 — player-vendor sale (a coupled unit)
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §6.

69
patches/tier.json Normal file
View File

@@ -0,0 +1,69 @@
{
"_comment": [
"The patch tier, described for the Runic Gateway installer.",
"",
"A .patch file on its own does not say enough to run the tier safely. The installer",
"additionally has 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 the",
"dynamic script build, and what capability the operator loses by declining. None of",
"that is derivable from the diffs, so it is declared here.",
"",
"This file is the maintainer-facing source of truth. release.yml folds it into",
"manifest.json as `patch_tier` and removes it from the staged patches/ directory, so",
"the tarball carries exactly one copy and it is the one the installer reads",
"(docs/installer/PLAN.md §7.0). CI also asserts that every .patch here is named by",
"exactly one feature and every named patch and companion exists — adding a patch",
"without describing it fails the release rather than shipping a tier that silently",
"ignores it.",
"",
"Older installers ignore `patch_tier` entirely, and an installer newer than the",
"overlay it is deploying falls back to its own built-in copy of this table."
],
"features": [
{
"name": "vendor-sale",
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
"lost": "no vendor.sale events",
"rebuild": "core",
"patches": [
{
"name": "playervendor-sale-eventsink",
"file": "playervendor-sale-eventsink.patch",
"target": "Server/EventSink.cs"
},
{
"name": "playervendor-sale-gump",
"file": "playervendor-sale-gump.patch",
"target": "Scripts/Gumps/PlayerVendorGumps.cs"
}
],
"companions": [
{
"file": "BridgeVendorSale.cs",
"install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs"
}
]
},
{
"name": "moderation-audit",
"summary": "in-game moderation actions ([ban, [kick, [bcast) forwarded to the website as admin.audit",
"lost": "no in-game moderation audit forwarding",
"rebuild": "scripts",
"patches": [
{
"name": "commandlogging-event",
"file": "commandlogging-event.patch",
"target": "Scripts/Commands/Logging.cs"
}
],
"companions": [
{
"file": "BridgeModerationAudit.cs",
"install_to": "Scripts/Custom/Bridge/BridgeModerationAudit.cs"
}
]
}
]
}

View File

@@ -0,0 +1,687 @@
using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
using Server.Guilds;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Gives a BridgeSeeder world presentable names, so a shard standing behind a public
/// screenshot does not read as test data.
///
/// Test scaffolding. Not part of the bridge. Never deployed — see tools/README.md.
///
/// WHY THIS EXISTS
/// ---------------
/// BridgeSeeder builds a world at realistic SCALE, which is what the bridge needed:
/// 50 accounts, 150 characters, 30 houses, 30 vendors, 1,200 listings. It never needed
/// the world to look like anything, so a vendor is "seed vendor" trading as
/// "Seed Shop 810" and a character is "Seed004A". Every one of those names travels the
/// whole bridge — plugin, sidecar, website — and lands on the marketplace, the guild
/// roster and the housing pages, which are exactly the pages a screenshot wants.
///
/// This pass renames what is already there rather than seeding anything new. That
/// matters: the data keeps its provenance. The prices, the listing counts, the decay
/// stages, the fame and the skill sheets are all still whatever BridgeSeeder produced
/// and whatever the shard has done to them since — only the strings a human reads are
/// replaced. Nothing here invents shard state that the game did not produce.
///
/// IDEMPOTENT, AND DETERMINISTIC
/// -----------------------------
/// Names come from fixed tables indexed by the object's own serial, so the same vendor
/// draws the same shop name on every run against the same save — screenshots retaken
/// later still match. A second run is therefore a no-op, and a world half-dressed by an
/// interrupted run finishes cleanly.
///
/// Shop and house names are also re-dressed when they are names THIS pass produced, so
/// a change to the tables or to the hash can be applied to a world that has already been
/// through here once. Character names are not: a person's name is an ordinary string
/// with no closed set to recognise it by, so once dressed it is left alone.
///
/// WHAT IT ALSO DOES, AND WHY EACH IS HERE
/// ---------------------------------------
/// - Walks a few houses into IDOC, in two passes with a wait between them, because the
/// website only records a collapse it watched happen. Decay is a live process: by the
/// time anybody looks the stages have moved on and "Houses in danger" is empty. Empty
/// is a true state and a poor screenshot, so this stages a handful — see PrimeIdoc.
/// - Sets a known password on one seeded account. Logging a character in is the only
/// way to make the online roster non-empty, and it needs a client, and a client needs
/// a password. The seeder gives every account a random GUID nobody kept.
/// - BUILDS GUILDS, which is the one thing here that creates rather than renames. The
/// seeder never made any, so a shard behind these screenshots has an empty guild
/// board and — because the website's Teams are reconciled from that board — no teams
/// either. There is nothing to rename: a guild has to exist before it can be called
/// something. Members are drawn from characters the seeder already made, so the only
/// invention is the association itself.
///
/// A NOTE ON THE GUILD BOARD, FOUND WHILE BUILDING THIS
/// ---------------------------------------------------
/// `BridgeSocial.Signature()` folds name, abbreviation, leader serial, member count and
/// alliance — not member NAMES — and the roster is only re-emitted when the member SET
/// changes. So renaming a guild member never reaches the site: the board keeps the name
/// the member had when the roster was last emitted. Dressing a world that was already
/// published therefore leaves stale rosters behind, and creating the guilds after the
/// rename (as this does) is what avoids it. Raised as a product observation, not fixed
/// here — a rename is rare in a real shard, and the fix belongs in the plugin.
///
/// Flag: `DemoDressOnStart=True` in Config/Bridge.cfg. In game: `[demodress`.
/// </summary>
public static class BridgeDemoDress
{
private const string Prefix = "seed_";
/// <summary>The account whose password is set, so a character can be logged in.</summary>
private const string LoginAccount = "seed_000";
/// <summary>
/// Read from Config/Bridge.cfg (`DemoDressPassword`) so a password never lands in
/// source control. Absent means the account is left alone.
/// </summary>
private static string LoginPassword
{
get { return Config.Get("Bridge.DemoDressPassword", default(string)); }
}
/// <summary>How many condemned houses to put back into the last two decay stages.</summary>
private const int IdocHouses = 4;
/// <summary>
/// How long after boot the second IDOC pass runs. See <see cref="PrimeIdoc"/> —
/// the delay is the whole point, not a politeness.
/// </summary>
private static int IdocDelaySeconds
{
get { return Config.Get("Bridge.DemoDressIdocDelaySeconds", 150); }
}
// ── Name tables ────────────────────────────────────────────────────────────────
//
// Ordinary fantasy given names and English trade-sign nouns. Deliberately dull: the
// point is that a reader's eye passes over them, which is what a real roster does.
private static readonly string[] Given =
{
"Alaric", "Bess", "Corwin", "Dagna", "Edric", "Fenna", "Garrick", "Halle",
"Ivo", "Jessa", "Kellen", "Lira", "Marek", "Nessa", "Orrin", "Perrin",
"Quill", "Rowan", "Sera", "Tamsin", "Ulric", "Vera", "Wendel", "Xanthe",
"Yorick", "Zara", "Bram", "Caitrin", "Doran", "Elspeth"
};
private static readonly string[] Family =
{
"Ashdown", "Bellweather", "Crowe", "Dunmore", "Eastgate", "Fairbourne",
"Grimsby", "Hollowell", "Ironwood", "Larkspur", "Mosswick", "Thornbury"
};
private static readonly string[] ShopFirst =
{
"The Copper", "The Silver", "The Gilded", "The Iron", "The Rusted", "The Amber",
"The Quiet", "The Crooked", "The Old", "The Wandering", "The Salted", "The Ember"
};
private static readonly string[] ShopSecond =
{
"Anvil", "Kettle", "Lantern", "Compass", "Bellows", "Flask", "Ledger",
"Wagon", "Tankard", "Whetstone", "Sextant", "Coffer"
};
private static readonly string[] HouseNames =
{
"Ashwood Cottage", "Bramblegate", "Candlewick House", "Dovecote",
"Eastmarch", "Fernhollow", "Greywater", "Hearthstone",
"Ivyfall", "Kestrel Lodge", "Longmeadow", "Millrace",
"Northrest", "Oakenshaw", "Pinefall", "Quarrystone",
"Riverwatch", "Stonebrook", "Thistledown", "Umberley",
"Vinesend", "Westbarrow", "Yewcross", "Almsgate",
"Brightmoor", "Coldspring", "Duskvale", "Elmshade",
"Foxhollow", "Gravensward"
};
/// <summary>
/// One guild to build, and how many of the seeded characters to put in it.
///
/// Four rather than one, and four of different sizes, because every screen that
/// shows guilds shows a LIST: a board with one row proves nothing about sorting,
/// member counts or the online column. The sizes are the shape a small shard
/// actually has — one large guild, one middling, two small.
/// </summary>
private struct GuildPlan
{
public readonly string Name;
public readonly string Abbr;
public readonly int Size;
public GuildPlan(string name, string abbr, int size)
{
Name = name;
Abbr = abbr;
Size = size;
}
}
private static readonly GuildPlan[] GuildsToBuild =
{
new GuildPlan("The Ashen Compact", "ASH", 14),
new GuildPlan("Hollowell Rangers", "HOL", 9),
new GuildPlan("The Quiet Ledger", "QLG", 6),
new GuildPlan("Wardens of Northrest", "WRD", 4)
};
/// <summary>
/// The first two guilds are allied, because `/uo/guilds` promises "rosters,
/// alliances and who's online" and an alliance column that is empty on every row
/// reads as a feature that does not work.
/// </summary>
private const string AllianceName = "The Northern Compact";
public static void Initialize()
{
CommandSystem.Register("demodress", AccessLevel.Administrator, Dress_OnCommand);
if (Config.Get("Bridge.DemoDressOnStart", false))
EventSink.ServerStarted += () => Run(null, save: true);
}
[Usage("demodress")]
[Description("Renames BridgeSeeder's synthetic world so it is presentable in screenshots.")]
private static void Dress_OnCommand(CommandEventArgs e)
{
Run(e.Mobile, save: false);
}
private static void Report(Mobile to, string text)
{
Console.WriteLine("[BridgeDemoDress] " + text);
if (to != null)
to.SendMessage(text);
}
private static void Run(Mobile to, bool save)
{
try
{
var start = DateTime.UtcNow;
int chars = DressCharacters();
int vendors = DressVendors();
int houses = DressHouses();
// After the rename, never before: the roster the bridge publishes is the one
// that exists when the guild's member set first changes, and that is here.
int guilds = BuildGuilds(to);
bool password = SetLoginPassword(to);
Report(to, String.Format(
"Dressed {0} characters, {1} vendors, {2} house signs; " +
"built {3} guilds; login password {4}. ({5:F1}s)",
chars, vendors, houses, guilds, password ? "set" : "skipped",
(DateTime.UtcNow - start).TotalSeconds));
// IDOC is two steps, and at boot the second one is LATE. See PrimeIdoc.
Report(to, "Primed " + PrimeIdoc() + " houses for decay.");
if (save)
Timer.DelayCall(
TimeSpan.FromSeconds(IdocDelaySeconds),
() => Report(to, "Staged " + StageIdoc() + " houses into IDOC."));
else
Report(to, "Staged " + StageIdoc() + " houses into IDOC.");
if (save)
{
Report(to, "Saving world...");
World.Save();
Report(to, "Save complete.");
}
}
catch (Exception ex)
{
Report(to, "FAILED: " + ex);
}
}
/// <summary>
/// A stable index for a world object, salted so that two names drawn for the SAME
/// object land in unrelated places in their tables.
///
/// Serial is the only identifier that survives a save and is identical on every
/// load, which is what makes the naming reproducible. But serials are dense and
/// sequential, so a weak mix hands neighbouring objects neighbouring names. The
/// first attempt derived the second word from `serial / 5`, which is constant
/// across five consecutive serials — twenty-seven vendors came out as four
/// Flasks, four Lanterns and three Anvils in a row. Salting and re-mixing per
/// draw is what fixes that: each word is an independent hash of the pair.
/// </summary>
private static int Pick(int serial, int salt, int modulus)
{
unchecked
{
uint h = (uint)serial ^ ((uint)salt * 0x9E3779B1u);
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
return (int)(h % (uint)modulus);
}
}
private static string PersonName(int serial)
{
return Given[Pick(serial, 1, Given.Length)] + " " + Family[Pick(serial, 2, Family.Length)];
}
private static string ShopSign(int serial)
{
return ShopFirst[Pick(serial, 3, ShopFirst.Length)] + " " +
ShopSecond[Pick(serial, 4, ShopSecond.Length)];
}
private static bool LooksSeeded(string name, string marker)
{
return name != null && name.StartsWith(marker, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// True when a name is one this pass could have produced.
///
/// Dressing has to be re-runnable in both directions: a first pass renames what the
/// seeder left, and a later pass — after the tables or the hash change — has to be
/// able to rename its own earlier output. A name is recognised by MEMBERSHIP of the
/// closed tables rather than by a marker on the object, because the object is a
/// PlayerVendor whose name is a plain string with nowhere to hide a flag, and a
/// name that is not in the tables was set by a person and is left alone.
/// </summary>
private static bool IsOurs(string name, string[] first, string[] second)
{
if (String.IsNullOrEmpty(name))
return false;
foreach (var a in first)
{
if (!name.StartsWith(a + " ", StringComparison.Ordinal))
continue;
var rest = name.Substring(a.Length + 1);
foreach (var b in second)
{
if (rest == b)
return true;
}
}
return false;
}
private static bool IsOurHouseName(string name)
{
foreach (var h in HouseNames)
{
if (h == name)
return true;
}
return false;
}
private static int DressCharacters()
{
int n = 0;
foreach (Account acct in Accounts.GetAccounts())
{
if (!acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
continue;
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m == null || !LooksSeeded(m.Name, "Seed"))
continue;
// Offset by the slot so an account's three characters are three people
// rather than three spellings of one.
m.Name = PersonName(m.Serial.Value + i * 101);
n++;
}
}
return n;
}
private static int DressVendors()
{
int n = 0;
if (PlayerVendor.PlayerVendors == null)
return 0;
// PlayerVendors is a live collection; the rename does not add or remove members,
// but copy anyway so an unrelated vendor placement mid-pass cannot invalidate it.
var vendors = new List<PlayerVendor>(PlayerVendor.PlayerVendors);
foreach (var vendor in vendors)
{
bool touched = false;
// "Bridge Test Shop" is not the seeder's — it is left over from a hand-run
// smoke test — and it reaches the marketplace exactly like the rest.
if (LooksSeeded(vendor.ShopName, "Seed Shop") ||
LooksSeeded(vendor.ShopName, "Bridge Test") ||
IsOurs(vendor.ShopName, ShopFirst, ShopSecond))
{
var sign = ShopSign(vendor.Serial.Value);
if (sign != vendor.ShopName)
{
vendor.ShopName = sign;
touched = true;
}
}
if (LooksSeeded(vendor.Name, "seed vendor"))
{
vendor.Name = PersonName(vendor.Serial.Value + 7919);
touched = true;
}
if (touched)
n++;
}
return n;
}
private static int DressHouses()
{
int n = 0;
foreach (var house in BaseHouse.AllHouses)
{
if (house.Sign == null)
continue;
if (!LooksSeeded(house.Sign.Name, "Seed House") && !IsOurHouseName(house.Sign.Name))
continue;
var name = HouseNames[Pick(house.Serial.Value, 5, HouseNames.Length)];
if (name == house.Sign.Name)
continue;
house.Sign.Name = name;
n++;
}
return n;
}
/// <summary>
/// The houses this run picked to walk into IDOC, held between the two passes so the
/// second one moves the same houses the first one primed.
/// </summary>
private static readonly List<BaseHouse> _idocPicks = new List<BaseHouse>();
/// <summary>
/// Picks the houses that will collapse and puts them at a MIDDLE decay stage.
///
/// Only houses that CAN decay are touched — an active owner's AutoRefresh house is
/// left alone, because forcing one into IDOC would be inventing a state the game
/// would never produce and the next refresh would undo it anyway.
///
/// WHY THE STAGING IS TWO PASSES, WITH A WAIT BETWEEN THEM
/// ------------------------------------------------------
/// The website's "Houses in danger" page reads a column the ingest only writes when
/// the plugin reports a house CHANGING decay stage (`house.decay`). The richer
/// `house.update` registry frame carries the stage as well, but the ingest
/// deliberately leaves that column to the transition feed so the two cannot clobber
/// each other. A house that is ALREADY in IDOC when the site connects therefore
/// never appears: the plugin's baseline records IDOC as the starting state and no
/// transition is ever emitted. The first run of this pass hit exactly that — the
/// shard plainly had two collapsing houses and the page said none.
///
/// So: prime now, collapse later. The sweep takes its baseline at the middle stage
/// and then sees a real move to IDOC, which is the event the page is built to show.
/// The underlying asymmetry is a product observation, raised rather than patched
/// from here.
/// </summary>
private static int PrimeIdoc()
{
_idocPicks.Clear();
foreach (var house in BaseHouse.AllHouses)
{
if (_idocPicks.Count >= IdocHouses)
break;
if (house == null || house.Deleted || !house.CanDecay)
continue;
_idocPicks.Add(house);
}
// Most of the world cannot decay at all: a house whose owner's account is active
// is AutoRefresh, and AutoRefresh reports Ageless forever. The seeder condemned
// its houses by backdating the owner's last login, which is the same lever a real
// shard pulls when somebody stops playing — so where there are not enough
// candidates, condemn a few more the same way rather than forcing a stage that
// the next refresh would undo.
if (_idocPicks.Count < IdocHouses)
{
foreach (var house in BaseHouse.AllHouses)
{
if (_idocPicks.Count >= IdocHouses)
break;
if (house == null || house.Deleted || house.CanDecay || house.Owner == null)
continue;
var acct = house.Owner.Account as Account;
// Never the account somebody is about to log in with: an inactive account
// is exactly what this is making, and logging in would undo it anyway.
if (acct == null || acct.Username == LoginAccount)
continue;
acct.LastLogin = DateTime.UtcNow - TimeSpan.FromDays(365);
if (house.CanDecay)
_idocPicks.Add(house);
}
}
foreach (var house in _idocPicks)
{
house.SetDynamicDecay(DecayLevel.Fairly);
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
}
return _idocPicks.Count;
}
/// <summary>Collapses the primed houses. See <see cref="PrimeIdoc"/> for the two-step.</summary>
private static int StageIdoc()
{
int n = 0;
foreach (var house in _idocPicks)
{
if (house == null || house.Deleted)
continue;
// Alternating, so the page shows a stage column doing something rather than
// four identical rows.
house.SetDynamicDecay(n % 2 == 0 ? DecayLevel.IDOC : DecayLevel.Greatly);
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
n++;
}
return n;
}
/// <summary>
/// Builds the guilds in <see cref="GuildsToBuild"/> out of seeded characters that
/// are not in a guild already, and allies the first two.
///
/// Idempotent by NAME: a guild that already exists is left exactly as it is, so a
/// second run adds nobody and a guild somebody has since edited in game is not
/// stamped back to the table. A character already in a guild is never moved, which
/// is what keeps a re-run from shuffling the world between screenshots.
///
/// Ranks are set rather than left at the default, because the roster the site draws
/// shows a rank per member and a page where every row says the same word tells a
/// reader nothing about what ranks are for. Real guilds are mostly members with a
/// couple of officers, so that is what this makes.
/// </summary>
private static int BuildGuilds(Mobile to)
{
var pool = UnguildedSeedCharacters();
var cursor = 0;
var made = 0;
var built = new List<Guild>();
foreach (var plan in GuildsToBuild)
{
var existing = FindGuild(plan.Name);
if (existing != null)
{
built.Add(existing);
continue;
}
if (cursor >= pool.Count)
{
Report(to, "Ran out of unguilded characters — " + plan.Name + " not built.");
break;
}
var leader = pool[cursor++];
var guild = new Guild(leader, plan.Name, plan.Abbr);
for (int i = 1; i < plan.Size && cursor < pool.Count; i++)
{
var member = pool[cursor++];
guild.AddMember(member);
var pm = member as PlayerMobile;
if (pm == null)
continue;
// Two officers per guild, then members. RankDefinition.Ranks is
// { Ronin, Member, Emissary, Warlord, Leader } — Ronin is the default a
// fresh member gets, and a board of Ronins looks like nobody has ever
// touched the guild.
pm.GuildRank =
i == 1 ? RankDefinition.Ranks[3] :
i == 2 ? RankDefinition.Ranks[2] :
RankDefinition.Member;
}
built.Add(guild);
made++;
}
if (built.Count >= 2 && built[0].Alliance == null && built[1].Alliance == null)
{
try
{
var alliance = new AllianceInfo(built[0], AllianceName, built[1]);
alliance.TurnToMember(built[1]);
}
catch (Exception ex)
{
Report(to, "Alliance not formed: " + ex.Message);
}
}
return made;
}
/// <summary>
/// Every seeded character with no guild, in a stable order: account name, then
/// character slot. Stable ordering is what makes the same person lead the same
/// guild on every run against the same save.
/// </summary>
private static List<Mobile> UnguildedSeedCharacters()
{
var accounts = new List<Account>();
foreach (Account acct in Accounts.GetAccounts())
{
if (acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
accounts.Add(acct);
}
accounts.Sort((a, b) => String.CompareOrdinal(a.Username, b.Username));
var chars = new List<Mobile>();
foreach (var acct in accounts)
{
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m == null || m.Deleted || m.Guild != null)
continue;
chars.Add(m);
}
}
return chars;
}
private static Guild FindGuild(string name)
{
foreach (var bg in BaseGuild.List.Values)
{
var g = bg as Guild;
if (g != null && !g.Disbanded && g.Name == name)
return g;
}
return null;
}
/// <summary>
/// Sets a known password on one seeded account so a character can be logged in with
/// a real client. The seeder assigns a random GUID, which nobody kept.
/// </summary>
private static bool SetLoginPassword(Mobile to)
{
var password = LoginPassword;
if (String.IsNullOrEmpty(password))
return false;
var acct = Accounts.GetAccount(LoginAccount) as Account;
if (acct == null)
{
Report(to, "No account " + LoginAccount + " — password not set.");
return false;
}
acct.SetPassword(password);
// The seeder backdates some accounts past InactiveDuration to condemn their
// houses. This one has to be able to log in, so bring it back to the present.
acct.LastLogin = DateTime.UtcNow;
return true;
}
}
}

View File

@@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
using Server.Mobiles;
using Server.Multis;
using Server.Network;
namespace Server.Custom
{
/// <summary>
/// Exercises all three Protocol 5 enrichments on a live shard, without a game client.
///
/// Each of the three needs something a unit test cannot produce, and each needs it for a
/// different reason:
///
/// * house.decay's `schedule` is only interesting ACROSS a transition, and the interesting
/// pair is Greatly -> IDOC: the first must carry no estimatedCollapse (under dynamic
/// decay the remaining stages have not been drawn yet) and the second must carry one.
/// A fixture can assert the mapping; only a real BaseHouse walking a real
/// SetDynamicDecay proves the emitter reads ServUO the way the comment claims.
/// * vendor.listing's `fees` are computed from PlayerVendor state that differs between
/// ServUO's two vendor systems. This reports what the shard actually holds so the
/// emitted frame can be checked against it rather than against an assumption.
/// * account.login.result is the one that could not be built at all before v5, because
/// EventSink.AccountLogin fires BEFORE the verdict exists. Invoking the real sink with a
/// real password (right and wrong) runs the shard's own AccountHandler, which is what
/// sets Accepted/RejectReason -- so this proves the deferred read sees the FINAL verdict
/// and not the constructor's default of true.
///
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
/// In game / at the console: `[p5probe`.
/// </summary>
public static class BridgeProtocol5Probe
{
public static void Initialize()
{
CommandSystem.Register("p5probe", AccessLevel.Administrator, Probe_OnCommand);
if (Config.Get("Bridge.Protocol5ProbeOnStart", false))
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(8.0), () => Run(null));
}
[Usage("p5probe")]
[Description("Drives the three Protocol 5 enrichments so their frames can be observed.")]
private static void Probe_OnCommand(CommandEventArgs e)
{
Run(e.Mobile);
}
private static void Report(Mobile to, string line)
{
Console.WriteLine("[P5Probe] " + line);
if (to != null)
to.SendMessage(line);
}
private static void Run(Mobile to)
{
try
{
ReportVendorFees(to);
DriveLogins(to);
WalkHouseToIdoc(to);
}
catch (Exception ex)
{
Report(to, "threw: " + ex);
}
}
// ---- (a) house.decay schedule ----
/// <summary>
/// Walks one house Greatly, then (after a pause long enough for a decay sweep to run)
/// IDOC. Two frames, and the PAIR is the assertion: no estimatedCollapse on the first,
/// one on the second.
/// </summary>
private static void WalkHouseToIdoc(Mobile to)
{
BaseHouse target = null;
var byType = new Dictionary<string, int>();
foreach (var h in BaseHouse.AllHouses)
{
if (h == null || h.Deleted || h.Owner == null)
continue;
var type = h.DecayType.ToString();
byType[type] = (byType.ContainsKey(type) ? byType[type] : 0) + 1;
// CanDecay is the filter that matters, and getting it wrong is silent. A house
// whose DecayType is AutoRefresh or Ageless -- and the owner's NEWEST house is
// always AutoRefresh -- has a DecayLevel getter that calls ResetDynamicDecay() and
// reports Ageless, so a forced SetDynamicDecay is wiped on the very next read. The
// sweep then sees no change and emits nothing at all, which looks exactly like a
// broken emitter.
if (!h.CanDecay)
continue;
// The current level does NOT disqualify a house. On this rig every decaying house
// is already at IDOC (a seeded world has only a couple of Condemned houses and they
// have long since bottomed out), so the walk starts by putting one BACK to Fairly.
// BridgeDemoDress.PrimeIdoc does the same thing for the same reason.
target = h;
break;
}
foreach (var kv in byType)
Report(to, "houses by DecayType: " + kv.Key + "=" + kv.Value);
if (target == null)
{
Report(to, "no walkable house found (none with CanDecay below IDOC)");
return;
}
Report(to, string.Format(
"walking house 0x{0:X} owner={1} decayType={2} from {3}; dynamicDecay={4}",
target.Serial.Value,
target.Owner == null ? "?" : target.Owner.Name,
target.DecayType,
target.DecayLevel,
DynamicDecay.Enabled));
// Each step needs its own sweep to land, or the sweep sees one net change and emits a
// single frame -- which would collapse the whole point, since the assertion is the
// DIFFERENCE between the Greatly frame and the IDOC one.
var step = TimeSpan.FromSeconds(Math.Max(4, BridgeConfigSeconds()) * 2 + 4);
Step(to, target, DecayLevel.Fairly, TimeSpan.Zero, "reset (no estimatedCollapse expected)");
Step(to, target, DecayLevel.Greatly, step, "expect schedule WITHOUT estimatedCollapse");
Step(to, target, DecayLevel.IDOC, TimeSpan.FromTicks(step.Ticks * 2), "expect schedule WITH estimatedCollapse");
}
private static void Step(Mobile to, BaseHouse house, DecayLevel level, TimeSpan after, string note)
{
Action go = () =>
{
if (house.Deleted)
return;
Report(to, string.Format("house 0x{0:X} -> {1} ({2})", house.Serial.Value, level, note));
house.SetDynamicDecay(level);
};
if (after <= TimeSpan.Zero)
go();
else
Timer.DelayCall(after, () => go());
}
/// <summary>The decay sweep interval, read the same way the bridge reads it.</summary>
private static int BridgeConfigSeconds()
{
return Config.Get("Bridge.DecaySweepSeconds", 60);
}
// ---- (b) vendor.listing fees ----
/// <summary>
/// Prints the fee state of the first few player vendors straight off the PlayerVendor
/// objects, so the emitted `fees` block can be compared against the shard's own numbers
/// rather than against what the emitter believes them to be.
/// </summary>
private static void ReportVendorFees(Mobile to)
{
bool newSystem = BaseHouse.NewVendorSystem;
int shown = 0;
Report(to, "NewVendorSystem=" + newSystem);
foreach (var m in World.Mobiles.Values)
{
var v = m as PlayerVendor;
if (v == null || v.Deleted)
continue;
int charge = newSystem ? v.ChargePerRealWorldDay : v.ChargePerDay;
int funds = newSystem ? v.HoldGold : v.BankAccount + v.HoldGold;
var acct = v.Owner == null ? null : v.Owner.Account as Account;
Report(to, string.Format(
"vendor 0x{0:X} owner={1} acct={2} commission={3} charge={4} funds={5} periods={6} nextPay={7:o}",
v.Serial.Value,
v.Owner == null ? "?" : v.Owner.Name,
acct == null ? "<none>" : acct.Username,
v.IsCommission,
charge,
funds,
charge > 0 ? (funds / charge).ToString() : "n/a",
v.NextPayTime.ToUniversalTime()));
if (++shown >= 3)
break;
}
if (shown == 0)
Report(to, "no player vendors in the world");
}
// ---- (c) account.login.result ----
/// <summary>
/// Fires the real EventSink.AccountLogin twice against a real account: once with a
/// deliberately wrong password and once with the right one.
///
/// The shard's own AccountHandler is what decides, and it decides AFTER our handler has
/// returned. So a correct implementation emits `accepted:false reason:BadPass` for the
/// first and `accepted:true` for the second. An implementation that read the verdict
/// inside the handler would emit `accepted:true` for BOTH -- which is precisely the bug
/// this kind exists to make impossible, and precisely what this probe would show.
///
/// The password is read from config, never compiled in. `Bridge.Protocol5ProbeAccount`
/// and `Bridge.Protocol5ProbePassword`; with no password configured only the failing
/// half runs, which is still the half that matters.
/// </summary>
private static void DriveLogins(Mobile to)
{
var username = Config.Get("Bridge.Protocol5ProbeAccount", (string)null);
if (String.IsNullOrEmpty(username))
{
Report(to, "no Bridge.Protocol5ProbeAccount configured; skipping the login probe");
return;
}
var password = Config.Get("Bridge.Protocol5ProbePassword", (string)null);
// Accounts store a hash, so the rig cannot READ a password to log in with -- it has to
// set one. Same posture as BridgeDemoDress, which does this for the same reason: the
// value comes from config and is never compiled in or logged.
if (!String.IsNullOrEmpty(password))
{
var acct = Accounts.GetAccount(username) as Account;
if (acct == null)
{
Report(to, "account '" + username + "' does not exist; skipping the login probe");
return;
}
acct.SetPassword(password);
Report(to, "set a known password on '" + username + "' for the accepted half");
}
Report(to, "login probe: '" + username + "' with a WRONG password (expect accepted:false)");
Fire(username, "definitely-not-the-password-" + Guid.NewGuid().ToString("N"));
if (String.IsNullOrEmpty(password))
{
Report(to, "no Bridge.Protocol5ProbePassword configured; skipping the accepted half");
return;
}
// Spaced out so the two results are unambiguous in the sidecar's history.
Timer.DelayCall(TimeSpan.FromSeconds(3.0), () =>
{
Report(to, "login probe: '" + username + "' with the RIGHT password (expect accepted:true)");
Fire(username, password);
});
}
private static void Fire(string username, string password)
{
// A null NetState is deliberate and is itself part of the test: the real emitter reads
// the address defensively because AccountLogin_ReplyRej disposes the state before the
// deferred read runs, so it must already survive not having one.
EventSink.InvokeAccountLogin(new AccountLoginEventArgs(null, username, password));
}
}
}

View File

@@ -0,0 +1,486 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Server.Accounting;
using Server.Commands;
using Server.Engines.CityLoyalty;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Drives the shard from OUTSIDE the game, one verb per line in a file the driver polls.
///
/// Every other probe here runs a fixed script at boot or from `[command`, and both are the
/// wrong shape for an acceptance walk: a walk asserts what happened BETWEEN two steps
/// ("one mail, then nothing for a day"), so the steps have to be separated by the observer
/// rather than by a hard-coded delay -- and ServUO's console reads a fixed verb set
/// (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard
/// at all. A file is the one channel a headless shard already has.
///
/// Write one or more lines to `Config/rigcmd.txt`; the driver runs them on the Core thread
/// within a second, prints `[RigDriver]` lines, and TRUNCATES the file so the next write is
/// the next command. Output is console-only: nothing here emits, and everything observed
/// travels the real bridge.
///
/// Verbs:
/// decaylist houses that CAN decay, with owner account and stage
/// decay &lt;serial|any&gt; &lt;stage&gt; force a decay stage (LikeNew|Slightly|Somewhat|
/// Fairly|Greatly|IDOC|Collapsed)
/// vendorlist player vendors, with owner account and next pay time
/// vendorfunds &lt;serial&gt; &lt;gold&gt; set a vendor's held gold (drives periodsRemaining)
/// citylist cities, governors and election phases
/// governor &lt;city&gt; &lt;mobile|none&gt; seat a governor (a mobile serial, or a player's name)
/// election &lt;city&gt; force a new election into its nomination window
/// activate &lt;account&gt; clear an account's inactivity, so its houses stop
/// being Condemned and CAN be refreshed
/// password &lt;account&gt; &lt;pw&gt; set a game account's password (for a login probe)
/// save a world save
/// shutdown a CLEAN shutdown, so the bridge emits server.shutdown
///
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
/// </summary>
public static class BridgeRigDriver
{
private static string _path;
private static DateTime _lastWrite = DateTime.MinValue;
public static void Initialize()
{
if (!Config.Get("Bridge.RigDriverEnabled", false))
return;
_path = Path.Combine(Core.BaseDirectory, "Config", "rigcmd.txt");
CommandSystem.Register("rigdriver", AccessLevel.Administrator, e => Poll());
Console.WriteLine("[RigDriver] watching {0}", _path);
Timer.DelayCall(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(1.0), Poll);
}
// ---- the poll ----
private static void Poll()
{
try
{
if (!File.Exists(_path))
return;
// Written-and-not-finished is a real case: the observer writes with a shell
// redirect while this timer fires. An empty file is nothing to do, and the
// timestamp guard keeps a slow write from being run twice.
var stamp = File.GetLastWriteTimeUtc(_path);
if (stamp <= _lastWrite)
return;
var lines = File.ReadAllLines(_path);
if (lines.Length == 0)
return;
_lastWrite = stamp;
File.WriteAllText(_path, String.Empty);
foreach (var line in lines)
{
var trimmed = (line ?? String.Empty).Trim();
if (trimmed.Length == 0 || trimmed.StartsWith("#"))
continue;
try
{
Run(trimmed);
}
catch (Exception ex)
{
Say("\"" + trimmed + "\" threw: " + ex.Message);
}
}
Say("done");
}
catch (IOException)
{
// The writer still holds it. Next tick.
}
catch (Exception ex)
{
Say("poll threw: " + ex.Message);
}
}
private static void Say(string line)
{
Console.WriteLine("[RigDriver] " + line);
}
private static void Run(string line)
{
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
var verb = parts[0].ToLowerInvariant();
switch (verb)
{
case "decaylist": DecayList(); break;
case "decay": Decay(Arg(parts, 1), Arg(parts, 2)); break;
case "vendorlist": VendorList(); break;
case "vendorfunds": VendorFunds(Arg(parts, 1), Arg(parts, 2)); break;
case "citylist": CityList(); break;
case "governor": Governor(Arg(parts, 1), Arg(parts, 2)); break;
case "election": Election(Arg(parts, 1)); break;
case "activate": Activate(Arg(parts, 1)); break;
case "password": Password(Arg(parts, 1), Arg(parts, 2)); break;
case "save": Say("saving"); Misc.AutoSave.Save(); break;
// A clean shutdown, which is the only kind that EMITS. `Stop-Process` drops the
// socket and the shard says nothing, so a killed shard is indistinguishable from
// a wedged one -- and `uo.server.down` never fires. Core.Kill runs
// EventSink.Shutdown, which is what BridgeBoot listens on.
case "shutdown": Say("shutting down"); Timer.DelayCall(TimeSpan.Zero, () => Core.Kill(false)); break;
default: Say("unknown verb \"" + verb + "\""); break;
}
}
private static string Arg(string[] parts, int i)
{
return i < parts.Length ? parts[i] : null;
}
// ---- houses ----
/// <summary>
/// `CanDecay` is the filter, and getting it wrong is silent: an AutoRefresh house --
/// and the owner's newest house is always AutoRefresh -- has a DecayLevel getter that
/// calls ResetDynamicDecay(), so a forced stage is wiped before the sweep reads it and
/// NOTHING is emitted. That looks exactly like a broken emitter.
/// </summary>
private static IEnumerable<BaseHouse> Decayable()
{
return BaseHouse.AllHouses
.Where(h => h != null && !h.Deleted && h.Owner != null && h.CanDecay);
}
private static void DecayList()
{
foreach (var h in Decayable())
{
var acct = h.Owner.Account == null ? "-" : h.Owner.Account.Username;
Say(String.Format(
"house 0x{0:X} owner={1} acct={2} name=\"{3}\" region={4} type={5} level={6}",
h.Serial.Value, h.Owner.Name, acct, HouseName(h), RegionName(h),
h.DecayType, h.DecayLevel));
}
Say("decayable=" + Decayable().Count());
}
private static string HouseName(BaseHouse h)
{
return h.Sign != null && h.Sign.Name != null ? h.Sign.Name : String.Empty;
}
private static string RegionName(BaseHouse h)
{
var r = Region.Find(h.Location, h.Map);
return r == null ? "-" : r.Name ?? "-";
}
private static void Decay(string which, string stage)
{
DecayLevel level;
if (!TryParseStage(stage, out level))
{
Say("unknown stage \"" + stage + "\"");
return;
}
BaseHouse house = null;
if (String.IsNullOrEmpty(which) || which == "any")
house = Decayable().FirstOrDefault();
else
{
var serial = ParseSerial(which);
house = Decayable().FirstOrDefault(h => h.Serial.Value == serial);
}
if (house == null)
{
Say("no decayable house matched \"" + which + "\"");
return;
}
var from = house.DecayLevel;
// A refresh is what a player does at the sign, and it is NOT SetDynamicDecay: the
// level is derived from LastRefreshed, so a "LikeNew" that only rewrote the dynamic
// stage would be undone by the next read.
if (level == DecayLevel.LikeNew)
house.RefreshDecay();
else
house.SetDynamicDecay(level);
Say(String.Format(
"house 0x{0:X} {1} -> {2} (now {3})",
house.Serial.Value, from, level, house.DecayLevel));
}
private static bool TryParseStage(string s, out DecayLevel level)
{
level = DecayLevel.Ageless;
if (String.IsNullOrEmpty(s))
return false;
foreach (DecayLevel candidate in Enum.GetValues(typeof(DecayLevel)))
{
if (String.Equals(candidate.ToString(), s, StringComparison.OrdinalIgnoreCase))
{
level = candidate;
return true;
}
}
return false;
}
private static int ParseSerial(string s)
{
var text = s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? s.Substring(2) : s;
int parsed;
if (Int32.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed))
return parsed;
return Int32.TryParse(s, out parsed) ? parsed : 0;
}
// ---- vendors ----
private static IEnumerable<PlayerVendor> Vendors()
{
return World.Mobiles.Values.OfType<PlayerVendor>().Where(v => !v.Deleted);
}
private static void VendorList()
{
Say("NewVendorSystem=" + BaseHouse.NewVendorSystem);
foreach (var v in Vendors())
{
var owner = v.Owner;
var acct = owner == null || owner.Account == null ? "-" : owner.Account.Username;
Say(String.Format(
"vendor 0x{0:X} shop=\"{1}\" owner={2} acct={3} hold={4} charge={5} nextPay={6}",
v.Serial.Value, v.ShopName, owner == null ? "-" : owner.Name, acct,
v.HoldGold, v.ChargePerDay, v.NextPayTime.ToUniversalTime().ToString("o")));
}
Say("vendors=" + Vendors().Count());
}
/// <summary>
/// Set a vendor's held gold, which is the only knob that walks it toward dismissal
/// without waiting a pay period -- `NextPayTime` has a private setter, and a period is
/// a real day on the new vendor system and a UO day (~2 real hours) on the old one.
/// The emitter computes `periodsRemaining` as funds / chargePerPeriod, so this moves
/// exactly the field the threshold tracker watches.
/// </summary>
private static void VendorFunds(string which, string gold)
{
var serial = ParseSerial(which ?? String.Empty);
var vendor = Vendors().FirstOrDefault(v => v.Serial.Value == serial);
if (vendor == null)
{
Say("no vendor matched \"" + which + "\"");
return;
}
int funds;
if (!Int32.TryParse(gold, out funds))
{
Say("bad gold \"" + gold + "\"");
return;
}
// Both, because the old vendor system spends BankAccount + HoldGold and the new one
// spends HoldGold alone -- setting one would leave the other paying the charge.
vendor.HoldGold = funds;
vendor.BankAccount = 0;
var charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
Say(String.Format(
"vendor 0x{0:X} hold={1} bank=0 charge={2} periodsRemaining={3}",
vendor.Serial.Value, vendor.HoldGold, charge, charge > 0 ? funds / charge : -1));
}
// ---- accounts ----
/// <summary>
/// Mark an account as having just logged in.
///
/// This is the ONLY way to walk a decaying house back out of danger on a seeded
/// world, and the reason is ServUO's, not the rig's: every house that CAN decay here
/// is `DecayType.Condemned` (the seeder backdates accounts past
/// `Account.InactiveDuration` precisely to make them decay), and
/// `BaseHouse.RefreshDecay()` returns false immediately for a Condemned house. A
/// condemned house is not refreshable by anyone; it is rescued by its OWNER LOGGING
/// IN, which is what this reproduces.
///
/// What the shard then reports depends on how many houses the owner has:
/// `AutoRefresh` (their newest) stops decaying and reads **Ageless**, while an older
/// `ManualRefresh` one is back on the clock and reads **LikeNew**. Both are "out of
/// danger", and a mapper that reads only one of them misses most rescues.
/// </summary>
private static void Activate(string username)
{
var acct = Accounts.GetAccount(username) as Account;
if (acct == null)
{
Say("no account \"" + username + "\"");
return;
}
acct.LastLogin = DateTime.UtcNow;
Say(String.Format("account {0} lastLogin=now inactive={1}", acct.Username, acct.Inactive));
foreach (var h in BaseHouse.AllHouses)
{
if (h == null || h.Deleted || h.Owner == null || h.Owner.Account != acct)
continue;
Say(String.Format(
" house 0x{0:X} type={1} level={2}", h.Serial.Value, h.DecayType, h.DecayLevel));
}
}
/// <summary>
/// Set a game account's password, so a login can be driven over a real socket.
///
/// The socket is not optional for the ACCEPTED half: ServUO's own AccountHandler calls
/// `acct.HasAccess(e.State)` before it ever checks the password, and a null NetState
/// fails that -- so an in-process probe reports "access denied" for a correct password
/// and can never produce `accepted:true`.
/// </summary>
private static void Password(string username, string pw)
{
var acct = Accounts.GetAccount(username) as Account;
if (acct == null)
{
Say("no account \"" + username + "\"");
return;
}
if (String.IsNullOrEmpty(pw))
{
Say("refusing to set an empty password");
return;
}
acct.SetPassword(pw);
Say("account " + acct.Username + " password set");
}
// ---- cities ----
private static void CityList()
{
Say("CityLoyaltySystem.Enabled=" + CityLoyaltySystem.Enabled);
foreach (var city in CityLoyaltySystem.Cities)
{
if (city == null)
continue;
var e = city.Election;
Say(String.Format(
"city={0} governor={1} elect={2} election={3} candidates={4} autoPick={5}",
city.City,
city.Governor == null ? "-" : city.Governor.Name + "/0x" + city.Governor.Serial.Value.ToString("X"),
city.GovernorElect == null ? "-" : city.GovernorElect.Name,
e == null ? "-" : (e.CanNominate() ? "nominate" : e.CanVote() ? "vote" : e.Ongoing ? "pending" : "none"),
e == null || e.Candidates == null ? 0 : e.Candidates.Count,
e == null ? "-" : e.AutoPickGovernor.ToUniversalTime().ToString("o")));
}
}
private static CityLoyaltySystem FindCity(string name)
{
return CityLoyaltySystem.Cities.FirstOrDefault(
c => c != null && String.Equals(c.City.ToString(), name, StringComparison.OrdinalIgnoreCase));
}
private static void Governor(string cityName, string who)
{
var city = FindCity(cityName);
if (city == null)
{
Say("no city \"" + cityName + "\"");
return;
}
if (String.Equals(who, "none", StringComparison.OrdinalIgnoreCase))
{
city.Governor = null;
Say("city=" + city.City + " governor cleared");
return;
}
var mob = FindMobile(who);
if (mob == null)
{
Say("no player matched \"" + who + "\"");
return;
}
city.Governor = mob;
var acct = mob.Account == null ? "-" : mob.Account.Username;
Say(String.Format(
"city={0} governor={1} 0x{2:X} acct={3}",
city.City, mob.Name, mob.Serial.Value, acct));
}
private static Mobile FindMobile(string who)
{
var serial = ParseSerial(who);
if (serial != 0)
{
var bySerial = World.FindMobile(serial);
if (bySerial != null)
return bySerial;
}
return World.Mobiles.Values.OfType<PlayerMobile>()
.FirstOrDefault(m => !m.Deleted && String.Equals(m.Name, who, StringComparison.OrdinalIgnoreCase));
}
private static void Election(string cityName)
{
var city = FindCity(cityName);
if (city == null)
{
Say("no city \"" + cityName + "\"");
return;
}
if (city.Election == null)
{
Say("city=" + city.City + " has no election object");
return;
}
city.Election.StartNewElection();
Say(String.Format(
"city={0} election restarted; autoPick={1} nominate={2}",
city.City,
city.Election.AutoPickGovernor.ToUniversalTime().ToString("o"),
city.Election.CanNominate()));
}
}
}

View File

@@ -13,6 +13,9 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is 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 (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `save`, `shutdown`. Flag: `RigDriverEnabled`. **Sets passwords and mutates the world.** |
| `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** |
## Deploy overwrites Bridge.cfg
@@ -34,6 +37,41 @@ Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.c
In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
## Dressing a seeded world for screenshots
`BridgeSeeder` builds a world at realistic **scale**, which is all the bridge ever needed. It does not
build one that looks like anything: a vendor is `seed vendor` trading as `Seed Shop 810`, a character
is `Seed004A`, a house sign says `Seed House 12`. Those strings travel the whole bridge and land on
the marketplace, the guild roster and the housing pages of the website — fine for a protocol test,
wrong for a screenshot.
`BridgeDemoDress.cs` renames them in place. It seeds nothing: prices, listing counts, decay stages,
fame and skills stay exactly as the seeder left them and as the shard has moved them since, so the
data keeps its provenance and only the strings a human reads change. Names are drawn from fixed
tables by a hash of each object's serial, so a re-run reproduces the same world, and shop and house
names are re-dressed when they are names the pass itself produced — so a change to the tables can be
applied to a world that has already been through here.
```ini
DemoDressOnStart=True
DemoDressPassword=<a password you choose>
```
Boot once, then set `DemoDressOnStart=False`. The password is written to `seed_000` so a real client
can log a character in — the only way to make the website's online roster non-empty — and it is read
from the config rather than compiled in, so it never lands in source control.
**It dresses seeded objects only, which means your own characters keep their names.** That is the
right behaviour for a test shard and a thing to remember before pointing a camera at one: a dev
world usually also holds the accounts, characters, guilds and houses of whoever built it, and those
are real identifiers on a page that may end up public.
**The sidecar's board is cached, so the website lags a rename.** A shop name reaches the site on the
next market sweep, and a sweep advances `MarketSweepBatch` vendors per tick — 27 vendors at the
defaults is two ticks. Allow a couple of minutes before concluding that a rename failed. This cost a
debugging detour once: the shard had the new names all along and the sidecar was still serving the
previous ones.
## Back up `Saves/` first
`[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination.
@@ -70,3 +108,65 @@ Probe, best-of-20 on the Core thread:
```
Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 24× for a fully-kitted character.
## The login half needs a socket, not the sink
`BridgeProtocol5Probe` fires `EventSink.InvokeAccountLogin` directly, which proves the REJECTED
half of `account.login.result` and nothing more. ServUO's own `AccountHandler` calls
`acct.HasAccess(e.State)` *before* it ever checks the password, and a null `NetState` fails that --
so an in-process probe logs `Access denied` for a correct password too, and never produces an
`accepted:true`.
To prove the accepted half, speak the wire. A real socket also gives the frame a real `ip`, which
is one of the fields being tested:
```python
# 4-byte seed, then 0x80 = [0x80][30b username][30b password][1b]
s = socket.create_connection(('127.0.0.1', 2593))
s.sendall(b'\x7f\x00\x00\x01')
s.sendall(b'\x80' + pad(user) + pad(password) + b'\x5d')
```
The shard logs `Invalid password for '<acct>'` or `Valid credentials for '<acct>'`, and the sidecar's
`/history?kind=account.login.result` should show `accepted:false reason:BadPass` and `accepted:true`
respectively. **Both saying `accepted:true` is the bug the kind exists to prevent** -- it means the
verdict was read inside the handler, before it existed.
## Walking a house into IDOC needs a house that can decay
Only a `Condemned` or `ManualRefresh` house decays. An `AutoRefresh` one -- and the owner's NEWEST
house is always `AutoRefresh` -- has a `DecayLevel` getter that calls `ResetDynamicDecay()` and
reports `Ageless`, so a forced `SetDynamicDecay` is wiped on the very next read, the sweep sees no
change, and **nothing is emitted at all**. That looks exactly like a broken emitter. Filter on
`house.CanDecay`, and expect a seeded world to have only one or two houses that qualify -- both
probably already at IDOC, so the walk has to put one back down first.
## A decaying house cannot be refreshed — only its owner coming back rescues it
`BaseHouse.RefreshDecay()` returns `false` immediately when `DecayType == Condemned`, and on a
seeded world **every house that can decay is Condemned** — the seeder backdates 18 accounts past
`Account.InactiveDuration` precisely to make them decay. So `SetDynamicDecay(DecayLevel.LikeNew)`
is wiped by the next read and `RefreshDecay()` does nothing: the sweep sees no change and emits
nothing, which looks exactly like a broken emitter for the second time on the same page.
The rescue is the OWNER LOGGING IN (`BridgeRigDriver`'s `activate <account>` reproduces it by
setting `LastLogin`). What the shard then reports depends on how many houses that owner has:
| the house | `DecayType` after the login | `DecayLevel` reads |
|---|---|---|
| their newest | `AutoRefresh` | **`Ageless`** — off the decay clock entirely |
| any older one | `ManualRefresh` | **`LikeNew`** — back on the clock, at the top |
Both are "out of danger", and the newest-house case is the common one. A consumer that watches only
for `LikeNew` misses most rescues — which is what the engagement mapper did until this walk.
## The console takes a fixed verb set, so `[commands` cannot be typed at a headless shard
`Scripts/Misc/ConsoleCommands.cs` handles `save`, `shutdown`, `restart`, `online`, `kick` and a
handful more; it does **not** dispatch arbitrary `[commands`. Every other probe here therefore runs
either at boot or from an in-game client, and neither works for a walk driven from a script. That is
what `BridgeRigDriver` and its `rigcmd.txt` are for.
Also: only a CLEAN shutdown emits. `Stop-Process` drops the socket and the shard says nothing, so a
killed shard is indistinguishable from a wedged one and `server.shutdown` never reaches the sidecar —
use the driver's `shutdown` verb (`Core.Kill`) when the shutdown itself is what is being tested.