Files
servuo-plugins/tools/scaffolding/README.md
wtclaude 63a7dc4374 feat(bridge): lease deadlines and the participation ledger (Phase 11b)
Protocol 6 amended in place. Two mechanisms behind one new default-off gate,
`Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling
the admin plane is consenting to staff moderation from a screen a human is
looking at, and this is consenting to the world being changed and watched on a
schedule, unattended.

BridgeLeases: a live config value held for a bounded time, with the deadline
honoured on the shard whether or not the website is heard from again, and a
compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is
a free restore.

BridgeParticipation: presence in a declared area plus kill credit inside it,
keyed by character serial, persisted in the world save. The Bridge's first
persisted state, because a run spans hours and an in-memory tally would regress
every attendee's score after one restart. Its snapshot is also the first handler
that DEFERS, which makes `bridge.busy` reachable for the first time.

And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame
with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so
the object carried two `kind` fields and every JSON parser takes the last. The
sidecar answered 200 instead of 425. Renamed `busyKind`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:20 -05:00

192 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Test scaffolding
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
| File | Server path when testing | What |
|------|--------------------------|------|
| `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. |
| `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. |
| `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. |
| `BridgeSweepProbe.cs` | `Scripts/Custom/BridgeSweepProbe.cs` | Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: `SweepProbeOnStart`. Pair with short `*SweepSeconds` overrides. |
| `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`, `configset`, `configread`, `partprobe`, `save`, `shutdown`. Flag: `RigDriverEnabled`. `configset` exists because **`Config.Get` is written by exactly ONE caller in the whole of ServUO 57.4** (`Server/ScriptCompiler.cs`): no in-game command, gump or console verb writes a config key, so on a stock shard a GM cannot drift a configuration lease even deliberately, and a lease's compare-and-set restore would have no way to be proved. `configread` reads a key back through `Config.Get` long after every type initialiser has run, which is how a key that TOOK is told from one that only appeared to. **Sets passwords, writes live config 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.** |
| `BridgeProtocol6Probe.cs` | `Scripts/Custom/BridgeProtocol6Probe.cs` | Spawns a real champion boss through the shard's own `SpawnChampion()`, waits two champ sweeps so the boss is attributed to its altar, registers unequal damage from two seeded players and kills it -- so `champ.boss.killed` can be observed with a real damage table. **The wait is the assertion**: without it the kill still emits, but with no `serial`/`type`/`level`, which is the documented fallback rather than the case being tested. The altar is placed inside a NAMED region on purpose (see below). Flag: `Protocol6ProbeOnStart`. In game: `[p6probe`. **Spawns and kills a champion boss; rig only.** Protocol 6's other half, the idempotency key, needs no probe -- it is driven from outside with two identical POSTs to the sidecar. |
| `BridgeParticipationProbe.cs` | `Scripts/Custom/BridgeParticipationProbe.cs` | Produces real kill credit inside a participation area with no game client: moves two player mobiles to the venue, spawns a creature there, damages it unequally from both and kills it. **Presence is the half it cannot drive** -- the sweep credits players with a live `NetState`, which is the correct test and not one a probe should loosen, so presence accrual needs a real login. In game: `[partprobe <map> <x> <y>`; from a headless rig, through `BridgeRigDriver`'s `partprobe` verb (the two ship together for that reason). **Moves players and spawns and kills a creature; rig only.** |
## Deploy overwrites Bridge.cfg
`deploy.ps1` copies `overlay/Config/Bridge.cfg`, which deliberately omits the scaffolding flags. So **every deploy strips `SeedOnStart` / `EventProbeOnStart` / etc.** Re-append the flag you need after deploying, or the probe silently does nothing on the next boot. (This bit once during Phase 2 testing.)
## Using them
Copy both into `Scripts/Custom/`, then append the flags to `Config/Bridge.cfg`:
```ini
SeedOnStart=True
CensusOnStart=False
ProbeOnStart=False
```
Boot once to seed and save, then set `SeedOnStart=False`. `CensusOnStart` reports what the loaded world actually contains; `ProbeOnStart` prints timings two seconds after `ServerStarted`.
Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.cfg` lacks these keys never runs the scaffolding — even if the `.cs` files are sitting in `Scripts/Custom/`. That is the safety net, not an excuse to ship them.
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.
`[unseedworld` deletes every `seed_*` account, which takes their characters and houses with it — but not necessarily their `PlayerVendor` mobiles. Restoring a backup is the reliable reset.
## What the seeder had to work around
Worth knowing before you trust its output:
- **Plate needs strength.** `BaseArmor.CanEquip` rejects when `from.Str < strReq` (`PlateChest` needs 95). A rejected `EquipItem` leaves the item parentless, and the `Cleanup` pass later deletes it en masse. The seeder gives characters `Str` 100125 and deletes any item whose equip is refused, rather than orphaning it.
- **`VendorItem.Price` is get-only** and `PlayerVendor.SetVendorItem` is private. Dropping an item into a vendor's pack fires `OnSubItemAdded`, which registers the item at the default price of 999. The seeder reaches `SetVendorItem` by reflection to set a real price. Acceptable in throwaway scaffolding; do not do this in the plugin.
- **Houses only decay when condemned.** `BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `ManualRefresh`. An active owner's newest house is `AutoRefresh` and never decays. The seeder backdates 18 accounts past `Account.InactiveDuration` (180 days) to condemn them, then forces stages with `SetDynamicDecay` — not by backdating `LastRefreshed`, because `DynamicDecay.Enabled` is true on this expansion and `GetOldDecayLevel` is unreachable.
## Reference output
Census after a fresh load of the seeded world:
```
[BridgeSeeder] houses=35
[BridgeSeeder] decay Ageless 13, Slightly 3, Somewhat 7, Fairly 3, Greatly 3, IDOC 6
[BridgeSeeder] seeded chars=150 avgEquipped=8.00 naked=0
[BridgeSeeder] playervendors=30
```
Probe, best-of-20 on the Core thread:
```
[BridgeProbe] char.profile 0.069 ms/char 2386 bytes json
[BridgeProbe] vitals sweep 0.223 ms for 150 chars (0.0015 ms/char)
[BridgeProbe] decay sweep 0.007 ms for 35 houses (0.0002 ms/house)
[BridgeProbe] economy sweep 0.001 ms for 51 accounts (supply 110,478,209 gold)
[BridgeProbe] vendor snap 0.343 ms for 30 vendors (1200 listings)
```
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.
## The innermost region has no name
`BridgeProtocol6Probe` places its altar in the middle of **Britain** rather than at a dungeon altar,
and that is not cosmetic. An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its own
spawn area, constructed with a **null name** and with the town region as its parent -- so the most
specific region containing a champion boss is the one region on the map guaranteed to be nameless.
`Mobile.Region` then hides that by falling back to the map's unnamed default region rather than to
null, and the emitted frame simply has no `region`.
Region registration is also **deferred**, which is what makes this survive a first look: a lookup
taken immediately after the altar is placed answers `"Britain"`, and one taken at the kill twenty
seconds later does not. The probe prints the spawn-time read for exactly this reason -- it is the
value that lies, printed next to a frame that disagrees with it.
Emitting from a named region is therefore the test. At a dungeon altar the field is legitimately
absent and the probe proves nothing about it.