Compare commits
22 Commits
v0.2.0
...
b9a27a2de5
| Author | SHA1 | Date | |
|---|---|---|---|
| b9a27a2de5 | |||
| 63a7dc4374 | |||
| d2a12c46e2 | |||
| 7aa7bc8032 | |||
| 827de04471 | |||
| 28b878c850 | |||
| 1dd490b483 | |||
| a144c12c46 | |||
| b818f6cf37 | |||
| badc1702de | |||
| fc4ebf0f5a | |||
| 8cf995f27f | |||
| 158c0596d8 | |||
| 0d9ac7fda8 | |||
| 8195454201 | |||
| 79cc611ee0 | |||
| d57d9aad84 | |||
| 8c6db9f0d5 | |||
| 65562eea40 | |||
| cc4f58317e | |||
| 0eda2d3a97 | |||
| 48b16dc70e |
@@ -176,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.
|
||||
@@ -477,18 +510,73 @@ jobs:
|
||||
# corrupt the Authorization header.
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -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')"
|
||||
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 "${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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
41
README.md
41
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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: 6 — see docs/link/v6.md (idempotency keys on inbound commands, champ.boss.killed).
|
||||
protocol = 6
|
||||
|
||||
# ── ServUO compatibility ─────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
@@ -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).
|
||||
@@ -187,6 +199,46 @@ RequireIpForCreate=true
|
||||
AccountNameMaxLength=16
|
||||
AccountPasswordMaxLength=30
|
||||
|
||||
# ── The event plane (docs/link/v6.md 8) ──────────────────────────────────────
|
||||
#
|
||||
# Leases and the participation ledger: the website holding a live config value for a bounded
|
||||
# time, and this shard counting who took part in a run. Both are driven on a SCHEDULE, by an
|
||||
# event the website starts unattended.
|
||||
#
|
||||
# This is deliberately NOT AdminWriteEnabled. Turning the admin plane on is consenting to
|
||||
# staff moderation driven from a screen a human is looking at; turning this on is consenting
|
||||
# to the website changing and watching your world at four in the morning. One switch could
|
||||
# not honestly express both.
|
||||
#
|
||||
# A lease always carries its own deadline and this shard restores the baseline when it
|
||||
# passes, whether or not the website is ever heard from again -- and a lease is never written
|
||||
# to disk, so a restart puts every leased value back too.
|
||||
EventsEnabled=false
|
||||
|
||||
# The longest this shard will hold a lease, whatever the website asks for. Thirty days.
|
||||
# A longer request is REFUSED rather than shortened: a silently-clamped lease would leave the
|
||||
# two halves disagreeing about when the world comes back.
|
||||
LeaseMaxDurationSec=2592000
|
||||
|
||||
# How long a finished lease stays listed after its deadline restored it, so a teardown that
|
||||
# arrives late still gets a definite verdict instead of finding nothing.
|
||||
LeaseGraceSec=86400
|
||||
|
||||
# How often the participation sweep credits everyone standing in a run's area, and what one
|
||||
# kill inside it is worth against one minute of being there.
|
||||
ParticipationSweepSeconds=30
|
||||
ParticipationKillWeight=5.0
|
||||
|
||||
# Bounds. Runs counted at once, members per run, and the widest area an event may declare.
|
||||
ParticipationMaxRuns=8
|
||||
ParticipationMaxMembers=2000
|
||||
ParticipationMaxRadius=300
|
||||
|
||||
# How long a closed run's tally stays readable before this shard forgets it, and how many
|
||||
# members one snapshot resolves before yielding the Core thread.
|
||||
ParticipationGraceSec=86400
|
||||
ParticipationSnapshotChunk=100
|
||||
|
||||
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||
|
||||
@@ -133,7 +133,42 @@ namespace Server.Custom.Bridge
|
||||
return;
|
||||
}
|
||||
|
||||
handler(obj);
|
||||
// Protocol 6. A command may carry an `idempotencyKey`, and one that does is executed at
|
||||
// most once: a repeat is answered with the original reply rather than re-run. The gate
|
||||
// is here rather than in each handler so it covers every inbound kind — including the
|
||||
// ones a later protocol adds, which is the half that is easy to forget. A command with
|
||||
// no key behaves exactly as it did before, which is what keeps the admin screens (which
|
||||
// send none) unchanged.
|
||||
var idempotencyKey = BridgeJson.GetString(obj, "idempotencyKey");
|
||||
|
||||
if (idempotencyKey == null)
|
||||
{
|
||||
handler(obj);
|
||||
return;
|
||||
}
|
||||
|
||||
if (BridgeIdempotency.Intercept(idempotencyKey, obj))
|
||||
return; // already answered: a replay of the original reply, or bridge.busy
|
||||
|
||||
string error = null;
|
||||
|
||||
try
|
||||
{
|
||||
handler(obj);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallowed deliberately, and only on the keyed path: the key must be closed out
|
||||
// with a definite answer (see BridgeIdempotency's header) rather than left in
|
||||
// flight by an exception unwinding past Finish. Unkeyed commands still throw the
|
||||
// way they always have.
|
||||
error = ex.Message;
|
||||
Console.WriteLine("[Bridge] handler for '{0}' threw: {1}", kind, ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BridgeIdempotency.Finish(idempotencyKey, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnPing(Dictionary<string, object> o)
|
||||
@@ -167,6 +202,8 @@ namespace Server.Custom.Bridge
|
||||
BridgeHousing.Rearm();
|
||||
BridgePoints.Rearm();
|
||||
BridgeMarket.Rearm();
|
||||
BridgeParticipation.Rearm();
|
||||
BridgeLeases.Rearm();
|
||||
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
|
||||
// .cfg wants the change on the site now, not after a shard restart.
|
||||
BridgeRuleset.Emit();
|
||||
@@ -188,6 +225,7 @@ namespace Server.Custom.Bridge
|
||||
BridgeHousing.SweepOnce();
|
||||
BridgePoints.SweepOnce();
|
||||
BridgeMarket.SweepOnce();
|
||||
BridgeParticipation.SweepOnce();
|
||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
@@ -197,6 +235,7 @@ namespace Server.Custom.Bridge
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -215,6 +254,9 @@ namespace Server.Custom.Bridge
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,17 @@ namespace Server.Custom.Bridge
|
||||
// Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
|
||||
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
||||
|
||||
private static long _sweeps, _emitted, _removed;
|
||||
// Protocol 6. Which spawn a live champion belongs to, refreshed by the sweep. The kill itself
|
||||
// is detected by TYPE (see OnCreatureDeath), so this map only ever supplies CONTEXT — which
|
||||
// altar, at what level. A boss that popped and died inside one sweep interval is still
|
||||
// reported; it simply arrives without its spawn.
|
||||
private static readonly Dictionary<Serial, Serial> _bossOf = new Dictionary<Serial, Serial>();
|
||||
|
||||
// How many damage entries a kill reports. Deep enough that a real champion fight's meaningful
|
||||
// contributors are all present, shallow enough that the frame stays one line on the wire.
|
||||
private const int MaxDamagers = 20;
|
||||
|
||||
private static long _sweeps, _emitted, _removed, _bossKills;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
@@ -56,12 +66,24 @@ namespace Server.Custom.Bridge
|
||||
// Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
|
||||
// independently of the shard rebuilds its state within one sweep.
|
||||
BridgeLink.Connected_Core += OnConnected;
|
||||
|
||||
// Protocol 6. A boss defeat was previously only INFERABLE — champ.update going bossUp
|
||||
// true then false, correlated against a mob.killed nearby — and that inference is both
|
||||
// fragile and silent about who did the work. It is a real moment in a shard's week and
|
||||
// an event's phase condition wants to name it, so it becomes a kind of its own.
|
||||
EventSink.CreatureDeath += OnCreatureDeath;
|
||||
|
||||
Rearm();
|
||||
}
|
||||
|
||||
private static void OnConnected()
|
||||
{
|
||||
_last.Clear();
|
||||
|
||||
// _bossOf is deliberately NOT cleared. It is a fact about the world, not a diff cache:
|
||||
// dropping it on a sidecar reconnect would lose the spawn attribution for a boss that is
|
||||
// up right now, and it refills from the sweep only if that boss's record happens to
|
||||
// change again before it dies.
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
@@ -82,8 +104,160 @@ namespace Server.Custom.Bridge
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})",
|
||||
_sweeps, _emitted, _removed, _last.Count);
|
||||
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3} bossKills={4} bossesUp={5})",
|
||||
_sweeps, _emitted, _removed, _last.Count, _bossKills, _bossOf.Count);
|
||||
}
|
||||
|
||||
// ---- champ.boss.killed (Protocol 6) ----
|
||||
|
||||
/// <summary>
|
||||
/// Fires for every creature death on the shard, so the first thing it does is decide
|
||||
/// this is not one. Detection is by TYPE — <c>BaseChampion</c>, which
|
||||
/// <c>BaseSeaChampion</c> derives from, so one check covers both families — with the
|
||||
/// sweep's map used only to name the altar. A boss that popped and died between two
|
||||
/// sweeps is therefore still reported; it simply arrives without a spawn.
|
||||
///
|
||||
/// The damage table is read here and nowhere else, because it exists here and nowhere
|
||||
/// else: ServUO discards a creature's damage entries with the creature, and the shard is
|
||||
/// the only party that ever sees them. Entries are reported whether or not ServUO
|
||||
/// considers them expired — expiry governs LOOTING RIGHTS, and someone who fought the
|
||||
/// first two thirds of a champion fight and then died took part in it regardless of what
|
||||
/// they are owed from the corpse.
|
||||
/// </summary>
|
||||
private static void OnCreatureDeath(CreatureDeathEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var boss = e.Creature;
|
||||
|
||||
if (boss == null)
|
||||
return;
|
||||
|
||||
Serial spawnSerial;
|
||||
bool attributed = _bossOf.TryGetValue(boss.Serial, out spawnSerial);
|
||||
|
||||
if (!(boss is BaseChampion) && !attributed)
|
||||
return;
|
||||
|
||||
_bossOf.Remove(boss.Serial);
|
||||
_bossKills++;
|
||||
|
||||
var spawn = attributed ? World.FindItem(spawnSerial) as ChampionSpawn : null;
|
||||
var name = String.IsNullOrEmpty(boss.Name) ? boss.GetType().Name : boss.Name;
|
||||
|
||||
var sb = BridgeJson.Begin("champ.boss.killed")
|
||||
.Str("category", boss is BaseSeaChampion ? "sea" : "champion")
|
||||
.Ser("bossSerial", boss.Serial)
|
||||
.Str("boss", name)
|
||||
.Str("bossType", boss.GetType().Name)
|
||||
.Str("map", boss.Map == null ? null : boss.Map.Name)
|
||||
.Num("x", boss.X).Num("y", boss.Y).Num("z", boss.Z);
|
||||
|
||||
// The altar's own record, when the kill could be attributed to one. `serial` is the
|
||||
// SPAWN here, matching champ.update, so a consumer can join the two without a rule
|
||||
// about which of two serials on the frame means what.
|
||||
if (spawn != null)
|
||||
{
|
||||
sb.Ser("serial", spawn.Serial)
|
||||
.Str("type", spawn.Type.ToString())
|
||||
.Num("level", spawn.Level);
|
||||
}
|
||||
|
||||
// A named region is what a phase condition can actually match on ("the boss in
|
||||
// Yew"); coordinates are not. Emitted alongside the coordinates rather than
|
||||
// instead, because large stretches of the map belong to no named region at all.
|
||||
//
|
||||
// **The innermost region here is ANONYMOUS, and the rig is the only thing that was
|
||||
// ever going to say so.** A champion killed in the middle of Britain produced a
|
||||
// frame with no region at all, because an active `ChampionSpawn` registers a
|
||||
// `ChampionSpawnRegion` over its own spawn area — constructed with a null name and
|
||||
// with the town region as its PARENT (`ChampionSpawn.cs`, its constructor). So the
|
||||
// most specific region containing a champion boss is, by construction, the one
|
||||
// region on the map guaranteed to have no name.
|
||||
//
|
||||
// It also explains why this looked fine for twenty seconds: region registration is
|
||||
// deferred, so a lookup immediately after the altar is placed still answers
|
||||
// "Britain" and one at the kill does not. A first read at spawn time would have
|
||||
// confirmed a bug into the design.
|
||||
//
|
||||
// Walking to the nearest NAMED ancestor is the general answer rather than a special
|
||||
// case for champions: a house region, a dungeon sub-region and a guarded-zone
|
||||
// overlay are all anonymous children of somewhere a player would name.
|
||||
var region = NamedRegionAt(boss.Location, boss.Map);
|
||||
|
||||
if (region != null)
|
||||
sb.Str("region", region);
|
||||
|
||||
if (e.Killer != null)
|
||||
sb.Actor("killer", e.Killer);
|
||||
|
||||
sb.Damagers("damagers", TopDamagers(boss), MaxDamagers);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A death handler must never be the thing that breaks a death.
|
||||
Console.WriteLine("[Bridge] champ.boss.killed threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player damage against this creature, highest first. Totals are summed per damager
|
||||
/// rather than trusted to be one entry each: ServUO's own registration folds repeat
|
||||
/// damage into an existing entry, but an entry that expired and was re-created leaves
|
||||
/// two, and a table that listed the same player twice would be read as two participants.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// The nearest NAMED region containing a point, walking outward from the most specific
|
||||
/// one, or null when nothing on the way out has a name.
|
||||
///
|
||||
/// Null rather than "" so the caller can leave the field off the frame entirely: a
|
||||
/// consumer reading `region: ""` cannot tell "outdoors, nowhere in particular" from
|
||||
/// "somewhere, but the shard would not say", and only one of those is true here.
|
||||
///
|
||||
/// The map's own default region terminates the walk with its parentless empty name, so
|
||||
/// a point in open countryside answers null without a special case.
|
||||
/// </summary>
|
||||
private static string NamedRegionAt(Point3D p, Map map)
|
||||
{
|
||||
if (map == null)
|
||||
return null;
|
||||
|
||||
for (var region = Region.Find(p, map); region != null; region = region.Parent)
|
||||
{
|
||||
if (!String.IsNullOrEmpty(region.Name))
|
||||
return region.Name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<KeyValuePair<Mobile, int>> TopDamagers(Mobile boss)
|
||||
{
|
||||
var totals = new Dictionary<Mobile, int>();
|
||||
|
||||
var entries = boss.DamageEntries;
|
||||
|
||||
if (entries != null)
|
||||
{
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var de = entries[i];
|
||||
|
||||
if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player)
|
||||
continue;
|
||||
|
||||
int running;
|
||||
totals.TryGetValue(de.Damager, out running);
|
||||
totals[de.Damager] = running + de.DamageGiven;
|
||||
}
|
||||
}
|
||||
|
||||
var ranked = totals.ToList();
|
||||
ranked.Sort((a, b) => b.Value.CompareTo(a.Value));
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
@@ -107,6 +281,15 @@ namespace Server.Custom.Bridge
|
||||
{
|
||||
if (s.Deleted)
|
||||
continue;
|
||||
|
||||
// Protocol 6. Remember which altar a live champion belongs to so its death can
|
||||
// name one. Recorded here rather than looked up at death because the lookup
|
||||
// would be a scan of World.Items on every creature death on the shard.
|
||||
var champion = s.Champion;
|
||||
|
||||
if (champion != null && !champion.Deleted)
|
||||
_bossOf[champion.Serial] = s.Serial;
|
||||
|
||||
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
|
||||
}
|
||||
|
||||
@@ -133,6 +316,19 @@ namespace Server.Custom.Bridge
|
||||
BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
|
||||
_removed++;
|
||||
}
|
||||
|
||||
// A defeated champion's attribution is consumed by OnCreatureDeath, but one deleted
|
||||
// by a GM or lost to a world reload never dies, so the map is swept too. Cheap: it
|
||||
// holds at most one entry per altar with a boss currently up.
|
||||
if (_bossOf.Count > 0)
|
||||
{
|
||||
var vanished = _bossOf.Keys
|
||||
.Where(k => { var m = World.FindMobile(k); return m == null || m.Deleted; })
|
||||
.ToList();
|
||||
|
||||
foreach (var k in vanished)
|
||||
_bossOf.Remove(k);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
@@ -74,6 +78,26 @@ namespace Server.Custom.Bridge
|
||||
public static int AdminReasonMaxLength { get; private set; }
|
||||
public static int AdminBanMaxDurationSec { get; private set; }
|
||||
|
||||
// ---- the event plane (docs/link/v6.md §8, EVENTS_PLAN.md Phase 11b) ----
|
||||
//
|
||||
// **Its own gate, deliberately not AdminWriteEnabled** (org lead, 2026-09-04). Enabling the
|
||||
// admin plane is an operator consenting to staff moderation driven from the website - a
|
||||
// human pressing kick or ban on a screen. A lease and a participation ledger are the
|
||||
// website changing and watching the world on a SCHEDULE, unattended, at four in the
|
||||
// morning. Those are different consents, and one switch cannot express both.
|
||||
public static bool EventsEnabled { get; private set; }
|
||||
|
||||
public static int LeaseMaxDurationSec { get; private set; }
|
||||
public static int LeaseGraceSec { get; private set; }
|
||||
|
||||
public static int ParticipationSweepSeconds { get; private set; }
|
||||
public static double ParticipationKillWeight { get; private set; }
|
||||
public static int ParticipationMaxRuns { get; private set; }
|
||||
public static int ParticipationMaxMembers { get; private set; }
|
||||
public static int ParticipationMaxRadius { get; private set; }
|
||||
public static int ParticipationGraceSec { get; private set; }
|
||||
public static int ParticipationSnapshotChunk { get; private set; }
|
||||
|
||||
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
|
||||
public static SignupMode Signup { get; private set; }
|
||||
public static bool AccountCreateEnabled { get; private set; }
|
||||
@@ -114,6 +138,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;
|
||||
@@ -213,6 +255,61 @@ namespace Server.Custom.Bridge
|
||||
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
||||
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
||||
|
||||
// The event plane. Off until an operator says otherwise - see the field block above for
|
||||
// why this is not AdminWriteEnabled.
|
||||
EventsEnabled = Config.Get("Bridge.EventsEnabled", false);
|
||||
|
||||
// Thirty days, matching core's own MAX_LEASE_MS. This is the shard's INDEPENDENT
|
||||
// ceiling rather than a mirror of it: the website bounds what it will ask for, and a
|
||||
// shard that trusted the asking would have no bound of its own at the one moment it
|
||||
// matters, which is when the website is wrong.
|
||||
LeaseMaxDurationSec = Config.Get("Bridge.LeaseMaxDurationSec", 2592000);
|
||||
if (LeaseMaxDurationSec < 1)
|
||||
LeaseMaxDurationSec = 1;
|
||||
|
||||
// How long a finished lease stays listed after its deadline restored it, so teardown
|
||||
// still gets a definite verdict rather than finding nothing and having to guess.
|
||||
LeaseGraceSec = Config.Get("Bridge.LeaseGraceSec", 86400);
|
||||
if (LeaseGraceSec < 0)
|
||||
LeaseGraceSec = 0;
|
||||
|
||||
ParticipationSweepSeconds = Config.Get("Bridge.ParticipationSweepSeconds", 30);
|
||||
if (ParticipationSweepSeconds < 1)
|
||||
ParticipationSweepSeconds = 1;
|
||||
|
||||
// What one kill inside the area is worth against one minute of standing in it. Both
|
||||
// halves live on the shard because the score IS the shard's number: core stores an
|
||||
// opaque decimal it never interprets, so a weight core could edit would be a weight
|
||||
// nobody could explain from either side.
|
||||
ParticipationKillWeight = Config.Get("Bridge.ParticipationKillWeight", 5.0);
|
||||
if (ParticipationKillWeight < 0.0)
|
||||
ParticipationKillWeight = 0.0;
|
||||
|
||||
ParticipationMaxRuns = Config.Get("Bridge.ParticipationMaxRuns", 8);
|
||||
if (ParticipationMaxRuns < 1)
|
||||
ParticipationMaxRuns = 1;
|
||||
|
||||
ParticipationMaxMembers = Config.Get("Bridge.ParticipationMaxMembers", 2000);
|
||||
if (ParticipationMaxMembers < 1)
|
||||
ParticipationMaxMembers = 1;
|
||||
|
||||
// A radius, not a rectangle, and bounded: an area big enough to cover a facet makes
|
||||
// "took part" meaningless and the sweep expensive in the same stroke.
|
||||
ParticipationMaxRadius = Config.Get("Bridge.ParticipationMaxRadius", 300);
|
||||
if (ParticipationMaxRadius < 1)
|
||||
ParticipationMaxRadius = 1;
|
||||
|
||||
ParticipationGraceSec = Config.Get("Bridge.ParticipationGraceSec", 86400);
|
||||
if (ParticipationGraceSec < 0)
|
||||
ParticipationGraceSec = 0;
|
||||
|
||||
// How many members one snapshot resolves before yielding the Core thread. See
|
||||
// BridgeParticipation: this is what makes the handler DEFER, which is what makes
|
||||
// `bridge.busy` reachable at all.
|
||||
ParticipationSnapshotChunk = Config.Get("Bridge.ParticipationSnapshotChunk", 100);
|
||||
if (ParticipationSnapshotChunk < 1)
|
||||
ParticipationSnapshotChunk = 1;
|
||||
|
||||
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
|
||||
// unrecognized* value falls back to Game (the safest — no website creation), so a
|
||||
// typo can never accidentally open provisioning.
|
||||
@@ -291,9 +388,10 @@ namespace Server.Custom.Bridge
|
||||
public static string Describe()
|
||||
{
|
||||
return String.Format(
|
||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
|
||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11}) events={12}",
|
||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
|
||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled,
|
||||
EventsEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
459
overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
Normal file
459
overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
Normal file
@@ -0,0 +1,459 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Protocol 6. Makes a repeated command safe.
|
||||
///
|
||||
/// The website's event runner retries a step that did not come back, and until now a command
|
||||
/// whose acknowledgement was lost was indistinguishable from one that never applied. There
|
||||
/// was no way to tell the difference from either end, so every world-writing verb had to be
|
||||
/// declared un-retryable — a lost announcement being cheaper than a doubled one. That is not
|
||||
/// a position you can hold once an event can spawn creatures or lease a config value.
|
||||
///
|
||||
/// So a command may now carry an `idempotencyKey`, and the shard promises: **a key is
|
||||
/// executed at most once.** A repeat is never re-run. It is answered with the original
|
||||
/// reply — the same acknowledgement the caller lost — under the repeat's own correlation id.
|
||||
///
|
||||
/// ── Reserve on receipt, not on completion ──────────────────────────────────────────────
|
||||
///
|
||||
/// The key is recorded BEFORE the handler is dispatched, not after it returns. A handler that
|
||||
/// finishes inside its own inbound call can never see a repeat (the Core thread processes one
|
||||
/// line at a time), but a handler that defers — a lease that arms a timer, a spawn that
|
||||
/// waits for a save — completes long after `OnInboundLine` has returned, and that is exactly
|
||||
/// the window a lost ack opens. Reserving late would leave it uncovered.
|
||||
///
|
||||
/// A repeat of a key that is still in flight is answered `bridge.busy`: it runs nothing and
|
||||
/// tells the caller to come back. `bridge.busy` is deliberately not an error — the work is
|
||||
/// happening, and the module classifies it retryable.
|
||||
///
|
||||
/// ── A key that has begun is never released ────────────────────────────────────────────
|
||||
///
|
||||
/// Not even when the handler throws. Releasing it would let a retry re-run a command that may
|
||||
/// have applied half of itself, which is precisely the failure this file exists to prevent.
|
||||
/// A handler that throws stores a `bridge.error` reply instead, so the retry gets a definite
|
||||
/// answer and the step fails once rather than looping.
|
||||
///
|
||||
/// ── The one hole, and why it is loud ──────────────────────────────────────────────────
|
||||
///
|
||||
/// The set is bounded, so an evicted key's repeat WOULD be applied a second time. The bounds
|
||||
/// are chosen to put that far outside reach — an hour, against core's fifteen-minute step
|
||||
/// lease — and an eviction that drops a key which had not yet expired prints a warning naming
|
||||
/// the count. If the guarantee is ever actually breached, an operator sees it here rather
|
||||
/// than discovering a doubled spawn in the world.
|
||||
/// </summary>
|
||||
public static class BridgeIdempotency
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a key is remembered. Core's step lease is 15 minutes and its retry backoff
|
||||
/// is bounded well inside that, so an hour is not a tuned number — it is a margin wide
|
||||
/// enough that expiry should never be the thing that ends a key's life.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan Ttl = TimeSpan.FromHours(1.0);
|
||||
|
||||
/// <summary>
|
||||
/// Hard bound on remembered keys, in the same spirit as BridgeLink's outbound queue cap:
|
||||
/// the Core thread never holds an unbounded collection. At command rates this is days of
|
||||
/// traffic, so reaching it means something is wrong — hence the warning on eviction.
|
||||
/// </summary>
|
||||
private const int Cap = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// The correlation fields the sidecar routes replies on, in the order `rpc.rs` tries
|
||||
/// them. A command carries exactly one; the reply echoes it. A replay must be stamped
|
||||
/// with the REPEAT's value, not the original's — the sidecar's `reqId` is a fresh
|
||||
/// per-process counter, so the retry is waiting on an id the first attempt never used.
|
||||
/// </summary>
|
||||
private static readonly string[] CorrFields = { "reqId", "code", "id" };
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public DateTime Reserved; // when the key was first seen
|
||||
public bool Done; // the handler has finished (successfully or not)
|
||||
public string Reply; // the correlated reply line, verbatim; null if there was none
|
||||
public string Corr; // the correlation value the original reply carries
|
||||
public string CorrField; // which of CorrFields that value sits in
|
||||
public string Kind; // for diagnostics only
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, Entry> _byKey =
|
||||
new Dictionary<string, Entry>(StringComparer.Ordinal);
|
||||
|
||||
// Insertion order, so the cap evicts oldest-first without sorting the dictionary.
|
||||
private static readonly Queue<string> _order = new Queue<string>();
|
||||
|
||||
// ---- capture state; Core thread only, one keyed command at a time ----
|
||||
|
||||
private static Entry _open;
|
||||
private static string _openCorr;
|
||||
private static string _openCorrField;
|
||||
|
||||
private static long _seen, _replayed, _busy, _evicted, _uncorrelated;
|
||||
|
||||
/// <summary>
|
||||
/// True while a keyed command's handler is running. BridgeLink.Emit checks this on every
|
||||
/// emit, so it is a plain bool read rather than anything that costs the sweep path.
|
||||
/// </summary>
|
||||
public static bool Capturing
|
||||
{
|
||||
get { return _open != null; }
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format(
|
||||
"idem(keys={0} seen={1} replayed={2} busy={3} evicted={4} uncorrelated={5})",
|
||||
_byKey.Count, _seen, _replayed, _busy, _evicted, _uncorrelated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by BridgeBoot for every inbound command that carries an `idempotencyKey`,
|
||||
/// before the handler runs.
|
||||
///
|
||||
/// Returns TRUE when the command must not be executed — this call has already emitted the
|
||||
/// answer (a replay of the original reply, or `bridge.busy`). Returns FALSE when the key
|
||||
/// is new: the key is now reserved and capture is open, and the caller MUST pair this
|
||||
/// with <see cref="Finish"/> in a finally.
|
||||
/// </summary>
|
||||
public static bool Intercept(string key, Dictionary<string, object> command)
|
||||
{
|
||||
_seen++;
|
||||
Sweep();
|
||||
|
||||
string corrField = null;
|
||||
string corr = null;
|
||||
|
||||
for (int i = 0; i < CorrFields.Length; i++)
|
||||
{
|
||||
var v = BridgeJson.GetString(command, CorrFields[i]);
|
||||
|
||||
if (v != null)
|
||||
{
|
||||
corrField = CorrFields[i];
|
||||
corr = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Entry prior;
|
||||
|
||||
if (_byKey.TryGetValue(key, out prior))
|
||||
{
|
||||
if (prior.Done)
|
||||
Replay(key, prior, corrField, corr);
|
||||
else
|
||||
Busy(key, prior, corrField, corr);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
Reserved = DateTime.UtcNow,
|
||||
Done = false,
|
||||
Kind = BridgeJson.GetString(command, "kind"),
|
||||
};
|
||||
|
||||
Remember(key, entry);
|
||||
|
||||
_open = entry;
|
||||
_openCorr = corr;
|
||||
_openCorrField = corrField;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by BridgeBoot in a finally, once the handler has returned. Closes capture and
|
||||
/// marks the key done. `error` is non-null when the handler threw.
|
||||
///
|
||||
/// A handler that deferred its work calls <see cref="Hold"/> first; this then leaves the
|
||||
/// key reserved and in flight, and the handler completes it later.
|
||||
/// </summary>
|
||||
public static void Finish(string key, string error)
|
||||
{
|
||||
var entry = _open;
|
||||
|
||||
_open = null;
|
||||
var corr = _openCorr;
|
||||
var corrField = _openCorrField;
|
||||
_openCorr = null;
|
||||
_openCorrField = null;
|
||||
|
||||
if (entry == null || entry.Done)
|
||||
return; // Hold() released it to its own completion, or there was nothing open
|
||||
|
||||
if (error != null)
|
||||
{
|
||||
// The handler threw. The key stays claimed — see the class header — and the stored
|
||||
// answer is the failure, so the retry ends the step instead of re-running a command
|
||||
// that may have applied part of itself.
|
||||
var sb = BridgeJson.Begin("bridge.error");
|
||||
|
||||
if (corrField != null)
|
||||
sb.Str(corrField, corr);
|
||||
|
||||
sb.Str("reason", "handler threw: " + error)
|
||||
.Str("idempotencyKey", key);
|
||||
|
||||
entry.Reply = sb.End();
|
||||
entry.Corr = corr;
|
||||
entry.CorrField = corrField;
|
||||
entry.Done = true;
|
||||
|
||||
Console.WriteLine("[Bridge] idempotency: {0} threw under key {1}; the retry will be answered with the failure",
|
||||
entry.Kind, key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Reply == null)
|
||||
{
|
||||
// Nothing the sidecar could have correlated was emitted. That is a defect in the
|
||||
// handler rather than a state to model: the FIRST attempt has already timed out at
|
||||
// the sidecar, and the retry would time out identically forever. Store a definite
|
||||
// answer so the retry terminates, and say so.
|
||||
_uncorrelated++;
|
||||
|
||||
var sb = BridgeJson.Begin("bridge.error");
|
||||
|
||||
if (corrField != null)
|
||||
sb.Str(corrField, corr);
|
||||
|
||||
sb.Str("reason", "the original command produced no correlated reply")
|
||||
.Str("idempotencyKey", key);
|
||||
|
||||
entry.Reply = sb.End();
|
||||
entry.Corr = corr;
|
||||
entry.CorrField = corrField;
|
||||
|
||||
Console.WriteLine("[Bridge] idempotency: {0} under key {1} emitted no reply the sidecar could correlate",
|
||||
entry.Kind, key);
|
||||
}
|
||||
|
||||
entry.Done = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For a handler that finishes AFTER its inbound call returns. It keeps the key reserved
|
||||
/// (so a repeat is answered `bridge.busy` rather than executed) and takes on the duty of
|
||||
/// calling <see cref="Complete"/> with the reply it eventually emits.
|
||||
///
|
||||
/// 11a built this door and had nothing to walk through it. `participation.snapshot` is
|
||||
/// the first: above a threshold it walks its members in chunks across Core ticks, so it
|
||||
/// completes long after its inbound call returned, and a repeat arriving in between is
|
||||
/// the first `bridge.busy` this shard can actually produce.
|
||||
/// </summary>
|
||||
public static void Hold(string key)
|
||||
{
|
||||
var entry = _open;
|
||||
|
||||
if (entry == null)
|
||||
return;
|
||||
|
||||
// The caller must be holding the key it was dispatched under. A mismatch would leave
|
||||
// the OPEN key marked done by Finish while the named one stayed in flight forever, so
|
||||
// it is refused rather than honoured: capture stays open and the ordinary path runs.
|
||||
if (key == null || !_byKey.ContainsKey(key))
|
||||
{
|
||||
Console.WriteLine("[Bridge] idempotency: Hold called with an unknown key '{0}'; ignoring", key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close capture without marking done: the key stays in flight until Complete.
|
||||
_open = null;
|
||||
_openCorr = null;
|
||||
_openCorrField = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes a key a handler previously held. `replyLine` is the line the handler emits
|
||||
/// as its answer; it is stored so a later repeat replays it.
|
||||
/// </summary>
|
||||
public static void Complete(string key, string replyLine)
|
||||
{
|
||||
Entry entry;
|
||||
|
||||
if (key == null || !_byKey.TryGetValue(key, out entry) || entry.Done)
|
||||
return;
|
||||
|
||||
var parsed = replyLine == null ? null : BridgeJson.Parse(replyLine);
|
||||
|
||||
if (parsed != null)
|
||||
{
|
||||
for (int i = 0; i < CorrFields.Length; i++)
|
||||
{
|
||||
var v = BridgeJson.GetString(parsed, CorrFields[i]);
|
||||
|
||||
if (v != null)
|
||||
{
|
||||
entry.CorrField = CorrFields[i];
|
||||
entry.Corr = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry.Reply = replyLine;
|
||||
entry.Done = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every line a keyed handler emits passes through here. Only the one the sidecar would
|
||||
/// correlate with THIS command is kept: an `admin.audit` broadcast that happens to be
|
||||
/// emitted alongside the reply is a fact about the world and must not be replayed, while
|
||||
/// the reply is an answer to a caller and must be.
|
||||
/// </summary>
|
||||
public static void Observe(string line)
|
||||
{
|
||||
var entry = _open;
|
||||
|
||||
if (entry == null || line == null || _openCorrField == null || _openCorr == null)
|
||||
return;
|
||||
|
||||
// Cheap reject before parsing: the correlation value is a string field on the reply, so
|
||||
// if it does not appear in the line at all this cannot be the reply.
|
||||
if (line.IndexOf(_openCorr, StringComparison.Ordinal) < 0)
|
||||
return;
|
||||
|
||||
var parsed = BridgeJson.Parse(line);
|
||||
|
||||
if (parsed == null)
|
||||
return;
|
||||
|
||||
if (!String.Equals(BridgeJson.GetString(parsed, _openCorrField), _openCorr, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
entry.Reply = line;
|
||||
entry.Corr = _openCorr;
|
||||
entry.CorrField = _openCorrField;
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
private static void Replay(string key, Entry prior, string corrField, string corr)
|
||||
{
|
||||
_replayed++;
|
||||
|
||||
// A repeat with no correlation field is nobody's outstanding call. Re-emitting the
|
||||
// original reply would put a stale answer on the event feed, where a subscriber would
|
||||
// read it as a fresh one, so the repeat is absorbed silently instead.
|
||||
if (corrField == null || corr == null)
|
||||
{
|
||||
Console.WriteLine("[Bridge] idempotency: absorbed an uncorrelated repeat of key {0} ({1})",
|
||||
key, prior.Kind);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stamp the repeat's correlation id over the original's. The sidecar is waiting on the
|
||||
// id IT sent this time; replaying the first attempt's id would leave the call hanging
|
||||
// until the reply timeout, which is the very failure being answered.
|
||||
string line = null;
|
||||
|
||||
if (prior.Reply != null && String.Equals(corrField, prior.CorrField, StringComparison.Ordinal))
|
||||
line = BridgeJson.RewriteStringField(prior.Reply, corrField, corr);
|
||||
|
||||
if (line == null)
|
||||
{
|
||||
// Either the original produced no reply to replay, or the repeat correlates on a
|
||||
// different field than the original did. Nothing sensible can be replayed under an
|
||||
// id the caller is not waiting on, so answer plainly rather than hang the call.
|
||||
BridgeLink.Emit(BridgeJson.Begin("bridge.error")
|
||||
.Str(corrField, corr)
|
||||
.Str("reason", "the original reply for this idempotency key cannot be replayed")
|
||||
.Str("idempotencyKey", key)
|
||||
.End());
|
||||
return;
|
||||
}
|
||||
|
||||
line = BridgeJson.WithTrueFlag(line, "replayed");
|
||||
|
||||
Console.WriteLine("[Bridge] idempotency: replaying the original reply for key {0} ({1})",
|
||||
key, prior.Kind);
|
||||
|
||||
BridgeLink.Emit(line);
|
||||
}
|
||||
|
||||
private static void Busy(string key, Entry prior, string corrField, string corr)
|
||||
{
|
||||
_busy++;
|
||||
|
||||
var sb = BridgeJson.Begin("bridge.busy");
|
||||
|
||||
if (corrField != null)
|
||||
sb.Str(corrField, corr);
|
||||
|
||||
// **`busyKind`, not `kind`, and the name is the whole bug.** `Begin` has already
|
||||
// written this frame's own `kind` as `bridge.busy`, so a second `kind` field made the
|
||||
// object carry two -- and every JSON parser worth the name takes the LAST. The sidecar
|
||||
// matches `bridge.busy` to decide on a 425, read `participation.snapshot` instead, and
|
||||
// answered an ordinary 200 with a body saying nothing had happened.
|
||||
//
|
||||
// It shipped in 11a and could not be seen there: with only synchronous handlers a
|
||||
// repeat can never arrive mid-flight, so this arm was unreachable on a live shard and
|
||||
// the unit test that covers the sidecar's mapping was, correctly, feeding it a frame
|
||||
// built by hand. The first deferring handler produced it on its first collision.
|
||||
sb.Str("idempotencyKey", key)
|
||||
.Str("busyKind", prior.Kind)
|
||||
.Str("reason", "a command with this idempotency key is still in flight");
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Remember(string key, Entry entry)
|
||||
{
|
||||
_byKey[key] = entry;
|
||||
_order.Enqueue(key);
|
||||
|
||||
while (_order.Count > Cap)
|
||||
{
|
||||
var oldest = _order.Dequeue();
|
||||
|
||||
Entry dropped;
|
||||
|
||||
if (!_byKey.TryGetValue(oldest, out dropped))
|
||||
continue;
|
||||
|
||||
_byKey.Remove(oldest);
|
||||
|
||||
// Expired keys leave silently; they are supposed to. A key evicted while still
|
||||
// inside its TTL is the guarantee's one hole, so it never leaves quietly.
|
||||
if (DateTime.UtcNow - dropped.Reserved < Ttl)
|
||||
{
|
||||
_evicted++;
|
||||
Console.WriteLine(
|
||||
"[Bridge] idempotency: evicted key {0} ({1}) while still live — the cap of {2} was reached, so a repeat of it WOULD be applied again ({3} so far)",
|
||||
oldest, dropped.Kind, Cap, _evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops keys past their TTL. Runs on the command path, which is human-rate.</summary>
|
||||
private static void Sweep()
|
||||
{
|
||||
if (_order.Count == 0)
|
||||
return;
|
||||
|
||||
var cutoff = DateTime.UtcNow - Ttl;
|
||||
|
||||
while (_order.Count > 0)
|
||||
{
|
||||
var oldest = _order.Peek();
|
||||
|
||||
Entry entry;
|
||||
|
||||
if (!_byKey.TryGetValue(oldest, out entry))
|
||||
{
|
||||
_order.Dequeue();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Reserved > cutoff)
|
||||
return; // insertion-ordered, so nothing behind this is older
|
||||
|
||||
_order.Dequeue();
|
||||
_byKey.Remove(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,14 +85,200 @@ namespace Server.Custom.Bridge
|
||||
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
|
||||
{
|
||||
sb.Append(",\"").Append(name).Append("\":");
|
||||
WriteActor(sb, m);
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <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 named array of actor objects each carrying a damage total — a boss kill's damage
|
||||
/// table (Protocol 6), and the first actor array whose entries are ranked rather than
|
||||
/// merely listed.
|
||||
///
|
||||
/// The pairs are written in the order given, so the CALLER owns the sort. That is
|
||||
/// deliberate: "the top damagers" is a judgement about a fight, and the shard's job is
|
||||
/// to report the numbers it holds rather than to decide what counts as a contribution.
|
||||
///
|
||||
/// Each entry is the standard actor object plus `damage`, which means it carries `acct`
|
||||
/// and `webId` and is therefore governed by the website's locked-field rule exactly as
|
||||
/// every other actor is. A shard that considers the whole table too revealing hides it
|
||||
/// with one field rule rather than by dropping the kind.
|
||||
/// </summary>
|
||||
public static StringBuilder Damagers(
|
||||
this StringBuilder sb, string name, IList<KeyValuePair<Mobile, int>> pairs, int count)
|
||||
{
|
||||
sb.Append(",\"").Append(name).Append("\":[");
|
||||
|
||||
if (pairs != null)
|
||||
{
|
||||
var end = Math.Min(count, pairs.Count);
|
||||
bool first = true;
|
||||
|
||||
for (int i = 0; i < end; i++)
|
||||
{
|
||||
var m = pairs[i].Key;
|
||||
|
||||
if (m == null)
|
||||
continue;
|
||||
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
|
||||
sb.Append('{');
|
||||
WriteActorFields(sb, m);
|
||||
sb.Append(",\"damage\":").Append(pairs[i].Value);
|
||||
sb.Append('}');
|
||||
|
||||
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 (1062959–1062963) 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, 0–4, 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;
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||
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 +298,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>
|
||||
@@ -123,6 +307,19 @@ namespace Server.Custom.Bridge
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a bare JSON string value, or `null`, with no leading comma and no field name.
|
||||
/// For the hand-built arrays the event plane emits, where <see cref="Escape"/> would
|
||||
/// throw on the null a nullable field is entitled to be.
|
||||
/// </summary>
|
||||
public static void Text(StringBuilder sb, string value)
|
||||
{
|
||||
if (value == null)
|
||||
sb.Append("null");
|
||||
else
|
||||
Escape(sb, value);
|
||||
}
|
||||
|
||||
public static void Escape(StringBuilder sb, string value)
|
||||
{
|
||||
sb.Append('"');
|
||||
@@ -152,6 +349,75 @@ namespace Server.Custom.Bridge
|
||||
sb.Append('"');
|
||||
}
|
||||
|
||||
// ---- rewriting an already-built line (protocol 6) ----
|
||||
//
|
||||
// BridgeIdempotency replays a stored reply under the REPEAT's correlation id. It could
|
||||
// parse the line, edit the dictionary and re-serialize, but a round trip through
|
||||
// JavaScriptSerializer would silently renormalise every number and string in a reply this
|
||||
// file went to the trouble of writing by hand. These two edit the text instead, so a
|
||||
// replayed reply is byte-for-byte the original apart from the field that had to change.
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the value of a top-level STRING field, honouring backslash escapes when
|
||||
/// finding the value's end. Returns null if the field is not present as a string —
|
||||
/// never a half-rewritten line.
|
||||
/// </summary>
|
||||
public static string RewriteStringField(string line, string name, string value)
|
||||
{
|
||||
if (line == null || name == null || value == null)
|
||||
return null;
|
||||
|
||||
// The leading comma is part of the needle: every top-level field is written by Str()
|
||||
// after Begin() has already emitted `t` and `kind`, so a real one always has one. It
|
||||
// is the cheapest thing that stops the search matching the same text inside a value.
|
||||
var needle = ",\"" + name + "\":\"";
|
||||
int at = line.IndexOf(needle, StringComparison.Ordinal);
|
||||
|
||||
if (at < 0)
|
||||
return null;
|
||||
|
||||
int valueStart = at + needle.Length;
|
||||
int i = valueStart;
|
||||
|
||||
while (i < line.Length)
|
||||
{
|
||||
char c = line[i];
|
||||
|
||||
if (c == '\\')
|
||||
{
|
||||
i += 2; // an escape consumes the next character, whatever it is
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
break;
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i >= line.Length)
|
||||
return null; // unterminated: refuse rather than guess
|
||||
|
||||
var sb = new StringBuilder(line.Length + value.Length);
|
||||
sb.Append(line, 0, valueStart - 1); // up to and excluding the opening quote
|
||||
Escape(sb, value);
|
||||
sb.Append(line, i + 1, line.Length - i - 1);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends `"name":true` to an already-closed object. Returns the line unchanged if it
|
||||
/// is not one, so a malformed reply is passed through rather than corrupted further.
|
||||
/// </summary>
|
||||
public static string WithTrueFlag(string line, string name)
|
||||
{
|
||||
if (String.IsNullOrEmpty(line) || line[line.Length - 1] != '}')
|
||||
return line;
|
||||
|
||||
return line.Substring(0, line.Length - 1) + ",\"" + name + "\":true}";
|
||||
}
|
||||
|
||||
// ---- inbound ----
|
||||
|
||||
/// <summary>
|
||||
@@ -234,5 +500,50 @@ namespace Server.Custom.Bridge
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Epoch milliseconds and lease durations do not fit an int, and JavaScriptSerializer
|
||||
/// hands a large JSON number back as a long or a decimal depending on its magnitude, so
|
||||
/// the conversion is done rather than the cast attempted.
|
||||
/// </summary>
|
||||
public static long GetLong(Dictionary<string, object> o, string key, long fallback)
|
||||
{
|
||||
object v;
|
||||
|
||||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||||
return fallback;
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.ToInt64(v, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A lease VALUE arrives as text on the wire whatever its declared type (see
|
||||
/// BridgeLeases), so this exists for the numbers that are genuinely numbers - a radius,
|
||||
/// a weight. InvariantCulture throughout: a shard running under a comma-decimal locale
|
||||
/// must read the same bytes the same way as one that is not.
|
||||
/// </summary>
|
||||
public static double GetDouble(Dictionary<string, object> o, string key, double fallback)
|
||||
{
|
||||
object v;
|
||||
|
||||
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||||
return fallback;
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.ToDouble(v, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
807
overlay/Scripts/Custom/Bridge/BridgeLeases.cs
Normal file
807
overlay/Scripts/Custom/Bridge/BridgeLeases.cs
Normal file
@@ -0,0 +1,807 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Protocol 6, part b. The lease plane: a live configuration value the website may hold for
|
||||
/// a bounded time, and which this shard puts back <b>on its own</b> when the time is up.
|
||||
///
|
||||
/// EVENTS.md calls the lease the primitive underneath the whole event system, and the two
|
||||
/// mechanisms it names are the whole of this file:
|
||||
///
|
||||
/// 1. **Restore is compare-and-set, never a blind write.** Before writing the baseline
|
||||
/// back, the current value must still equal what the event applied. If it does not,
|
||||
/// somebody moved it deliberately: report `drifted`, leave the world alone, and let an
|
||||
/// operator decide. Blindly restoring would silently revert a staff member's change,
|
||||
/// which is the one failure that would make operators distrust the feature.
|
||||
///
|
||||
/// 2. **The expiry lives here, not only in core.** The deadline comes down the wire and
|
||||
/// this shard honours it whether or not the website is ever heard from again. Core
|
||||
/// drives the normal restore; this is the backstop. That inversion is what makes an
|
||||
/// unattended, scheduled world change defensible: the failure mode is a world back at
|
||||
/// baseline early, never a world stuck changed indefinitely.
|
||||
///
|
||||
/// ── What a lease is made of, and why it is memory-only ─────────────────────────────────
|
||||
///
|
||||
/// `Server.Config` is a runtime key-value store. `Config.Set` mutates the in-memory entry
|
||||
/// table; `Config.Load()` is guarded by `_Initialized` and so runs exactly once at boot,
|
||||
/// which is what makes a Set survive every later Get. **Nothing here ever calls
|
||||
/// `Config.Save()`**, and that is a decision rather than an omission (org lead, 2026-09-04):
|
||||
/// a lease that never reaches disk means a shard restart is a *free* restore. It is the
|
||||
/// strongest fail-safe available and it costs nothing, and it is also why `lease.list`
|
||||
/// reports an empty hand after a restart, which is exactly what lets the website's
|
||||
/// reconcile notice that the lease is gone.
|
||||
///
|
||||
/// A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's
|
||||
/// ORIGINAL default marker too, because the entry compares against the value it was loaded
|
||||
/// with. Restoring a key that was `@`-defaulted in a cfg file leaves it `@`-defaulted.
|
||||
///
|
||||
/// ── The catalog is short on purpose, and shorter than EVENTS.md expected ───────────────
|
||||
///
|
||||
/// §D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at
|
||||
/// type initialisation (a lease does nothing) and read live (a lease takes effect at once).
|
||||
/// Measured on ServUO 57.4 the split is not near even: of the 158 non-Bridge call sites in
|
||||
/// `Scripts/`, roughly **eight** are live reads. A lease on any of the others applies
|
||||
/// cleanly and changes nothing, which is the worst failure this feature has.
|
||||
///
|
||||
/// So the catalog below is an allowlist of keys verified by reading the call site, never
|
||||
/// "any config key", and Phase 11b ships exactly one. Phase 12 adds the rest along with the
|
||||
/// boot-time self-check that drops a key from the advertised catalog if it does not take.
|
||||
///
|
||||
/// ── Drift cannot happen by accident on a stock shard ───────────────────────────────────
|
||||
///
|
||||
/// `Config.Set` has exactly ONE caller in the whole of ServUO 57.4
|
||||
/// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). There is no in-game command, gump
|
||||
/// or console path that writes a config key, so on a stock shard a GM cannot drift a config
|
||||
/// lease even deliberately. The compare-and-set below is still correct and still required —
|
||||
/// Phase 12's object-property leases are trivially driftable, and a shard with custom
|
||||
/// scripts may well write config at runtime — but proving the `drifted` path needs the
|
||||
/// scaffolding command in `tools/scaffolding/`, and this paragraph is why.
|
||||
/// </summary>
|
||||
public static class BridgeLeases
|
||||
{
|
||||
private enum LeaseType
|
||||
{
|
||||
Float,
|
||||
Int,
|
||||
Bool,
|
||||
Text
|
||||
}
|
||||
|
||||
/// <summary>One allowlisted key: what it is, what it holds, and what it is worth by default.</summary>
|
||||
private sealed class Catalog
|
||||
{
|
||||
public string Key;
|
||||
public string Label;
|
||||
public LeaseType Type;
|
||||
public double Min;
|
||||
public double Max;
|
||||
|
||||
/// <summary>
|
||||
/// The value the shard's own call site passes as its default, as text.
|
||||
///
|
||||
/// It is carried rather than inferred because `Config.Get` cannot tell "absent" from
|
||||
/// "absent, and here is what the caller would have used" — it just returns whatever
|
||||
/// default it is handed. Reading a key with the WRONG default would make the
|
||||
/// baseline a fiction, and restoring that fiction would leave the shard running on
|
||||
/// a number no source file ever chose.
|
||||
/// </summary>
|
||||
public string Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11b's one proven key (org lead, 2026-09-04).
|
||||
///
|
||||
/// `Scripts/Misc/CharacterCreation.cs` reads it live, inside the per-character creation
|
||||
/// path, and divides by ten to get the per-skill cap. So it takes effect on the next
|
||||
/// character created and is observable without a restart, which is what "proven" has to
|
||||
/// mean here — the failure this catalog exists to prevent is a key that applies cleanly
|
||||
/// and does nothing at all.
|
||||
/// </summary>
|
||||
private static readonly Catalog[] Keys =
|
||||
{
|
||||
new Catalog
|
||||
{
|
||||
Key = "PlayerCaps.SkillCap",
|
||||
Label = "Starting skill cap",
|
||||
Type = LeaseType.Float,
|
||||
Min = 1000.0,
|
||||
Max = 1500.0,
|
||||
Default = "1000",
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>A lease this shard is holding, or has finished holding and not yet been asked about.</summary>
|
||||
private sealed class Held
|
||||
{
|
||||
public string Key;
|
||||
public string Baseline; // canonical text, as read before the lease applied
|
||||
public string Applied; // canonical text, as written
|
||||
public long UntilMs;
|
||||
public string RunId;
|
||||
public Timer Deadline;
|
||||
|
||||
// Set once the deadline has fired. The entry stays listed through the grace window so
|
||||
// that teardown gets a definite verdict rather than finding nothing and having to guess
|
||||
// whether the value came back or was never held.
|
||||
public bool Expired;
|
||||
public bool Restored;
|
||||
public bool Drifted;
|
||||
public string Current; // what was there instead, when drifted
|
||||
public long ExpiredAtMs;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, Held> _held =
|
||||
new Dictionary<string, Held>(StringComparer.Ordinal);
|
||||
|
||||
private static Timer _prune;
|
||||
|
||||
private static long _applied, _released, _drifted, _expired, _refused;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("lease.apply", OnApply);
|
||||
BridgeBoot.RegisterHandler("lease.release", OnRelease);
|
||||
BridgeBoot.RegisterHandler("lease.list", OnList);
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
Rearm();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the prune timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
if (_prune != null)
|
||||
{
|
||||
_prune.Stop();
|
||||
_prune = null;
|
||||
}
|
||||
|
||||
_prune = Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), Prune);
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format(
|
||||
"leases(held={0} applied={1} released={2} drifted={3} expired={4} refused={5})",
|
||||
_held.Count, _applied, _released, _drifted, _expired, _refused);
|
||||
}
|
||||
|
||||
// ---- lease.apply ----
|
||||
|
||||
/// <summary>
|
||||
/// Takes a lease. `holdMs` is authoritative and `untilMs` is carried for display only.
|
||||
///
|
||||
/// That split is deliberate. An absolute deadline computed on the website and honoured
|
||||
/// on the shard is a deadline measured against two clocks; a shard whose clock is ten
|
||||
/// minutes fast would restore a ten-minute lease the instant it took it. A duration is
|
||||
/// immune, and the absolute time is still worth carrying so that `lease.list` and the
|
||||
/// run console can say when the hold ends in terms the operator's own clock agrees with.
|
||||
/// </summary>
|
||||
private static void OnApply(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "apply"))
|
||||
return;
|
||||
|
||||
var entry = Lookup(BridgeJson.GetString(o, "key"));
|
||||
|
||||
if (entry == null)
|
||||
{
|
||||
Err(reqId, "apply", "no lease is offered for key '" + BridgeJson.GetString(o, "key") + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
string canonical;
|
||||
string why;
|
||||
|
||||
if (!Coerce(entry, BridgeJson.GetString(o, "value"), out canonical, out why))
|
||||
{
|
||||
Err(reqId, "apply", why);
|
||||
return;
|
||||
}
|
||||
|
||||
var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
|
||||
var maxMs = (long)BridgeConfig.LeaseMaxDurationSec * 1000L;
|
||||
|
||||
if (holdMs < 1L)
|
||||
{
|
||||
Err(reqId, "apply", "a lease needs a positive holdMs");
|
||||
return;
|
||||
}
|
||||
|
||||
// **Refused, never clamped.** A clamp would silently give the website a shorter lease
|
||||
// than it believes it has, and the website is the half that schedules the restore; the
|
||||
// two would then disagree about when the world comes back. The shard's ceiling exists
|
||||
// precisely for the case where the website is wrong, and being loud about it is the
|
||||
// whole value.
|
||||
if (holdMs > maxMs)
|
||||
{
|
||||
Err(reqId, "apply",
|
||||
String.Format(CultureInfo.InvariantCulture,
|
||||
"this shard holds a lease for at most {0} seconds, and {1} were asked for",
|
||||
BridgeConfig.LeaseMaxDurationSec, holdMs / 1000L));
|
||||
return;
|
||||
}
|
||||
|
||||
Held existing;
|
||||
|
||||
if (_held.TryGetValue(entry.Key, out existing) && !existing.Expired)
|
||||
{
|
||||
Err(reqId, "apply",
|
||||
"'" + entry.Key + "' is already leased" +
|
||||
(existing.RunId == null ? "" : " by run " + existing.RunId));
|
||||
return;
|
||||
}
|
||||
|
||||
// A key whose previous lease expired is re-leasable, and the baseline is read fresh
|
||||
// rather than inherited: whatever is true now is what this lease undertakes to restore.
|
||||
var baseline = Read(entry);
|
||||
|
||||
var held = new Held
|
||||
{
|
||||
Key = entry.Key,
|
||||
Baseline = baseline,
|
||||
Applied = canonical,
|
||||
UntilMs = BridgeJson.GetLong(o, "untilMs", BridgeJson.NowMs() + holdMs),
|
||||
RunId = BridgeJson.GetString(o, "runId"),
|
||||
};
|
||||
|
||||
Write(entry, canonical);
|
||||
|
||||
held.Deadline = Timer.DelayCall(TimeSpan.FromMilliseconds(holdMs), () => OnDeadline(entry.Key));
|
||||
_held[entry.Key] = held;
|
||||
_applied++;
|
||||
|
||||
Console.WriteLine("[Bridge] lease {0}: {1} -> {2} for {3}s (run {4})",
|
||||
entry.Key, baseline, canonical, holdMs / 1000L, held.RunId ?? "-");
|
||||
|
||||
BridgeLink.Emit(BridgeJson.Begin("lease.applied")
|
||||
.Str("key", entry.Key)
|
||||
.Str("label", entry.Label)
|
||||
.Str("baseline", baseline)
|
||||
.Str("applied", canonical)
|
||||
.Num("untilMs", held.UntilMs)
|
||||
.Str("runId", held.RunId)
|
||||
.End());
|
||||
|
||||
var sb = BridgeJson.Begin("lease.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", "apply")
|
||||
.Str("key", entry.Key)
|
||||
.Str("baseline", baseline)
|
||||
.Str("applied", canonical)
|
||||
.Num("untilMs", held.UntilMs);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- lease.release ----
|
||||
|
||||
/// <summary>
|
||||
/// Gives a lease back, compare-and-set.
|
||||
///
|
||||
/// `expected` is what the event applied and `baseline` is what to put back. Both come
|
||||
/// from the website's ledger rather than from this shard's memory, so a release still
|
||||
/// works across a sidecar reconnect — and so that a shard which has forgotten the lease
|
||||
/// entirely (a restart) can answer honestly instead of refusing.
|
||||
/// </summary>
|
||||
private static void OnRelease(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "release"))
|
||||
return;
|
||||
|
||||
var entry = Lookup(BridgeJson.GetString(o, "key"));
|
||||
|
||||
if (entry == null)
|
||||
{
|
||||
Err(reqId, "release", "no lease is offered for key '" + BridgeJson.GetString(o, "key") + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
Held held;
|
||||
_held.TryGetValue(entry.Key, out held);
|
||||
|
||||
// The deadline already dealt with it, and it drifted. That verdict is the one thing
|
||||
// teardown must not lose, so it is held here through the grace window and handed over
|
||||
// now rather than being reported as an ordinary restore.
|
||||
if (held != null && held.Expired && held.Drifted)
|
||||
{
|
||||
Drop(entry.Key);
|
||||
Drifted(reqId, entry.Key, held.Current);
|
||||
return;
|
||||
}
|
||||
|
||||
// Either the deadline restored it, or this shard restarted and never had it. Both are
|
||||
// "the value is back and nothing more is owed", which is a successful release: the
|
||||
// fail-safe firing is not a failure.
|
||||
if (held == null || held.Expired)
|
||||
{
|
||||
Drop(entry.Key);
|
||||
_released++;
|
||||
|
||||
var already = BridgeJson.Begin("lease.ok");
|
||||
if (reqId != null) already.Str("reqId", reqId);
|
||||
already.Str("action", "release")
|
||||
.Str("key", entry.Key)
|
||||
.Bool("released", true)
|
||||
.Bool("alreadyRestored", true)
|
||||
.Str("current", Read(entry));
|
||||
BridgeLink.Emit(already.End());
|
||||
return;
|
||||
}
|
||||
|
||||
var expected = BridgeJson.GetString(o, "expected");
|
||||
var current = Read(entry);
|
||||
|
||||
if (expected != null && !Same(entry, current, expected))
|
||||
{
|
||||
// Somebody moved it. Stop honouring the deadline too: the value is no longer this
|
||||
// lease's to restore, and a timer that fired later would revert the change that was
|
||||
// just reported as somebody else's.
|
||||
Drop(entry.Key);
|
||||
Drifted(reqId, entry.Key, current);
|
||||
return;
|
||||
}
|
||||
|
||||
var baseline = BridgeJson.GetString(o, "baseline");
|
||||
|
||||
if (baseline == null)
|
||||
baseline = held.Baseline;
|
||||
|
||||
string canonical;
|
||||
string why;
|
||||
|
||||
if (!Coerce(entry, baseline, out canonical, out why))
|
||||
{
|
||||
// The website handed back a baseline this key cannot hold. Refusing is right: the
|
||||
// alternative is writing a value nothing has ever verified into a live shard.
|
||||
Err(reqId, "release", "the baseline offered is not valid for this key: " + why);
|
||||
return;
|
||||
}
|
||||
|
||||
Write(entry, canonical);
|
||||
Drop(entry.Key);
|
||||
_released++;
|
||||
|
||||
Console.WriteLine("[Bridge] lease {0}: restored to {1}", entry.Key, canonical);
|
||||
|
||||
var sb = BridgeJson.Begin("lease.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", "release")
|
||||
.Str("key", entry.Key)
|
||||
.Bool("released", true)
|
||||
.Bool("alreadyRestored", false)
|
||||
.Str("current", canonical);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- lease.list ----
|
||||
|
||||
/// <summary>
|
||||
/// Every key this shard offers, with what it is worth right now and what is holding it.
|
||||
///
|
||||
/// It answers two different questions with one frame on purpose. The website's lease
|
||||
/// `read()` needs the current value before it applies anything; its `inForce()` needs to
|
||||
/// know whether the shard still has a record of the hold. Splitting them into two verbs
|
||||
/// would mean two round trips to answer one question about one key.
|
||||
///
|
||||
/// **`held` means "this shard still has a record of the lease", not "the value is still
|
||||
/// overridden".** A lease whose deadline has fired is `held` with `expired: true` until
|
||||
/// teardown collects its verdict, precisely so that reconcile does not report it gone
|
||||
/// and have core write it off as orphaned when what actually happened was the backstop
|
||||
/// working correctly.
|
||||
/// </summary>
|
||||
private static void OnList(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "list"))
|
||||
return;
|
||||
|
||||
var sb = BridgeJson.Begin("lease.list.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
|
||||
sb.Append(",\"leases\":[");
|
||||
|
||||
for (int i = 0; i < Keys.Length; i++)
|
||||
{
|
||||
var entry = Keys[i];
|
||||
|
||||
if (i > 0)
|
||||
sb.Append(',');
|
||||
|
||||
sb.Append("{\"key\":");
|
||||
BridgeJson.Text(sb, entry.Key);
|
||||
sb.Append(",\"label\":");
|
||||
BridgeJson.Text(sb, entry.Label);
|
||||
sb.Append(",\"type\":\"").Append(TypeName(entry.Type)).Append('"');
|
||||
sb.Append(",\"default\":");
|
||||
BridgeJson.Text(sb, entry.Default);
|
||||
sb.Append(",\"current\":");
|
||||
BridgeJson.Text(sb, Read(entry));
|
||||
|
||||
if (entry.Type == LeaseType.Float || entry.Type == LeaseType.Int)
|
||||
{
|
||||
sb.Append(",\"min\":").Append(entry.Min.ToString("R", CultureInfo.InvariantCulture));
|
||||
sb.Append(",\"max\":").Append(entry.Max.ToString("R", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
Held held;
|
||||
|
||||
if (_held.TryGetValue(entry.Key, out held))
|
||||
{
|
||||
sb.Append(",\"held\":true");
|
||||
sb.Append(",\"baseline\":");
|
||||
BridgeJson.Text(sb, held.Baseline);
|
||||
sb.Append(",\"applied\":");
|
||||
BridgeJson.Text(sb, held.Applied);
|
||||
sb.Append(",\"untilMs\":").Append(held.UntilMs);
|
||||
sb.Append(",\"runId\":");
|
||||
BridgeJson.Text(sb, held.RunId);
|
||||
sb.Append(",\"expired\":").Append(held.Expired ? "true" : "false");
|
||||
|
||||
if (held.Expired)
|
||||
{
|
||||
sb.Append(",\"restored\":").Append(held.Restored ? "true" : "false");
|
||||
sb.Append(",\"drifted\":").Append(held.Drifted ? "true" : "false");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(",\"held\":false");
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- the deadline ----
|
||||
|
||||
/// <summary>
|
||||
/// The backstop. Runs on the Core thread whether or not the website still exists, which
|
||||
/// is the entire point of the lease framing: the undo is the default and holding is the
|
||||
/// exception, so nothing has to be alive for the world to come back.
|
||||
/// </summary>
|
||||
private static void OnDeadline(string key)
|
||||
{
|
||||
Held held;
|
||||
|
||||
if (!_held.TryGetValue(key, out held) || held.Expired)
|
||||
return;
|
||||
|
||||
var entry = Lookup(key);
|
||||
|
||||
if (entry == null)
|
||||
return;
|
||||
|
||||
held.Deadline = null;
|
||||
held.Expired = true;
|
||||
held.ExpiredAtMs = BridgeJson.NowMs();
|
||||
_expired++;
|
||||
|
||||
var current = Read(entry);
|
||||
|
||||
if (!Same(entry, current, held.Applied))
|
||||
{
|
||||
held.Drifted = true;
|
||||
held.Current = current;
|
||||
_drifted++;
|
||||
|
||||
Console.WriteLine("[Bridge] lease {0}: deadline passed but the value is now {1}, not {2}; NOT restoring",
|
||||
key, current, held.Applied);
|
||||
}
|
||||
else
|
||||
{
|
||||
Write(entry, held.Baseline);
|
||||
held.Restored = true;
|
||||
|
||||
Console.WriteLine("[Bridge] lease {0}: deadline passed, restored to {1} without being asked",
|
||||
key, held.Baseline);
|
||||
}
|
||||
|
||||
BridgeLink.Emit(BridgeJson.Begin("lease.expired")
|
||||
.Str("key", key)
|
||||
.Str("runId", held.RunId)
|
||||
.Str("baseline", held.Baseline)
|
||||
.Str("applied", held.Applied)
|
||||
.Bool("restored", held.Restored)
|
||||
.Bool("drifted", held.Drifted)
|
||||
.Str("current", held.Drifted ? held.Current : held.Baseline)
|
||||
.End());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops expired entries once the grace window has passed.
|
||||
///
|
||||
/// The window exists so teardown can still collect a verdict; the prune exists because a
|
||||
/// run that is never torn down must not leave a row here for the life of the process.
|
||||
/// Dropping a DRIFTED entry is worth a line in the console: it is the one case where the
|
||||
/// shard is quietly forgetting something an operator was meant to look at.
|
||||
/// </summary>
|
||||
private static void Prune()
|
||||
{
|
||||
if (_held.Count == 0)
|
||||
return;
|
||||
|
||||
var cutoff = BridgeJson.NowMs() - (long)BridgeConfig.LeaseGraceSec * 1000L;
|
||||
List<string> drop = null;
|
||||
|
||||
foreach (var kv in _held)
|
||||
{
|
||||
if (!kv.Value.Expired || kv.Value.ExpiredAtMs > cutoff)
|
||||
continue;
|
||||
|
||||
if (drop == null)
|
||||
drop = new List<string>();
|
||||
|
||||
drop.Add(kv.Key);
|
||||
}
|
||||
|
||||
if (drop == null)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < drop.Count; i++)
|
||||
{
|
||||
Held held;
|
||||
|
||||
if (_held.TryGetValue(drop[i], out held) && held.Drifted)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[Bridge] lease {0}: dropping a DRIFTED record nobody collected; the world is still at {1}",
|
||||
drop[i], held.Current);
|
||||
}
|
||||
|
||||
Drop(drop[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the config plane ----
|
||||
|
||||
private static Catalog Lookup(string key)
|
||||
{
|
||||
if (key == null)
|
||||
return null;
|
||||
|
||||
for (int i = 0; i < Keys.Length; i++)
|
||||
{
|
||||
if (String.Equals(Keys[i].Key, key, StringComparison.Ordinal))
|
||||
return Keys[i];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a key through the same typed accessor the game does, and renders the answer as
|
||||
/// canonical text.
|
||||
///
|
||||
/// Text is the transport for every lease value in both directions, whatever the declared
|
||||
/// type. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a
|
||||
/// parser and two strings to a diff, and a compare-and-set that compared formatted
|
||||
/// numbers would report drift on a value nobody touched. Comparison is done by
|
||||
/// <see cref="Same"/>, on parsed values, for exactly that reason.
|
||||
/// </summary>
|
||||
private static string Read(Catalog entry)
|
||||
{
|
||||
switch (entry.Type)
|
||||
{
|
||||
case LeaseType.Float:
|
||||
return Config.Get(entry.Key, ParseDouble(entry.Default))
|
||||
.ToString("R", CultureInfo.InvariantCulture);
|
||||
|
||||
case LeaseType.Int:
|
||||
return Config.Get(entry.Key, (int)ParseDouble(entry.Default))
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
case LeaseType.Bool:
|
||||
return Config.Get(entry.Key, ParseBool(entry.Default)) ? "true" : "false";
|
||||
|
||||
default:
|
||||
return Config.Get(entry.Key, entry.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Write(Catalog entry, string canonical)
|
||||
{
|
||||
switch (entry.Type)
|
||||
{
|
||||
case LeaseType.Float:
|
||||
Config.Set(entry.Key, ParseDouble(canonical));
|
||||
break;
|
||||
|
||||
case LeaseType.Int:
|
||||
Config.Set(entry.Key, (int)ParseDouble(canonical));
|
||||
break;
|
||||
|
||||
case LeaseType.Bool:
|
||||
Config.Set(entry.Key, ParseBool(canonical));
|
||||
break;
|
||||
|
||||
default:
|
||||
Config.Set(entry.Key, canonical);
|
||||
break;
|
||||
}
|
||||
|
||||
// Deliberately no Config.Save(). See the class header: a lease that never reaches disk
|
||||
// makes a shard restart a free restore.
|
||||
}
|
||||
|
||||
/// <summary>Parses and range-checks a wire value, answering the canonical text for it.</summary>
|
||||
private static bool Coerce(Catalog entry, string raw, out string canonical, out string why)
|
||||
{
|
||||
canonical = null;
|
||||
why = null;
|
||||
|
||||
if (raw == null)
|
||||
{
|
||||
why = "no value was given";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.Type == LeaseType.Bool)
|
||||
{
|
||||
var t = raw.Trim();
|
||||
|
||||
if (String.Equals(t, "true", StringComparison.OrdinalIgnoreCase) || t == "1")
|
||||
canonical = "true";
|
||||
else if (String.Equals(t, "false", StringComparison.OrdinalIgnoreCase) || t == "0")
|
||||
canonical = "false";
|
||||
else
|
||||
{
|
||||
why = "'" + raw + "' is not a yes or no value";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.Type == LeaseType.Text)
|
||||
{
|
||||
canonical = raw;
|
||||
return true;
|
||||
}
|
||||
|
||||
double n;
|
||||
|
||||
if (!Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
|
||||
{
|
||||
why = "'" + raw + "' is not a number";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.Type == LeaseType.Int && n != Math.Floor(n))
|
||||
{
|
||||
why = "'" + raw + "' is not a whole number";
|
||||
return false;
|
||||
}
|
||||
|
||||
// The shard's own range, checked even though core checks the module's declaration
|
||||
// first. The two are the same numbers today and that is not the point: this one is the
|
||||
// one that is true when the website is wrong.
|
||||
if (n < entry.Min || n > entry.Max)
|
||||
{
|
||||
why = String.Format(CultureInfo.InvariantCulture,
|
||||
"{0} accepts {1} to {2}, and '{3}' is outside that",
|
||||
entry.Label, entry.Min, entry.Max, raw);
|
||||
return false;
|
||||
}
|
||||
|
||||
canonical = entry.Type == LeaseType.Int
|
||||
? ((long)n).ToString(CultureInfo.InvariantCulture)
|
||||
: n.ToString("R", CultureInfo.InvariantCulture);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare-and-set's comparison, done on parsed values rather than on text.
|
||||
///
|
||||
/// The two sides are formatted by two different runtimes — one of them a JavaScript
|
||||
/// engine — and `1200` against `1200.0` is a difference only a string comparison can
|
||||
/// see. Reporting that as drift would refuse to restore a value nobody had touched,
|
||||
/// which is the failure mode of a safety check that is too eager: it leaves the world
|
||||
/// changed and blames an innocent operator.
|
||||
/// </summary>
|
||||
private static bool Same(Catalog entry, string a, string b)
|
||||
{
|
||||
if (a == null || b == null)
|
||||
return a == b;
|
||||
|
||||
if (entry.Type == LeaseType.Text)
|
||||
return String.Equals(a, b, StringComparison.Ordinal);
|
||||
|
||||
if (entry.Type == LeaseType.Bool)
|
||||
return ParseBool(a) == ParseBool(b);
|
||||
|
||||
double x, y;
|
||||
|
||||
if (!Double.TryParse(a, NumberStyles.Float, CultureInfo.InvariantCulture, out x) ||
|
||||
!Double.TryParse(b, NumberStyles.Float, CultureInfo.InvariantCulture, out y))
|
||||
return String.Equals(a, b, StringComparison.Ordinal);
|
||||
|
||||
return x == y;
|
||||
}
|
||||
|
||||
private static double ParseDouble(string s)
|
||||
{
|
||||
double n;
|
||||
return Double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out n) ? n : 0.0;
|
||||
}
|
||||
|
||||
private static bool ParseBool(string s)
|
||||
{
|
||||
return String.Equals(s, "true", StringComparison.OrdinalIgnoreCase) || s == "1";
|
||||
}
|
||||
|
||||
private static string TypeName(LeaseType t)
|
||||
{
|
||||
switch (t)
|
||||
{
|
||||
case LeaseType.Float: return "float";
|
||||
case LeaseType.Int: return "int";
|
||||
case LeaseType.Bool: return "bool";
|
||||
default: return "string";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- replies ----
|
||||
|
||||
private static bool Ready(string reqId, string action)
|
||||
{
|
||||
if (!BridgeConfig.EventsEnabled)
|
||||
{
|
||||
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Err(string reqId, string action, string reason)
|
||||
{
|
||||
_refused++;
|
||||
|
||||
var sb = BridgeJson.Begin("lease.error");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action).Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Drifted(string reqId, string key, string current)
|
||||
{
|
||||
_drifted++;
|
||||
|
||||
Console.WriteLine("[Bridge] lease {0}: DRIFTED, the world is at {1} and was left alone", key, current);
|
||||
|
||||
var sb = BridgeJson.Begin("lease.drifted");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("key", key).Str("current", current);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Drop(string key)
|
||||
{
|
||||
Held held;
|
||||
|
||||
if (_held.TryGetValue(key, out held) && held.Deadline != null)
|
||||
held.Deadline.Stop();
|
||||
|
||||
_held.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,17 @@ namespace Server.Custom.Bridge
|
||||
/// </summary>
|
||||
public static void Emit(string line)
|
||||
{
|
||||
if (!_running || line == null)
|
||||
if (line == null)
|
||||
return;
|
||||
|
||||
// Protocol 6. While a keyed command's handler runs — Core thread, one at a time — every
|
||||
// line it emits is offered to the recent-key store so the correlated reply can be
|
||||
// replayed to a retry later. Deliberately BEFORE the `_running` check: a reply the link
|
||||
// was too dead to deliver is precisely the one a retry will come back for.
|
||||
if (BridgeIdempotency.Capturing)
|
||||
BridgeIdempotency.Observe(line);
|
||||
|
||||
if (!_running)
|
||||
return;
|
||||
|
||||
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over
|
||||
|
||||
@@ -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('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
937
overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
Normal file
937
overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
Normal file
@@ -0,0 +1,937 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Protocol 6, part b. The run-scoped participation ledger: who took part in an event, and
|
||||
/// how much.
|
||||
///
|
||||
/// EVENTS.md §G rates participation attribution as the largest remaining piece of new UO
|
||||
/// work, and says why nothing composed out of the existing streams can stand in for it:
|
||||
/// `region.enter` plus `mob.killed` is loosely composable and **not trustworthy enough to
|
||||
/// publish results on**. Nothing scopes a kill or an arrival to a run, nothing separates a
|
||||
/// passer-by from an attendee, and nothing survives a relog. Results and a leaderboard on
|
||||
/// top of that would be a table of confident numbers that were not true.
|
||||
///
|
||||
/// So participation is measured here, where the world is, and reported as one opaque number
|
||||
/// per member. **The plugin computes the score; core stores a decimal it never interprets.**
|
||||
/// That split is what keeps the event engine game-agnostic: "one minute present plus five a
|
||||
/// kill" is a sentence about Ultima Online, and the sentence has to live on the Ultima
|
||||
/// Online side of the seam.
|
||||
///
|
||||
/// ── Keyed by character serial ──────────────────────────────────────────────────────────
|
||||
///
|
||||
/// Which matches `module-uo`'s existing Teams `memberKey` (`teamProvider.model.js`), so one
|
||||
/// module speaks one member vocabulary and a participant can be joined to a roster without a
|
||||
/// translation table. A player who attends on two characters is two members, and that is the
|
||||
/// same answer Teams already gives.
|
||||
///
|
||||
/// ── Persisted in the world save, which is a first ──────────────────────────────────────
|
||||
///
|
||||
/// Nothing in this bridge has ever persisted anything. A ledger has to, because a run spans
|
||||
/// hours and a restart mid-event is an ordinary Tuesday: an in-memory tally would silently
|
||||
/// regress every attendee's score to whatever they earned after the restart. The only ways
|
||||
/// to paper over that from the other side are a high-water rule in core — which must stay
|
||||
/// game-agnostic and cannot have one — or a per-run offset in the module, which is the same
|
||||
/// bug with more moving parts.
|
||||
///
|
||||
/// `Server.Persistence` plus `EventSink.WorldSave` writes a companion file beside the world
|
||||
/// save rather than a persistence ITEM. No world object, no serial, nothing for a GM to find
|
||||
/// and delete by accident, and a wipe of custom items leaves the ledger intact.
|
||||
///
|
||||
/// **The save/load hooks are attached unconditionally**, before the enabled gate is
|
||||
/// consulted. An operator who switches the plane off for an afternoon must not come back to
|
||||
/// a truncated file where a run's tally used to be.
|
||||
///
|
||||
/// ── The first handler that defers ──────────────────────────────────────────────────────
|
||||
///
|
||||
/// `participation.snapshot` resolves every member serial to a mobile and an account, so a
|
||||
/// well-attended run is hundreds of world lookups in one inbound call — exactly the kind of
|
||||
/// work the Core thread must not be handed in one piece. Above
|
||||
/// `Bridge.ParticipationSnapshotChunk` members it walks in chunks across ticks.
|
||||
///
|
||||
/// That makes it the first handler in the bridge to complete AFTER its inbound call returns,
|
||||
/// and therefore the first that can genuinely answer `bridge.busy` — protocol 6 built the
|
||||
/// door in 11a with `BridgeIdempotency.Hold`/`Complete` and had nothing to walk through it.
|
||||
/// </summary>
|
||||
public static class BridgeParticipation
|
||||
{
|
||||
private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Participation.bin");
|
||||
|
||||
private const int SaveVersion = 1;
|
||||
|
||||
/// <summary>One character's part in one run.</summary>
|
||||
private sealed class Member
|
||||
{
|
||||
public int Serial;
|
||||
|
||||
/// <summary>
|
||||
/// Last seen name, kept only so the console and the snapshot can say something
|
||||
/// useful about a character that has since been deleted. The website resolves its
|
||||
/// own names from the serial and never reads this.
|
||||
/// </summary>
|
||||
public string Name;
|
||||
|
||||
/// <summary>
|
||||
/// Accrued presence in SECONDS, not in sample counts.
|
||||
///
|
||||
/// A sample count would have to be multiplied by the sweep interval to mean
|
||||
/// anything, and the interval is a config key an operator may change halfway
|
||||
/// through a five-hour run — which would silently rewrite the first half of the
|
||||
/// tally. Accruing the interval as it is actually used makes history immutable.
|
||||
/// </summary>
|
||||
public long Seconds;
|
||||
|
||||
public int Kills;
|
||||
public long FirstMs;
|
||||
public long LastMs;
|
||||
}
|
||||
|
||||
/// <summary>One run's declared area and its members.</summary>
|
||||
private sealed class Run
|
||||
{
|
||||
public string RunId;
|
||||
public string MapName;
|
||||
public int MapIndex;
|
||||
public int X;
|
||||
public int Y;
|
||||
public int Radius;
|
||||
|
||||
public long OpenedMs;
|
||||
public long UntilMs;
|
||||
public long ClosedMs;
|
||||
public bool Closed;
|
||||
|
||||
/// <summary>
|
||||
/// Frozen at open, for the same reason presence is accrued in seconds: a weight the
|
||||
/// operator retunes mid-run must not retroactively re-score the kills that already
|
||||
/// happened under the old one.
|
||||
/// </summary>
|
||||
public double KillWeight;
|
||||
|
||||
/// <summary>Members the cap turned away. Reported, because a truncated tally that says so is usable and one that does not is a lie.</summary>
|
||||
public long Refused;
|
||||
|
||||
public Dictionary<int, Member> Members = new Dictionary<int, Member>();
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, Run> _runs = new Dictionary<string, Run>(StringComparer.Ordinal);
|
||||
|
||||
private static Timer _timer;
|
||||
|
||||
private static long _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad`
|
||||
/// fires, so this cannot be deferred to Initialize.
|
||||
/// </summary>
|
||||
[CallPriority(900)]
|
||||
public static void Configure()
|
||||
{
|
||||
EventSink.WorldSave += OnWorldSave;
|
||||
EventSink.WorldLoad += OnWorldLoad;
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("participation.open", OnOpen);
|
||||
BridgeBoot.RegisterHandler("participation.snapshot", OnSnapshot);
|
||||
BridgeBoot.RegisterHandler("participation.close", OnClose);
|
||||
|
||||
EventSink.CreatureDeath += OnCreatureDeath;
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
Rearm();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the sweep timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
Stop();
|
||||
|
||||
_timer = Timer.DelayCall(
|
||||
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
|
||||
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
|
||||
Sweep);
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_timer != null)
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
int members = 0;
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
members += run.Members.Count;
|
||||
|
||||
return String.Format(
|
||||
"participation(runs={0} members={1} sweeps={2} opened={3} closed={4} snapshots={5} kills={6} deferred={7} refused={8})",
|
||||
_runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused);
|
||||
}
|
||||
|
||||
// ---- participation.open ----
|
||||
|
||||
/// <summary>
|
||||
/// Declares a run's area and starts counting.
|
||||
///
|
||||
/// The area is a map, a point and a radius (org lead, 2026-09-04). Not a region name:
|
||||
/// protocol 6's own live walk established that the most specific region containing an
|
||||
/// event is routinely anonymous, so a region-named area would be undeclarable for
|
||||
/// exactly the venues events use. Not a rectangle either — an author picks the spot the
|
||||
/// event happens at, not two opposite corners of it.
|
||||
/// </summary>
|
||||
private static void OnOpen(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "open"))
|
||||
return;
|
||||
|
||||
var runId = BridgeJson.GetString(o, "runId");
|
||||
|
||||
if (String.IsNullOrEmpty(runId))
|
||||
{
|
||||
Err(reqId, "open", "a run id is required");
|
||||
return;
|
||||
}
|
||||
|
||||
var mapName = BridgeJson.GetString(o, "map");
|
||||
var map = MapByName(mapName);
|
||||
|
||||
if (map == null)
|
||||
{
|
||||
Err(reqId, "open", "unknown map '" + (mapName ?? "") + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
var radius = BridgeJson.GetInt(o, "radius", 0);
|
||||
|
||||
if (radius < 1 || radius > BridgeConfig.ParticipationMaxRadius)
|
||||
{
|
||||
Err(reqId, "open",
|
||||
String.Format(CultureInfo.InvariantCulture,
|
||||
"radius must be 1 to {0} tiles, and {1} was asked for",
|
||||
BridgeConfig.ParticipationMaxRadius, radius));
|
||||
return;
|
||||
}
|
||||
|
||||
var x = BridgeJson.GetInt(o, "x", -1);
|
||||
var y = BridgeJson.GetInt(o, "y", -1);
|
||||
|
||||
if (x < 0 || y < 0)
|
||||
{
|
||||
Err(reqId, "open", "an area needs an x and a y");
|
||||
return;
|
||||
}
|
||||
|
||||
Run existing;
|
||||
|
||||
if (_runs.TryGetValue(runId, out existing))
|
||||
{
|
||||
// Re-opening the same area is the ordinary consequence of a step being re-authored
|
||||
// or a run being resumed, and answering it as an error would fail a run for doing
|
||||
// nothing. Re-opening a DIFFERENT area is an authoring mistake, and silently
|
||||
// moving the venue mid-run would make the tally describe two places at once.
|
||||
if (existing.MapIndex != map.MapIndex || existing.X != x || existing.Y != y ||
|
||||
existing.Radius != radius)
|
||||
{
|
||||
Err(reqId, "open", "run " + runId + " is already counting a different area");
|
||||
return;
|
||||
}
|
||||
|
||||
existing.Closed = false;
|
||||
Ok(reqId, "open", existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_runs.Count >= BridgeConfig.ParticipationMaxRuns)
|
||||
{
|
||||
Err(reqId, "open",
|
||||
String.Format(CultureInfo.InvariantCulture,
|
||||
"this shard counts at most {0} runs at once", BridgeConfig.ParticipationMaxRuns));
|
||||
return;
|
||||
}
|
||||
|
||||
var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
|
||||
var now = BridgeJson.NowMs();
|
||||
|
||||
var run = new Run
|
||||
{
|
||||
RunId = runId,
|
||||
MapName = map.Name,
|
||||
MapIndex = map.MapIndex,
|
||||
X = x,
|
||||
Y = y,
|
||||
Radius = radius,
|
||||
OpenedMs = now,
|
||||
UntilMs = holdMs > 0L ? now + holdMs : 0L,
|
||||
KillWeight = BridgeConfig.ParticipationKillWeight,
|
||||
};
|
||||
|
||||
_runs[runId] = run;
|
||||
_opened++;
|
||||
|
||||
Console.WriteLine("[Bridge] participation: run {0} counting {1} tiles around {2} ({3}, {4})",
|
||||
runId, radius, map.Name, x, y);
|
||||
|
||||
Ok(reqId, "open", run);
|
||||
}
|
||||
|
||||
// ---- participation.close ----
|
||||
|
||||
/// <summary>
|
||||
/// Stops counting. The tally stays readable through the grace window, because the run
|
||||
/// that closes an event and the step that collects its results are two different steps
|
||||
/// and either can be retried.
|
||||
/// </summary>
|
||||
private static void OnClose(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "close"))
|
||||
return;
|
||||
|
||||
var runId = BridgeJson.GetString(o, "runId");
|
||||
|
||||
Run run;
|
||||
|
||||
if (runId == null || !_runs.TryGetValue(runId, out run))
|
||||
{
|
||||
// Not an error. A close of a run this shard has already forgotten — a restart, a
|
||||
// second teardown attempt — has the same meaning as one it honoured: nothing is
|
||||
// being counted for that run any more.
|
||||
var gone = BridgeJson.Begin("participation.ok");
|
||||
if (reqId != null) gone.Str("reqId", reqId);
|
||||
gone.Str("action", "close").Str("runId", runId).Bool("closed", true).Bool("known", false);
|
||||
BridgeLink.Emit(gone.End());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!run.Closed)
|
||||
{
|
||||
// One last sweep before the books shut, so the people standing there when the event
|
||||
// ended are credited for the interval they were standing there in.
|
||||
SweepRun(run, BridgeConfig.ParticipationSweepSeconds);
|
||||
|
||||
run.Closed = true;
|
||||
run.ClosedMs = BridgeJson.NowMs();
|
||||
_closed++;
|
||||
|
||||
Console.WriteLine("[Bridge] participation: run {0} closed with {1} member(s)",
|
||||
run.RunId, run.Members.Count);
|
||||
}
|
||||
|
||||
Ok(reqId, "close", run);
|
||||
}
|
||||
|
||||
// ---- participation.snapshot ----
|
||||
|
||||
/// <summary>One snapshot in progress. See the class header for why this exists at all.</summary>
|
||||
private sealed class Job
|
||||
{
|
||||
public string ReqId;
|
||||
public string IdempotencyKey;
|
||||
public Run Run;
|
||||
public List<Member> Members;
|
||||
public int Index;
|
||||
public StringBuilder Sb;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this job took the key out of the inbound call's hands.
|
||||
///
|
||||
/// Recorded rather than re-derived from the chunk size, because the chunk size is a
|
||||
/// config key an operator may change between the Hold and the Complete — and a
|
||||
/// Complete that did not happen leaves every retry answered `bridge.busy` until the
|
||||
/// store evicts the key an hour later.
|
||||
/// </summary>
|
||||
public bool Held;
|
||||
}
|
||||
|
||||
private static void OnSnapshot(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
if (!Ready(reqId, "snapshot"))
|
||||
return;
|
||||
|
||||
var runId = BridgeJson.GetString(o, "runId");
|
||||
|
||||
Run run;
|
||||
|
||||
if (runId == null || !_runs.TryGetValue(runId, out run))
|
||||
{
|
||||
Err(reqId, "snapshot", "this shard is not counting run '" + (runId ?? "") + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
// **Copied, not iterated in place.** A sweep or a kill landing between two chunks would
|
||||
// otherwise mutate the dictionary the walk is enumerating, and a snapshot is a
|
||||
// point-in-time answer in any case: the run it describes is the run as it was when the
|
||||
// question was asked.
|
||||
var members = new List<Member>(run.Members.Values);
|
||||
|
||||
var job = new Job
|
||||
{
|
||||
ReqId = reqId,
|
||||
IdempotencyKey = BridgeJson.GetString(o, "idempotencyKey"),
|
||||
Run = run,
|
||||
Members = members,
|
||||
Index = 0,
|
||||
Sb = OpenSnapshot(reqId, run, members.Count),
|
||||
};
|
||||
|
||||
_snapshots++;
|
||||
|
||||
if (members.Count <= BridgeConfig.ParticipationSnapshotChunk)
|
||||
{
|
||||
// Small enough to answer in the inbound call. Deliberately NOT deferred anyway: the
|
||||
// idempotency store captures a reply emitted inside the handler for free, and
|
||||
// holding a key we did not need to hold would put an ordinary command through the
|
||||
// in-flight path for no reason.
|
||||
Step(job);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferring. The key must be HELD before this call returns, or a repeat arriving while
|
||||
// the walk is still running would be executed a second time rather than answered
|
||||
// `bridge.busy` — which is the entire failure protocol 6 exists to prevent, and it is
|
||||
// reachable for the first time right here.
|
||||
if (job.IdempotencyKey != null)
|
||||
{
|
||||
BridgeIdempotency.Hold(job.IdempotencyKey);
|
||||
job.Held = true;
|
||||
}
|
||||
|
||||
_deferred++;
|
||||
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
|
||||
}
|
||||
|
||||
/// <summary>One chunk of a snapshot. Re-arms itself until the walk is done.</summary>
|
||||
private static void Step(Job job)
|
||||
{
|
||||
try
|
||||
{
|
||||
var end = Math.Min(job.Index + BridgeConfig.ParticipationSnapshotChunk, job.Members.Count);
|
||||
|
||||
for (; job.Index < end; job.Index++)
|
||||
WriteMember(job.Sb, job.Run, job.Members[job.Index], job.Index > 0);
|
||||
|
||||
if (job.Index < job.Members.Count)
|
||||
{
|
||||
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
|
||||
return;
|
||||
}
|
||||
|
||||
job.Sb.Append(']');
|
||||
var line = job.Sb.End();
|
||||
|
||||
BridgeLink.Emit(line);
|
||||
|
||||
// Only a HELD key needs completing. An inline snapshot was captured by the
|
||||
// idempotency store on its way through Emit, and completing it twice would replace
|
||||
// a correlated reply with one this method has no correlation information for.
|
||||
if (job.Held)
|
||||
BridgeIdempotency.Complete(job.IdempotencyKey, line);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] participation snapshot threw: {0}", ex.Message);
|
||||
|
||||
// A held key whose walk died must still be closed out, or every retry of this step
|
||||
// gets `bridge.busy` until the store's TTL evicts it an hour later.
|
||||
if (job.Held)
|
||||
{
|
||||
var sb = BridgeJson.Begin("participation.error");
|
||||
if (job.ReqId != null) sb.Str("reqId", job.ReqId);
|
||||
sb.Str("action", "snapshot").Str("reason", "the snapshot failed: " + ex.Message);
|
||||
var line = sb.End();
|
||||
|
||||
BridgeLink.Emit(line);
|
||||
BridgeIdempotency.Complete(job.IdempotencyKey, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static StringBuilder OpenSnapshot(string reqId, Run run, int count)
|
||||
{
|
||||
var sb = BridgeJson.Begin("participation.snapshot.ok");
|
||||
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Str("runId", run.RunId)
|
||||
.Str("map", run.MapName)
|
||||
.Num("x", run.X)
|
||||
.Num("y", run.Y)
|
||||
.Num("radius", run.Radius)
|
||||
.Bool("closed", run.Closed)
|
||||
.Num("openedMs", run.OpenedMs)
|
||||
.Num("killWeight", run.KillWeight)
|
||||
.Num("members", count)
|
||||
.Num("refused", run.Refused);
|
||||
|
||||
sb.Append(",\"participants\":[");
|
||||
return sb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One member, with the score this shard computed and the two components it came from.
|
||||
///
|
||||
/// The components ride along because core stores the score opaquely and could never
|
||||
/// explain it: a results table that can say "forty minutes and three kills" beside a
|
||||
/// number is a table an operator can argue with, and one that shows only the number is
|
||||
/// one they can only believe or not.
|
||||
/// </summary>
|
||||
private static void WriteMember(StringBuilder sb, Run run, Member member, bool comma)
|
||||
{
|
||||
if (comma)
|
||||
sb.Append(',');
|
||||
|
||||
var minutes = member.Seconds / 60.0;
|
||||
var score = minutes + run.KillWeight * member.Kills;
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(((uint)member.Serial).ToString("X")).Append('"');
|
||||
|
||||
// Resolved now rather than at sweep time, and the mobile is looked up whether or not
|
||||
// its owner is online: a character that took part and logged out is still in the world,
|
||||
// so its account — and the linked website user with it — is still readable.
|
||||
var mobile = World.FindMobile((Serial)member.Serial);
|
||||
|
||||
sb.Append(",\"name\":");
|
||||
BridgeJson.Text(sb, mobile != null && !String.IsNullOrEmpty(mobile.Name) ? mobile.Name : member.Name);
|
||||
|
||||
var acct = mobile == null ? null : mobile.Account as Accounting.Account;
|
||||
|
||||
if (acct != null)
|
||||
{
|
||||
sb.Append(",\"acct\":");
|
||||
BridgeJson.Text(sb, acct.Username);
|
||||
|
||||
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||
|
||||
if (webId != null)
|
||||
{
|
||||
sb.Append(",\"webId\":");
|
||||
BridgeJson.Text(sb, webId);
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(",\"seconds\":").Append(member.Seconds);
|
||||
sb.Append(",\"minutes\":").Append(minutes.ToString("F2", CultureInfo.InvariantCulture));
|
||||
sb.Append(",\"kills\":").Append(member.Kills);
|
||||
sb.Append(",\"score\":").Append(score.ToString("F4", CultureInfo.InvariantCulture));
|
||||
sb.Append(",\"firstMs\":").Append(member.FirstMs);
|
||||
sb.Append(",\"lastMs\":").Append(member.LastMs);
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
// ---- counting ----
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
public static void SweepOnce()
|
||||
{
|
||||
Sweep();
|
||||
}
|
||||
|
||||
private static void Sweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
if (_runs.Count == 0)
|
||||
return;
|
||||
|
||||
var seconds = BridgeConfig.ParticipationSweepSeconds;
|
||||
var now = BridgeJson.NowMs();
|
||||
List<string> expired = null;
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
{
|
||||
if (run.Closed)
|
||||
continue;
|
||||
|
||||
// The run's own deadline, honoured here for the reason a lease's is honoured on
|
||||
// the shard: a website that stopped talking must not leave this shard counting
|
||||
// an event that ended days ago.
|
||||
if (run.UntilMs > 0L && now >= run.UntilMs)
|
||||
{
|
||||
SweepRun(run, seconds);
|
||||
run.Closed = true;
|
||||
run.ClosedMs = now;
|
||||
_closed++;
|
||||
|
||||
Console.WriteLine("[Bridge] participation: run {0} passed its deadline and stopped counting",
|
||||
run.RunId);
|
||||
continue;
|
||||
}
|
||||
|
||||
SweepRun(run, seconds);
|
||||
}
|
||||
|
||||
var cutoff = now - (long)BridgeConfig.ParticipationGraceSec * 1000L;
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
{
|
||||
if (!run.Closed || run.ClosedMs > cutoff)
|
||||
continue;
|
||||
|
||||
if (expired == null)
|
||||
expired = new List<string>();
|
||||
|
||||
expired.Add(run.RunId);
|
||||
}
|
||||
|
||||
if (expired == null)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < expired.Count; i++)
|
||||
{
|
||||
Console.WriteLine("[Bridge] participation: forgetting run {0}, closed longer than the grace window",
|
||||
expired[i]);
|
||||
|
||||
_runs.Remove(expired[i]);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] participation sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Credits every online player standing in one run's area with one interval.</summary>
|
||||
private static void SweepRun(Run run, int seconds)
|
||||
{
|
||||
var map = Map.Maps[run.MapIndex];
|
||||
|
||||
if (map == null)
|
||||
return;
|
||||
|
||||
var now = BridgeJson.NowMs();
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var pm = m as PlayerMobile;
|
||||
|
||||
if (pm == null || pm.NetState == null || pm.Deleted)
|
||||
continue;
|
||||
|
||||
if (!Inside(run, pm))
|
||||
continue;
|
||||
|
||||
var member = Touch(run, pm, now);
|
||||
|
||||
if (member == null)
|
||||
continue;
|
||||
|
||||
member.Seconds += seconds;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kill credit, and it goes to every damager standing in the area rather than to the
|
||||
/// killer alone.
|
||||
///
|
||||
/// A last hit is a poor description of who fought something: the player who held it for
|
||||
/// four minutes and died to it took part more than the one who happened to land the blow
|
||||
/// that finished it. `Mobile.DamageEntries` is already populated and is readable here
|
||||
/// because a `CreatureDeath` handler runs before the creature is disposed of — the same
|
||||
/// fact protocol 6's damage table rests on.
|
||||
///
|
||||
/// The presence check is applied to the DAMAGER, not only to the corpse. Someone
|
||||
/// shooting into the venue from outside it is not attending the event, and someone who
|
||||
/// fought there and has since walked away is no longer accruing anything either.
|
||||
/// </summary>
|
||||
private static void OnCreatureDeath(CreatureDeathEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_runs.Count == 0 || e == null || e.Creature == null)
|
||||
return;
|
||||
|
||||
var creature = e.Creature;
|
||||
|
||||
if (creature.Player)
|
||||
return; // a player death is not a kill anybody is credited for
|
||||
|
||||
var now = BridgeJson.NowMs();
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
{
|
||||
if (run.Closed || !Inside(run, creature))
|
||||
continue;
|
||||
|
||||
var entries = creature.DamageEntries;
|
||||
|
||||
if (entries == null)
|
||||
continue;
|
||||
|
||||
// Summed into a set first: ServUO folds repeat damage into an existing entry,
|
||||
// but an entry that expired and was re-created leaves two, and crediting per
|
||||
// entry would pay a long fight twice. Expiry governs looting rights, not
|
||||
// whether somebody was there.
|
||||
var credited = new HashSet<Mobile>();
|
||||
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var de = entries[i];
|
||||
|
||||
if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player)
|
||||
continue;
|
||||
|
||||
if (!credited.Add(de.Damager))
|
||||
continue;
|
||||
|
||||
if (!Inside(run, de.Damager))
|
||||
continue;
|
||||
|
||||
var member = Touch(run, de.Damager, now);
|
||||
|
||||
if (member == null)
|
||||
continue;
|
||||
|
||||
member.Kills++;
|
||||
_kills++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A death handler must never be the thing that breaks a death.
|
||||
Console.WriteLine("[Bridge] participation kill credit threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Inside(Run run, Mobile m)
|
||||
{
|
||||
if (m == null || m.Map == null || m.Map.MapIndex != run.MapIndex)
|
||||
return false;
|
||||
|
||||
// A circle, and squared so the check costs no square root. `Radius` is in tiles and the
|
||||
// z axis is deliberately ignored: a venue is a place on the map, and a player one floor
|
||||
// up in a tower over the square is at the event.
|
||||
var dx = m.X - run.X;
|
||||
var dy = m.Y - run.Y;
|
||||
|
||||
return (dx * dx) + (dy * dy) <= run.Radius * run.Radius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds or creates a member row, or answers null when the cap turned it away.
|
||||
///
|
||||
/// The cap counts a refusal rather than swallowing it, and the count rides on every
|
||||
/// snapshot: a truncated tally that says it is truncated is usable, and one that does
|
||||
/// not is a leaderboard with people missing from it for no stated reason.
|
||||
/// </summary>
|
||||
private static Member Touch(Run run, Mobile m, long now)
|
||||
{
|
||||
var serial = m.Serial.Value;
|
||||
|
||||
Member member;
|
||||
|
||||
if (run.Members.TryGetValue((int)serial, out member))
|
||||
{
|
||||
member.LastMs = now;
|
||||
member.Name = m.Name ?? member.Name;
|
||||
return member;
|
||||
}
|
||||
|
||||
if (run.Members.Count >= BridgeConfig.ParticipationMaxMembers)
|
||||
{
|
||||
run.Refused++;
|
||||
_refused++;
|
||||
return null;
|
||||
}
|
||||
|
||||
member = new Member
|
||||
{
|
||||
Serial = (int)serial,
|
||||
Name = m.Name ?? "",
|
||||
FirstMs = now,
|
||||
LastMs = now,
|
||||
};
|
||||
|
||||
run.Members[member.Serial] = member;
|
||||
return member;
|
||||
}
|
||||
|
||||
// ---- persistence ----
|
||||
|
||||
private static void OnWorldSave(WorldSaveEventArgs e)
|
||||
{
|
||||
Persistence.Serialize(
|
||||
SavePath,
|
||||
writer =>
|
||||
{
|
||||
writer.Write(SaveVersion);
|
||||
writer.Write(_runs.Count);
|
||||
|
||||
foreach (var run in _runs.Values)
|
||||
{
|
||||
writer.Write(run.RunId ?? "");
|
||||
writer.Write(run.MapName ?? "");
|
||||
writer.Write(run.MapIndex);
|
||||
writer.Write(run.X);
|
||||
writer.Write(run.Y);
|
||||
writer.Write(run.Radius);
|
||||
writer.Write(run.OpenedMs);
|
||||
writer.Write(run.UntilMs);
|
||||
writer.Write(run.ClosedMs);
|
||||
writer.Write(run.Closed);
|
||||
writer.Write(run.KillWeight);
|
||||
writer.Write(run.Refused);
|
||||
|
||||
writer.Write(run.Members.Count);
|
||||
|
||||
foreach (var member in run.Members.Values)
|
||||
{
|
||||
writer.Write(member.Serial);
|
||||
writer.Write(member.Name ?? "");
|
||||
writer.Write(member.Seconds);
|
||||
writer.Write(member.Kills);
|
||||
writer.Write(member.FirstMs);
|
||||
writer.Write(member.LastMs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void OnWorldLoad()
|
||||
{
|
||||
Persistence.Deserialize(
|
||||
SavePath,
|
||||
reader =>
|
||||
{
|
||||
var version = reader.ReadInt();
|
||||
|
||||
if (version < 1)
|
||||
return;
|
||||
|
||||
var runs = reader.ReadInt();
|
||||
|
||||
for (int i = 0; i < runs; i++)
|
||||
{
|
||||
var run = new Run
|
||||
{
|
||||
RunId = reader.ReadString(),
|
||||
MapName = reader.ReadString(),
|
||||
MapIndex = reader.ReadInt(),
|
||||
X = reader.ReadInt(),
|
||||
Y = reader.ReadInt(),
|
||||
Radius = reader.ReadInt(),
|
||||
OpenedMs = reader.ReadLong(),
|
||||
UntilMs = reader.ReadLong(),
|
||||
ClosedMs = reader.ReadLong(),
|
||||
Closed = reader.ReadBool(),
|
||||
KillWeight = reader.ReadDouble(),
|
||||
Refused = reader.ReadLong(),
|
||||
};
|
||||
|
||||
var members = reader.ReadInt();
|
||||
|
||||
for (int j = 0; j < members; j++)
|
||||
{
|
||||
var member = new Member
|
||||
{
|
||||
Serial = reader.ReadInt(),
|
||||
Name = reader.ReadString(),
|
||||
Seconds = reader.ReadLong(),
|
||||
Kills = reader.ReadInt(),
|
||||
FirstMs = reader.ReadLong(),
|
||||
LastMs = reader.ReadLong(),
|
||||
};
|
||||
|
||||
run.Members[member.Serial] = member;
|
||||
}
|
||||
|
||||
if (!String.IsNullOrEmpty(run.RunId))
|
||||
_runs[run.RunId] = run;
|
||||
}
|
||||
|
||||
if (_runs.Count > 0)
|
||||
Console.WriteLine("[Bridge] participation: {0} run(s) restored from the world save", _runs.Count);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static Map MapByName(string name)
|
||||
{
|
||||
if (String.IsNullOrEmpty(name))
|
||||
return null;
|
||||
|
||||
for (int i = 0; i < Map.Maps.Length; i++)
|
||||
{
|
||||
var map = Map.Maps[i];
|
||||
|
||||
if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return map;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Ready(string reqId, string action)
|
||||
{
|
||||
if (!BridgeConfig.EventsEnabled)
|
||||
{
|
||||
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Ok(string reqId, string action, Run run)
|
||||
{
|
||||
var sb = BridgeJson.Begin("participation.ok");
|
||||
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Str("action", action)
|
||||
.Str("runId", run.RunId)
|
||||
.Str("map", run.MapName)
|
||||
.Num("x", run.X)
|
||||
.Num("y", run.Y)
|
||||
.Num("radius", run.Radius)
|
||||
.Bool("closed", run.Closed)
|
||||
.Bool("known", true)
|
||||
.Num("members", run.Members.Count)
|
||||
.Num("refused", run.Refused)
|
||||
.Num("untilMs", run.UntilMs);
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Err(string reqId, string action, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("participation.error");
|
||||
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Str("action", action).Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,15 +125,64 @@ 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
|
||||
|
||||
_last[g.Id] = sig;
|
||||
BridgeLink.Emit(WriteGuild(g));
|
||||
_emitted++;
|
||||
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.
|
||||
@@ -120,9 +190,19 @@ namespace Server.Custom.Bridge
|
||||
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;
|
||||
count++;
|
||||
unchecked { memberSum += (uint)m.Serial.Value; }
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
687
tools/scaffolding/BridgeDemoDress.cs
Normal file
687
tools/scaffolding/BridgeDemoDress.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
159
tools/scaffolding/BridgeParticipationProbe.cs
Normal file
159
tools/scaffolding/BridgeParticipationProbe.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
using Server.Commands;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Produces real kill credit inside a participation area, without a game client.
|
||||
///
|
||||
/// ── What this can drive, and what it cannot ───────────────────────────────────────────
|
||||
///
|
||||
/// The participation ledger counts two things: presence, and kill credit. Only one of them
|
||||
/// is reachable from a headless rig, and the split is worth stating rather than discovering.
|
||||
///
|
||||
/// **Presence needs a connected client.** The sweep credits online players — `NetState !=
|
||||
/// null` — which is the correct test and not one a probe should loosen: a character parked
|
||||
/// in Britain and logged out for eight hours did not attend anything, and a ledger that said
|
||||
/// otherwise would put people at the top of a leaderboard for being AFK. There is no way to
|
||||
/// produce a NetState here short of writing a client, so presence accrual is exercised by a
|
||||
/// real login and not by this file.
|
||||
///
|
||||
/// **Kill credit needs none.** `EventSink.CreatureDeath` fires for a creature killed by any
|
||||
/// means, `Mobile.DamageEntries` is populated by real damage, and the area test is a
|
||||
/// coordinate comparison. So the whole of the credit path — the damager filter, the
|
||||
/// per-damager fold, the area test applied to the DAMAGER rather than only the corpse, the
|
||||
/// member cap — runs exactly as it would in a fight.
|
||||
///
|
||||
/// What it does, in order: moves two real player mobiles to the venue, spawns a creature
|
||||
/// there, damages it unequally from both, and kills it.
|
||||
///
|
||||
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||
/// 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.**
|
||||
/// </summary>
|
||||
public static class BridgeParticipationProbe
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("partprobe", AccessLevel.Administrator, Probe_OnCommand);
|
||||
}
|
||||
|
||||
[Usage("partprobe <map> <x> <y>")]
|
||||
[Description("Moves two players to a point, spawns a creature there and kills it.")]
|
||||
private static void Probe_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
if (e.Length < 3)
|
||||
{
|
||||
Say(e.Mobile, "partprobe <map> <x> <y>");
|
||||
return;
|
||||
}
|
||||
|
||||
Run(e.Mobile, e.GetString(0), e.GetInt32(1), e.GetInt32(2));
|
||||
}
|
||||
|
||||
public static void Run(Mobile from, string mapName, int x, int y)
|
||||
{
|
||||
var map = MapByName(mapName);
|
||||
|
||||
if (map == null)
|
||||
{
|
||||
Say(from, "partprobe: unknown map " + mapName);
|
||||
return;
|
||||
}
|
||||
|
||||
var players = FindPlayers(2);
|
||||
|
||||
if (players.Count < 2)
|
||||
{
|
||||
Say(from, "partprobe: need two player mobiles in the world; found " + players.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
var z = map.GetAverageZ(x, y);
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
// Spread them a tile apart so neither lands inside the other, and so the area test
|
||||
// is answering about two distinct points rather than one.
|
||||
players[i].MoveToWorld(new Point3D(x + i, y, z), map);
|
||||
Say(from, String.Format(CultureInfo.InvariantCulture,
|
||||
"partprobe: {0} moved to {1} ({2}, {3})", players[i].Name, map.Name, x + i, y));
|
||||
}
|
||||
|
||||
var victim = new Mongbat();
|
||||
victim.MoveToWorld(new Point3D(x, y + 1, z), map);
|
||||
|
||||
// Real damage through the real path, unequal so the fold is doing something: the
|
||||
// ledger credits one kill per damager regardless of how much they did, and a table
|
||||
// where both did the same amount could not show that.
|
||||
//
|
||||
// **Both amounts are small on purpose, and the first run of this probe is why.** A
|
||||
// Mongbat has around thirty hit points, and an opening blow of 40 killed it where it
|
||||
// stood -- so the SECOND damager never landed a hit, `DamageEntries` held one name,
|
||||
// and the ledger correctly credited one player. The frame looked like a plugin bug
|
||||
// crediting only the killer and was a rig artefact. A probe that means to produce two
|
||||
// damagers has to leave the creature alive to receive the second one.
|
||||
var hit = Math.Max(1, victim.HitsMax / 10);
|
||||
victim.Damage(hit * 2, players[0]);
|
||||
victim.Damage(hit, players[1]);
|
||||
|
||||
Say(from, String.Format(CultureInfo.InvariantCulture,
|
||||
"partprobe: {0} spawned at ({1}, {2}) and damaged by {3} and {4}",
|
||||
victim.Name, x, y + 1, players[0].Name, players[1].Name));
|
||||
|
||||
// Killed on the next tick rather than inline, so the damage above has actually been
|
||||
// registered against the creature before CreatureDeath reads the entries.
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(1.0), () =>
|
||||
{
|
||||
victim.Kill();
|
||||
Say(from, "partprobe: killed; the credit should now be on the ledger");
|
||||
});
|
||||
}
|
||||
|
||||
private static List<PlayerMobile> FindPlayers(int count)
|
||||
{
|
||||
var found = new List<PlayerMobile>();
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var pm = m as PlayerMobile;
|
||||
|
||||
if (pm == null || pm.Deleted || pm.AccessLevel > AccessLevel.Player)
|
||||
continue;
|
||||
|
||||
found.Add(pm);
|
||||
|
||||
if (found.Count >= count)
|
||||
break;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private static Map MapByName(string name)
|
||||
{
|
||||
for (int i = 0; i < Map.Maps.Length; i++)
|
||||
{
|
||||
var map = Map.Maps[i];
|
||||
|
||||
if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
return map;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Say(Mobile to, string text)
|
||||
{
|
||||
if (to != null)
|
||||
to.SendMessage(text);
|
||||
else
|
||||
Console.WriteLine("[PartProbe] " + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
275
tools/scaffolding/BridgeProtocol5Probe.cs
Normal file
275
tools/scaffolding/BridgeProtocol5Probe.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
201
tools/scaffolding/BridgeProtocol6Probe.cs
Normal file
201
tools/scaffolding/BridgeProtocol6Probe.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using Server.Commands;
|
||||
using Server.Custom.Bridge;
|
||||
using Server.Engines.CannedEvil;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises the two halves of Protocol 6 on a live shard, without a game client.
|
||||
///
|
||||
/// **Idempotency needs no probe.** It is driven from the OTHER end — two identical POSTs to
|
||||
/// the sidecar, the second of which must come back `replayed: true` under its own reqId — so
|
||||
/// a curl and the shard's own audit trail are the whole test. Nothing here would make that
|
||||
/// more convincing.
|
||||
///
|
||||
/// `champ.boss.killed` is the opposite case. It cannot be produced from outside the game at
|
||||
/// all: a champion boss appears only when a spawn is driven to its final level, and the
|
||||
/// damage table the frame carries is assembled by real combat against a real creature. A
|
||||
/// fixture can assert the shape of the JSON; only this proves that
|
||||
/// `EventSink.CreatureDeath` fires for a `BaseChampion`, that `DamageEntries` still holds
|
||||
/// anything by the time it does, and that the sweep's spawn attribution is there to name the
|
||||
/// altar.
|
||||
///
|
||||
/// What it does, in order:
|
||||
///
|
||||
/// 1. Places a real `ChampionSpawn`, activates it and calls `SpawnChampion()` — the
|
||||
/// shard's own code path, not a hand-constructed creature.
|
||||
/// 2. Waits for the champ sweep to see it, so the boss is attributed to its altar exactly
|
||||
/// the way a real one would be. **This wait is the assertion**: run without it and the
|
||||
/// kill still emits, but with no `serial`, `type` or `level` — which is the phase's own
|
||||
/// documented fallback rather than the case being tested.
|
||||
/// 3. Damages it from two real player mobiles found in the world, so the damage table has
|
||||
/// two ranked entries rather than none.
|
||||
/// 4. Kills it and cleans up the altar.
|
||||
///
|
||||
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||
/// In game / at the console: `[p6probe`. Flag: `Protocol6ProbeOnStart`.
|
||||
/// **Spawns and kills a champion boss.** Use on a rig, never on a live shard.
|
||||
/// </summary>
|
||||
public static class BridgeProtocol6Probe
|
||||
{
|
||||
private static ChampionSpawn _spawn;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("p6probe", AccessLevel.Administrator, Probe_OnCommand);
|
||||
|
||||
if (Config.Get("Bridge.Protocol6ProbeOnStart", false))
|
||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(10.0), () => Run(null));
|
||||
}
|
||||
|
||||
[Usage("p6probe")]
|
||||
[Description("Spawns a champion boss, damages it from two players and kills it.")]
|
||||
private static void Probe_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
Run(e == null ? null : e.Mobile);
|
||||
}
|
||||
|
||||
private static void Say(Mobile to, string text)
|
||||
{
|
||||
Console.WriteLine("[p6probe] {0}", text);
|
||||
|
||||
if (to != null)
|
||||
to.SendMessage(text);
|
||||
}
|
||||
|
||||
private static void Run(Mobile from)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Inside a NAMED region, deliberately. A champion altar really lives in a dungeon
|
||||
// and the first version of this probe put one there — but the dungeon floor at
|
||||
// Destard belongs to the map's default region, whose Name is empty, so the emitted
|
||||
// frame carried no `region` at all and the one field a phase condition is most
|
||||
// likely to match on ("the boss in Yew") went unproven. Britain has a named region,
|
||||
// so this exercises the field rather than the guard that omits it.
|
||||
var where = new Point3D(1496, 1628, 10);
|
||||
var map = Map.Felucca;
|
||||
|
||||
Cleanup();
|
||||
|
||||
_spawn = new ChampionSpawn();
|
||||
_spawn.MoveToWorld(where, map);
|
||||
_spawn.Type = ChampionSpawnType.Abyss;
|
||||
_spawn.AutoRestart = false;
|
||||
_spawn.Active = true;
|
||||
|
||||
Say(from, "altar placed; spawning its champion");
|
||||
|
||||
_spawn.SpawnChampion();
|
||||
|
||||
var boss = _spawn.Champion;
|
||||
|
||||
if (boss == null)
|
||||
{
|
||||
Say(from, "FAILED: the spawn produced no champion");
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
Say(from, String.Format("champion up: {0} ({1}) serial {2} region {3}",
|
||||
boss.Name, boss.GetType().Name, boss.Serial,
|
||||
boss.Region == null ? "(none)" : ("\"" + boss.Region.Name + "\"")));
|
||||
|
||||
// Give the sweep time to attribute the boss to its altar. Two intervals, because a
|
||||
// single one races the timer that is already part-way through its period.
|
||||
var wait = TimeSpan.FromSeconds(Math.Max(2.0, BridgeConfig.ChampSweepSeconds * 2.0));
|
||||
|
||||
Say(from, String.Format("waiting {0:0}s for the champ sweep to see it", wait.TotalSeconds));
|
||||
|
||||
Timer.DelayCall(wait, () => Finish(from, boss));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Say(from, "threw: " + ex);
|
||||
Cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Finish(Mobile from, Mobile boss)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (boss == null || boss.Deleted)
|
||||
{
|
||||
Say(from, "FAILED: the champion vanished before it could be killed");
|
||||
Cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Two real players, so the damage table has two ranked entries and the ranking is
|
||||
// testable rather than trivially one row. Registered through Mobile.RegisterDamage,
|
||||
// which is the same call combat makes.
|
||||
var players = World.Mobiles.Values
|
||||
.OfType<PlayerMobile>()
|
||||
.Where(p => !p.Deleted && p.Account != null)
|
||||
.Take(2)
|
||||
.ToList();
|
||||
|
||||
if (players.Count < 2)
|
||||
{
|
||||
Say(from, "note: fewer than two player mobiles in the world; the table will be short");
|
||||
}
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
// Deliberately unequal and deliberately in ascending order, so a frame that
|
||||
// reported them in arrival order rather than by damage would be visibly wrong.
|
||||
int amount = 120 * (i + 1);
|
||||
boss.RegisterDamage(amount, players[i]);
|
||||
Say(from, String.Format("registered {0} damage from {1}", amount, players[i].Name));
|
||||
}
|
||||
|
||||
var killer = players.Count > 0 ? players[players.Count - 1] : null;
|
||||
|
||||
Say(from, "killing the champion");
|
||||
|
||||
boss.Damage(boss.HitsMax * 10, killer);
|
||||
|
||||
if (!boss.Deleted && boss.Alive)
|
||||
{
|
||||
Say(from, "note: it survived the blow; killing it outright");
|
||||
boss.Kill();
|
||||
}
|
||||
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(2.0), () =>
|
||||
{
|
||||
Cleanup();
|
||||
Say(from, "done — check the sidecar feed for champ.boss.killed");
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Say(from, "threw: " + ex);
|
||||
Cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Cleanup()
|
||||
{
|
||||
if (_spawn == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_spawn.Active = false;
|
||||
_spawn.Delete();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The altar is scaffolding; failing to tidy it is not worth an exception.
|
||||
}
|
||||
|
||||
_spawn = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
556
tools/scaffolding/BridgeRigDriver.cs
Normal file
556
tools/scaffolding/BridgeRigDriver.cs
Normal file
@@ -0,0 +1,556 @@
|
||||
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 <serial|any> <stage> force a decay stage (LikeNew|Slightly|Somewhat|
|
||||
/// Fairly|Greatly|IDOC|Collapsed)
|
||||
/// vendorlist player vendors, with owner account and next pay time
|
||||
/// vendorfunds <serial> <gold> set a vendor's held gold (drives periodsRemaining)
|
||||
/// citylist cities, governors and election phases
|
||||
/// governor <city> <mobile|none> seat a governor (a mobile serial, or a player's name)
|
||||
/// election <city> force a new election into its nomination window
|
||||
/// activate <account> clear an account's inactivity, so its houses stop
|
||||
/// being Condemned and CAN be refreshed
|
||||
/// password <account> <pw> 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;
|
||||
// Phase 11b. Plays the interfering GM a config lease's compare-and-set exists to
|
||||
// catch, and reads a key back the way the game reads it. Both halves are here
|
||||
// rather than only in `[leaseprobe` because a headless rig has no client to type
|
||||
// a command at, and ServUO's own console takes a fixed verb set.
|
||||
case "configset": ConfigSet(Arg(parts, 1), Arg(parts, 2)); break;
|
||||
case "configread": ConfigRead(Arg(parts, 1)); break;
|
||||
// Kill credit inside a participation area. Lives in BridgeParticipationProbe
|
||||
// because it moves mobiles and spawns a creature; reachable from here because a
|
||||
// headless rig has no client to type `[partprobe` at. The two files ship together.
|
||||
case "partprobe":
|
||||
BridgeParticipationProbe.Run(null, Arg(parts, 1), Int(Arg(parts, 2)), Int(Arg(parts, 3)));
|
||||
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;
|
||||
}
|
||||
|
||||
private static int Int(string raw)
|
||||
{
|
||||
int n;
|
||||
return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out n) ? n : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a live config key, so a lease's `drifted` verdict can be produced at all.
|
||||
///
|
||||
/// **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4**
|
||||
/// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). 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 the one safety property a lease has
|
||||
/// that nothing else does would go untested. Written through the same typed setter a
|
||||
/// float lease uses, so what it produces is indistinguishable to the compare-and-set
|
||||
/// from a real interfering write.
|
||||
///
|
||||
/// Deliberately no `Config.Save()`, matching BridgeLeases: nothing about a rig should
|
||||
/// leave a modified .cfg behind for the next boot to inherit.
|
||||
/// </summary>
|
||||
private static void ConfigSet(string key, string raw)
|
||||
{
|
||||
if (key == null || raw == null)
|
||||
{
|
||||
Say("configset <key> <value>");
|
||||
return;
|
||||
}
|
||||
|
||||
double n;
|
||||
|
||||
if (Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
|
||||
Config.Set(key, n);
|
||||
else
|
||||
Config.Set(key, raw);
|
||||
|
||||
Say("configset " + key + " = " + raw + " (in memory only)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a key back through `Config.Get`, at a moment long after every type
|
||||
/// initialiser has run.
|
||||
///
|
||||
/// This is the check that tells a key which TOOK from one that only appeared to: a
|
||||
/// lease on one of ServUO's ~150 cached call sites applies cleanly and does nothing,
|
||||
/// which is the worst failure this feature has.
|
||||
/// </summary>
|
||||
private static void ConfigRead(string key)
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
Say("configread <key>");
|
||||
return;
|
||||
}
|
||||
|
||||
Say("configread " + key + " = " + Config.Get(key, Double.NaN).ToString("R", CultureInfo.InvariantCulture)
|
||||
+ " (double), \"" + Config.Get(key, "<unset>") + "\" (string)");
|
||||
}
|
||||
|
||||
// ---- 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ 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`, `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
|
||||
|
||||
@@ -34,6 +39,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 +110,82 @@ 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 2–4× 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.
|
||||
|
||||
Reference in New Issue
Block a user