feat(bridge): lease deadlines and the participation ledger (Phase 11b)
Protocol 6 amended in place. Two mechanisms behind one new default-off gate,
`Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling
the admin plane is consenting to staff moderation from a screen a human is
looking at, and this is consenting to the world being changed and watched on a
schedule, unattended.
BridgeLeases: a live config value held for a bounded time, with the deadline
honoured on the shard whether or not the website is heard from again, and a
compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is
a free restore.
BridgeParticipation: presence in a declared area plus kill credit inside it,
keyed by character serial, persisted in the world save. The Bridge's first
persisted state, because a run spans hours and an in-memory tally would regress
every attendee's score after one restart. Its snapshot is also the first handler
that DEFERS, which makes `bridge.busy` reachable for the first time.
And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame
with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so
the object carried two `kind` fields and every JSON parser takes the last. The
sidecar answered 200 instead of 425. Renamed `busyKind`.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -199,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
|
||||
|
||||
@@ -202,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();
|
||||
@@ -223,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());
|
||||
@@ -232,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:
|
||||
@@ -251,6 +255,8 @@ namespace Server.Custom.Bridge
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,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; }
|
||||
@@ -235,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.
|
||||
@@ -313,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,9 +239,10 @@ namespace Server.Custom.Bridge
|
||||
/// (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.
|
||||
///
|
||||
/// Nothing in protocol 6 defers yet. The door exists because the leases and world verbs
|
||||
/// that follow do, and because a deferred handler that had no way to hold its key would
|
||||
/// quietly be the one place the guarantee did not hold.
|
||||
/// 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)
|
||||
{
|
||||
@@ -250,6 +251,15 @@ namespace Server.Custom.Bridge
|
||||
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;
|
||||
@@ -373,8 +383,18 @@ namespace Server.Custom.Bridge
|
||||
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("kind", prior.Kind)
|
||||
.Str("busyKind", prior.Kind)
|
||||
.Str("reason", "a command with this idempotency key is still in flight");
|
||||
|
||||
BridgeLink.Emit(sb.End());
|
||||
|
||||
@@ -307,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('"');
|
||||
@@ -487,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,18 @@ namespace Server.Custom
|
||||
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
|
||||
@@ -149,6 +161,64 @@ namespace Server.Custom
|
||||
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>
|
||||
|
||||
@@ -14,9 +14,10 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
|
||||
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
|
||||
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
|
||||
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
|
||||
| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `save`, `shutdown`. Flag: `RigDriverEnabled`. **Sets passwords and mutates the world.** |
|
||||
| `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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user