The shard half of protocol 7 part b. Two lease planes whose value lives on something already in the world, and two verbs that cannot be taken back. A LEASE HERE MUST BE PERSISTED, AND THE CONFIG PLANE'S MUST NOT 11b's fail-safe is stated plainly in its own header: a lease that never reaches disk means a shard restart is a FREE restore. That argument depends entirely on the leased value being memory-only too, and here it is not. A spawner is an Item and is in the world save; a seasonal entry is written to `Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a restart does not put either back -- it puts the CHANGE back and throws away the deadline timer that was going to undo it, leaving the world at the leased value with nothing here remembering it is borrowed. So the Bridge gains its THIRD save file, `Saves/Bridge/Leases.bin`, written by the same `EventSink.WorldSave` that writes what it describes, with deadlines re-armed at load. A deadline that passed while the shard was down fires AT ONCE: the promise was "back at baseline by then", and extending it would silently turn a two-hour lease into however long the outage was. Config holds are still not written down -- the same argument, applied to planes where its premise is false. A TARGET IS A SERIAL OR A UniqueId, AND BOTH ARE NEEDED A serial is what `[props` shows a GM. An `XmlSpawner.UniqueId` is what the shard's own `Spawns/*.xml` carry -- and it is not a convenience: a dropdown built from serials is IMPOSSIBLE, because serials are assigned when the world is built and nothing off-shard knows them. A lease addressable only by serial could have no authoring list at all. `Spawner` and `XmlSpawner` share all four property names, which is a fact about this tree rather than a convenience: the spawn files load as XmlSpawners while `[add spawner` makes the native one. And it is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such property. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries seconds. The allowlist is checked against the object's OWN type, which is the sentence the whole plane rests on: a serial is a number a caller chooses, so that check is all that stands between `Spawner.MaxCount` and any item on the shard. Reflection is bounded three ways -- the pair must be in the catalog, the property must carry `CommandProperty` (so this can never reach further than `[set` could), and its CLR type must be one this file renders. THE SELF-CHECK, AND THE ONE FAILURE NO PROBE CAN CATCH §N10 in full: a config key is probed live (write, read back, restore) because there is exactly one of it. A property CANNOT be -- thousands of instances and no canonical one, so probing would mean writing to somebody's spawner at boot. What is verified instead is everything verifiable without touching the world. And `TreasuresOfTokuno` is excluded by name, because `IsActive()` reads its own `DropEra` rather than `Status`: the write succeeds, the value reads back, a compare-and-set restore passes, and the capability does nothing at all. That is N10's "capability that lies" in its purest form and the only way to find it is to read the source. §G also called this toggle "small and safe" -- it is safe, but `OnStatusChange()` generates or removes world content for six of the eight. THE ONE-SHOTS Who receives a grant is answered HERE. The website has the list too, but a module cannot read core's tables, so the alternative was a new core surface handing participants to a module. Not needed: 11b's participation ledger already holds them, keyed by the same serials. A run with no ledger is a 404; a run whose ledger is open and empty is a 200 with `granted: 0`, because an event nobody attended still happened. An undeliverable grant is DELETED rather than dropped -- `AddItem` failing on a full backpack would otherwise leave it at (0,0). A save stops the world, so it is rate-limited rather than capped, counting ServUO's own autosave as the last one. Refused, never queued: a queued save would land at a moment nobody chose. VERIFIED Compiles clean (0 warnings, 0 errors). Then a full walk on the real local ServUO 57.4 world against the release sidecar: - all six catalog keys survive the boot self-check; `current` is correctly absent on the targeted rows and filled when a target is named; - a spawner reads the same by UniqueId and by serial; - TWO RUNS ON TWO SPAWNERS BOTH SUCCEED while a second run on the SAME spawner is refused -- the whole reason for the targeted ref; - a GM edit behind the plane's back yields `lease.drifted` and the world is left at 55, not reverted; a clean release restores to baseline; - ToT refused with its own reason, a bad status refused, Fellowship toggled; - grant: no ledger 404, empty ledger 200 `granted: 0`, unknown item 400, over the stack bound 400; save 200 then 429 inside the interval; - BOTH HOLDS AND BOTH LEASED VALUES SURVIVE save + clean shutdown + restart, the deadlines re-arm, and a release across the restart still compare-and-sets; - with only a CONFIG lease held, `Leases.bin` is 8 bytes and names nothing; - refusals: targeted-with-no-target, untargeted-with-a-target, out of range, over 30 days, a target that is not there, and a `ChainChest` refused as a spawner; - 90 seconds becomes `00:01:30` and the baseline reads back as 18000; - a deleted target reads `unreadable` and releases `targetGone: true`. The test world was never saved after the deliberate deletion, so it is intact. Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
1406 lines
57 KiB
C#
1406 lines
57 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
|
|
namespace Server.Custom.Bridge
|
|
{
|
|
/// <summary>
|
|
/// Protocol 6 part b, extended by protocol 7 part b. The lease plane: a 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.
|
|
///
|
|
/// **Three planes now, and this file owns what is common to all of them** — the wire, the
|
|
/// deadline, the compare-and-set, the bookkeeping and the persistence rule. The config plane
|
|
/// is here because it is small and was first; the two TARGETED planes (a property on an
|
|
/// existing object, a seasonal event's status) live in <see cref="BridgeLeaseTargets"/>,
|
|
/// because everything specific about them is specific to ServUO rather than to leasing.
|
|
///
|
|
/// A targeted key names a capability over many things — `Spawner.MaxCount` is one lease over
|
|
/// thousands of spawners — so a hold is keyed by the key AND its target, and two runs may
|
|
/// hold the same key on two different spawners. The website composes its own ledger ref the
|
|
/// same way and for the same reason.
|
|
///
|
|
/// 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 CONFIG lease is made of, and why it alone is memory-only ────────────────────
|
|
///
|
|
/// Everything in this section is true of the config plane and **false of the other two**,
|
|
/// which is the single most important thing to know before changing this file. A config
|
|
/// value lives in memory, so a restart restores it for free; a spawner is in the world save
|
|
/// and a seasonal status is in `Saves/Misc/SeasonalEvents.bin`, so a restart preserves the
|
|
/// CHANGE and destroys only the timer that was going to undo it. That is why those two
|
|
/// planes' holds are written to `Saves/Bridge/Leases.bin` and this one's are not.
|
|
///
|
|
/// `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
|
|
{
|
|
public enum LeaseType
|
|
{
|
|
Float,
|
|
Int,
|
|
Bool,
|
|
Text
|
|
}
|
|
|
|
/// <summary>
|
|
/// Which plane a key lives on, and therefore where its value actually is.
|
|
///
|
|
/// **The distinction is not cosmetic: it decides whether the hold is persisted.** A
|
|
/// `Config` value is memory-only, so a restart restores it for free and writing the
|
|
/// hold to disk would throw that away. An `ObjectProperty` is on an `Item` in the world
|
|
/// save and a `Seasonal` status is in `Saves/Misc/SeasonalEvents.bin`, so for both of
|
|
/// those a restart preserves the CHANGE and loses only the timer that would undo it —
|
|
/// which is why those two, and only those two, are written down. See
|
|
/// <see cref="BridgeLeaseTargets"/>.
|
|
/// </summary>
|
|
public enum LeaseKind
|
|
{
|
|
Config,
|
|
ObjectProperty,
|
|
Seasonal
|
|
}
|
|
|
|
/// <summary>One allowlisted key: what it is, what it holds, and what it is worth by default.</summary>
|
|
public sealed class Catalog
|
|
{
|
|
public string Key;
|
|
public string Label;
|
|
public LeaseKind Kind;
|
|
public LeaseType Type;
|
|
public double Min;
|
|
public double Max;
|
|
|
|
/// <summary>
|
|
/// The closed set of values a `Text` key may hold, or null when it is free text.
|
|
///
|
|
/// It exists because the seasonal status is a three-value enum and `Min`/`Max` bound
|
|
/// only the numeric types — so without it the sole check on that value would be the
|
|
/// `Enum.Parse` at the point of writing, which is a refusal arriving mid-run rather
|
|
/// than on the form.
|
|
/// </summary>
|
|
public string[] Values;
|
|
|
|
/// <summary>
|
|
/// What the thing this key applies to is CALLED, or null when the key is a single
|
|
/// value. Non-null is what makes a key targeted, on the wire and in `lease.list`.
|
|
/// </summary>
|
|
public string TargetLabel;
|
|
|
|
/// <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[] ConfigKeys =
|
|
{
|
|
new Catalog
|
|
{
|
|
Key = "PlayerCaps.SkillCap",
|
|
Label = "Starting skill cap",
|
|
Kind = LeaseKind.Config,
|
|
Type = LeaseType.Float,
|
|
Min = 1000.0,
|
|
Max = 1500.0,
|
|
Default = "1000",
|
|
},
|
|
};
|
|
|
|
/// <summary>
|
|
/// Every key this shard offers, config plane first.
|
|
///
|
|
/// Built per call rather than cached, because the targeted planes drop keys their boot
|
|
/// self-check failed and `[bridge reload` re-runs it — a cached array would keep serving
|
|
/// a capability the shard has just decided it does not have.
|
|
/// </summary>
|
|
private static IEnumerable<Catalog> Keys()
|
|
{
|
|
for (int i = 0; i < ConfigKeys.Length; i++)
|
|
{
|
|
if (!_droppedConfig.Contains(ConfigKeys[i].Key))
|
|
yield return ConfigKeys[i];
|
|
}
|
|
|
|
foreach (var entry in BridgeLeaseTargets.Catalog())
|
|
yield return entry;
|
|
}
|
|
|
|
/// <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;
|
|
/// <summary>What the key applies to, or null for a single-value key.</summary>
|
|
public string Target;
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Held leases, keyed by <see cref="Slot"/> — the key and its target together.
|
|
///
|
|
/// Keyed by the pair rather than by the key alone, because `Spawner.MaxCount` is one
|
|
/// capability over thousands of spawners: keyed by the key, one run turning up one
|
|
/// spawner would have refused every other run every other spawner. The website's own
|
|
/// two-events-one-target index composes its ref exactly the same way and for exactly
|
|
/// the same reason.
|
|
/// </summary>
|
|
private static readonly Dictionary<string, Held> _held =
|
|
new Dictionary<string, Held>(StringComparer.Ordinal);
|
|
|
|
/// <summary>Config keys the boot self-check dropped. See <see cref="SelfCheck"/>.</summary>
|
|
private static readonly HashSet<string> _droppedConfig = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
private static Timer _prune;
|
|
|
|
private static long _applied, _released, _drifted, _expired, _refused;
|
|
|
|
/// <summary>
|
|
/// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad`
|
|
/// fires, so this cannot be deferred to Initialize — 11b's rule, unchanged.
|
|
/// </summary>
|
|
[CallPriority(900)]
|
|
public static void Configure()
|
|
{
|
|
EventSink.WorldSave += OnWorldSave;
|
|
EventSink.WorldLoad += OnWorldLoad;
|
|
}
|
|
|
|
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()
|
|
{
|
|
SelfCheck();
|
|
RearmDeadlines();
|
|
Rearm();
|
|
}
|
|
|
|
/// <summary>The pair a lease is held under: the key, and what it applies to.</summary>
|
|
private static string Slot(string key, string target)
|
|
{
|
|
return String.IsNullOrEmpty(target) ? key : key + "#" + target;
|
|
}
|
|
|
|
/// <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 target;
|
|
string why;
|
|
|
|
if (!Target(entry, o, out target, out why))
|
|
{
|
|
Err(reqId, "apply", why);
|
|
return;
|
|
}
|
|
|
|
string canonical;
|
|
|
|
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;
|
|
}
|
|
|
|
var slot = Slot(entry.Key, target);
|
|
|
|
Held existing;
|
|
|
|
if (_held.TryGetValue(slot, out existing) && !existing.Expired)
|
|
{
|
|
Err(reqId, "apply",
|
|
"'" + slot + "' 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, target, out why);
|
|
|
|
// **A target that cannot be resolved refuses the lease rather than defaulting.** For a
|
|
// config key this cannot happen; for a spawner it happens the moment a serial names
|
|
// something that was deleted, and taking the lease anyway would record a fictional
|
|
// baseline and later write it onto whatever next held that serial.
|
|
if (baseline == null)
|
|
{
|
|
Err(reqId, "apply", why ?? "that target could not be read");
|
|
return;
|
|
}
|
|
|
|
var held = new Held
|
|
{
|
|
Key = entry.Key,
|
|
Target = target,
|
|
Baseline = baseline,
|
|
Applied = canonical,
|
|
UntilMs = BridgeJson.GetLong(o, "untilMs", BridgeJson.NowMs() + holdMs),
|
|
RunId = BridgeJson.GetString(o, "runId"),
|
|
};
|
|
|
|
if (!Write(entry, target, canonical, out why))
|
|
{
|
|
Err(reqId, "apply", why ?? "that value could not be applied");
|
|
return;
|
|
}
|
|
|
|
held.Deadline = Timer.DelayCall(TimeSpan.FromMilliseconds(holdMs), () => OnDeadline(slot));
|
|
_held[slot] = held;
|
|
_applied++;
|
|
|
|
Console.WriteLine("[Bridge] lease {0}: {1} -> {2} for {3}s (run {4})",
|
|
slot, baseline, canonical, holdMs / 1000L, held.RunId ?? "-");
|
|
|
|
BridgeLink.Emit(BridgeJson.Begin("lease.applied")
|
|
.Str("key", entry.Key)
|
|
.Str("target", target)
|
|
.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("target", target)
|
|
.Str("baseline", baseline)
|
|
.Str("applied", canonical)
|
|
.Num("untilMs", held.UntilMs);
|
|
BridgeLink.Emit(sb.End());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads and checks the target for a key, in both directions.
|
|
///
|
|
/// A targeted key with no target and an untargeted key with one are both refusals, and
|
|
/// both are the caller's mistake rather than the world's — the same pair core refuses at
|
|
/// authoring time, checked again here because this is the half that is true when the
|
|
/// website is wrong.
|
|
/// </summary>
|
|
private static bool Target(Catalog entry, Dictionary<string, object> o, out string target, out string why)
|
|
{
|
|
why = null;
|
|
target = BridgeJson.GetString(o, "target");
|
|
|
|
if (target != null)
|
|
target = target.Trim();
|
|
|
|
if (String.IsNullOrEmpty(target))
|
|
target = null;
|
|
|
|
if (entry.TargetLabel != null && target == null)
|
|
{
|
|
why = "'" + entry.Key + "' needs a target (" + entry.TargetLabel + ")";
|
|
return false;
|
|
}
|
|
|
|
if (entry.TargetLabel == null && target != null)
|
|
{
|
|
why = "'" + entry.Key + "' is a single value and takes no target";
|
|
return false;
|
|
}
|
|
|
|
if (target != null && target.Length > MaxTargetLength)
|
|
{
|
|
why = "that target is longer than this shard records (" + MaxTargetLength + " characters)";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// ---- 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;
|
|
}
|
|
|
|
string target;
|
|
string why;
|
|
|
|
if (!Target(entry, o, out target, out why))
|
|
{
|
|
Err(reqId, "release", why);
|
|
return;
|
|
}
|
|
|
|
var slot = Slot(entry.Key, target);
|
|
|
|
Held held;
|
|
_held.TryGetValue(slot, 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(slot);
|
|
Drifted(reqId, entry.Key, target, 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.
|
|
//
|
|
// **On the targeted planes the second half of that is no longer true, which is why the
|
|
// hold is persisted.** A restart does not put a spawner back — it is in the world save.
|
|
// So a restarted shard reaches here only when the persisted hold was ALSO lost (an
|
|
// unsaved run), and it answers with what is actually there rather than asserting the
|
|
// baseline is back.
|
|
if (held == null || held.Expired)
|
|
{
|
|
Drop(slot);
|
|
_released++;
|
|
|
|
var already = BridgeJson.Begin("lease.ok");
|
|
if (reqId != null) already.Str("reqId", reqId);
|
|
already.Str("action", "release")
|
|
.Str("key", entry.Key)
|
|
.Str("target", target)
|
|
.Bool("released", true)
|
|
.Bool("alreadyRestored", true)
|
|
.Str("current", Read(entry, target, out why));
|
|
BridgeLink.Emit(already.End());
|
|
return;
|
|
}
|
|
|
|
var expected = BridgeJson.GetString(o, "expected");
|
|
var current = Read(entry, target, out why);
|
|
|
|
// The target is gone — a spawner somebody deleted mid-run. Nothing to restore and
|
|
// nothing wrong: this is 12a's `gone` in the lease plane's vocabulary, and reporting it
|
|
// as a failure would leave a row unresolved forever over an object that no longer
|
|
// exists.
|
|
if (current == null)
|
|
{
|
|
Drop(slot);
|
|
_released++;
|
|
|
|
var vanished = BridgeJson.Begin("lease.ok");
|
|
if (reqId != null) vanished.Str("reqId", reqId);
|
|
vanished.Str("action", "release")
|
|
.Str("key", entry.Key)
|
|
.Str("target", target)
|
|
.Bool("released", true)
|
|
.Bool("alreadyRestored", true)
|
|
.Bool("targetGone", true)
|
|
.Str("reason", why);
|
|
BridgeLink.Emit(vanished.End());
|
|
return;
|
|
}
|
|
|
|
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(slot);
|
|
Drifted(reqId, entry.Key, target, current);
|
|
return;
|
|
}
|
|
|
|
var baseline = BridgeJson.GetString(o, "baseline");
|
|
|
|
if (baseline == null)
|
|
baseline = held.Baseline;
|
|
|
|
string canonical;
|
|
|
|
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;
|
|
}
|
|
|
|
if (!Write(entry, target, canonical, out why))
|
|
{
|
|
Err(reqId, "release", why ?? "the baseline could not be written back");
|
|
return;
|
|
}
|
|
|
|
Drop(slot);
|
|
_released++;
|
|
|
|
Console.WriteLine("[Bridge] lease {0}: restored to {1}", slot, canonical);
|
|
|
|
var sb = BridgeJson.Begin("lease.ok");
|
|
if (reqId != null) sb.Str("reqId", reqId);
|
|
sb.Str("action", "release")
|
|
.Str("key", entry.Key)
|
|
.Str("target", target)
|
|
.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;
|
|
|
|
// **One frame answers the catalog AND one entry**, because a targeted key has no
|
|
// single "current". `Spawner.MaxCount` is worth something different on every spawner,
|
|
// so a catalog walk cannot fill it in and the website's `read()` — which needs exactly
|
|
// one value, for exactly one target, before it applies anything — would have nothing to
|
|
// read. Naming a key and a target narrows the answer to that one row and fills it.
|
|
var onlyKey = BridgeJson.GetString(o, "key");
|
|
var onlyTarget = BridgeJson.GetString(o, "target");
|
|
|
|
var sb = BridgeJson.Begin("lease.list.ok");
|
|
if (reqId != null) sb.Str("reqId", reqId);
|
|
|
|
sb.Append(",\"leases\":[");
|
|
|
|
var first = true;
|
|
|
|
foreach (var entry in Keys())
|
|
{
|
|
if (onlyKey != null && !String.Equals(entry.Key, onlyKey, StringComparison.Ordinal))
|
|
continue;
|
|
|
|
if (!first)
|
|
sb.Append(',');
|
|
|
|
first = false;
|
|
|
|
var target = entry.TargetLabel == null ? null : onlyTarget;
|
|
|
|
sb.Append("{\"key\":");
|
|
BridgeJson.Text(sb, entry.Key);
|
|
sb.Append(",\"label\":");
|
|
BridgeJson.Text(sb, entry.Label);
|
|
sb.Append(",\"kind\":\"").Append(KindName(entry.Kind)).Append('"');
|
|
sb.Append(",\"type\":\"").Append(TypeName(entry.Type)).Append('"');
|
|
sb.Append(",\"default\":");
|
|
BridgeJson.Text(sb, entry.Default);
|
|
|
|
if (entry.TargetLabel != null)
|
|
{
|
|
sb.Append(",\"targetLabel\":");
|
|
BridgeJson.Text(sb, entry.TargetLabel);
|
|
}
|
|
|
|
if (entry.Values != null)
|
|
{
|
|
sb.Append(",\"values\":[");
|
|
for (int v = 0; v < entry.Values.Length; v++)
|
|
{
|
|
if (v > 0) sb.Append(',');
|
|
BridgeJson.Text(sb, entry.Values[v]);
|
|
}
|
|
sb.Append(']');
|
|
}
|
|
|
|
// **`current` is present only when it MEANS something**, rather than defaulted to
|
|
// an empty string. A targeted key listed with no target has no current value, and
|
|
// sending `""` would make the website's `read()` record an empty baseline and later
|
|
// try to restore it.
|
|
if (entry.TargetLabel == null || target != null)
|
|
{
|
|
string why;
|
|
var current = Read(entry, target, out why);
|
|
|
|
if (current != null)
|
|
{
|
|
sb.Append(",\"current\":");
|
|
BridgeJson.Text(sb, current);
|
|
}
|
|
else if (why != null)
|
|
{
|
|
sb.Append(",\"unreadable\":");
|
|
BridgeJson.Text(sb, why);
|
|
}
|
|
}
|
|
|
|
if (target != null)
|
|
{
|
|
sb.Append(",\"target\":");
|
|
BridgeJson.Text(sb, target);
|
|
}
|
|
|
|
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(Slot(entry.Key, target), 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(']');
|
|
|
|
// Every hold this shard is carrying, whatever key or target it is on. `lease.list` with
|
|
// no arguments could enumerate the catalog but never the HOLDS on a targeted key —
|
|
// there is no list of spawners to walk — so a reconcile after an outage would have no
|
|
// way to ask "what are you still holding?". This is that list.
|
|
sb.Append(",\"holds\":[");
|
|
|
|
var firstHold = true;
|
|
|
|
foreach (var kv in _held)
|
|
{
|
|
if (!firstHold)
|
|
sb.Append(',');
|
|
|
|
firstHold = false;
|
|
|
|
var h = kv.Value;
|
|
|
|
sb.Append("{\"key\":");
|
|
BridgeJson.Text(sb, h.Key);
|
|
sb.Append(",\"target\":");
|
|
BridgeJson.Text(sb, h.Target);
|
|
sb.Append(",\"runId\":");
|
|
BridgeJson.Text(sb, h.RunId);
|
|
sb.Append(",\"baseline\":");
|
|
BridgeJson.Text(sb, h.Baseline);
|
|
sb.Append(",\"applied\":");
|
|
BridgeJson.Text(sb, h.Applied);
|
|
sb.Append(",\"untilMs\":").Append(h.UntilMs);
|
|
sb.Append(",\"expired\":").Append(h.Expired ? "true" : "false");
|
|
sb.Append(",\"restored\":").Append(h.Restored ? "true" : "false");
|
|
sb.Append(",\"drifted\":").Append(h.Drifted ? "true" : "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 slot)
|
|
{
|
|
Held held;
|
|
|
|
if (!_held.TryGetValue(slot, out held) || held.Expired)
|
|
return;
|
|
|
|
var entry = Lookup(held.Key);
|
|
|
|
if (entry == null)
|
|
return;
|
|
|
|
held.Deadline = null;
|
|
held.Expired = true;
|
|
held.ExpiredAtMs = BridgeJson.NowMs();
|
|
_expired++;
|
|
|
|
string why;
|
|
var current = Read(entry, held.Target, out why);
|
|
|
|
if (current == null)
|
|
{
|
|
// The target is gone. Nothing to restore and nothing drifted: the object this lease
|
|
// was borrowing no longer exists, which is a clean end rather than a failure.
|
|
held.Restored = true;
|
|
|
|
Console.WriteLine("[Bridge] lease {0}: deadline passed and the target is gone -- {1}", slot, why);
|
|
}
|
|
else 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",
|
|
slot, current, held.Applied);
|
|
}
|
|
else if (Write(entry, held.Target, held.Baseline, out why))
|
|
{
|
|
held.Restored = true;
|
|
|
|
Console.WriteLine("[Bridge] lease {0}: deadline passed, restored to {1} without being asked",
|
|
slot, held.Baseline);
|
|
}
|
|
else
|
|
{
|
|
// The backstop could not write. Not drift — nobody moved it — so it is reported as
|
|
// neither restored nor drifted, and the row stays for teardown to collect. Silence
|
|
// here would be the one thing worse than the failure.
|
|
Console.WriteLine("[Bridge] lease {0}: deadline passed and the restore FAILED -- {1}", slot, why);
|
|
}
|
|
|
|
BridgeLink.Emit(BridgeJson.Begin("lease.expired")
|
|
.Str("key", held.Key)
|
|
.Str("target", held.Target)
|
|
.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>
|
|
/// Re-arms every persisted hold's deadline after a world load.
|
|
///
|
|
/// **A deadline that passed while the shard was down fires at once.** The promise the
|
|
/// website was given is "back at baseline by then", and a shard that was off for the
|
|
/// whole hold has not kept it; restoring immediately is the only reading of that promise
|
|
/// still available. Extending it would silently turn a two-hour lease into however long
|
|
/// the outage was.
|
|
/// </summary>
|
|
private static void RearmDeadlines()
|
|
{
|
|
if (_held.Count == 0)
|
|
return;
|
|
|
|
var now = BridgeJson.NowMs();
|
|
var slots = new List<string>(_held.Keys);
|
|
|
|
for (int i = 0; i < slots.Count; i++)
|
|
{
|
|
var slot = slots[i];
|
|
var held = _held[slot];
|
|
|
|
if (held.Expired || held.Deadline != null)
|
|
continue;
|
|
|
|
var remaining = held.UntilMs - now;
|
|
var captured = slot;
|
|
|
|
held.Deadline = remaining <= 0L
|
|
? Timer.DelayCall(TimeSpan.Zero, () => OnDeadline(captured))
|
|
: Timer.DelayCall(TimeSpan.FromMilliseconds(remaining), () => OnDeadline(captured));
|
|
}
|
|
|
|
Console.WriteLine("[Bridge] leases: {0} hold(s) restored from the world save", _held.Count);
|
|
}
|
|
|
|
/// <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;
|
|
|
|
foreach (var entry in Keys())
|
|
{
|
|
if (String.Equals(entry.Key, key, StringComparison.Ordinal))
|
|
return entry;
|
|
}
|
|
|
|
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, string target, out string why)
|
|
{
|
|
why = null;
|
|
|
|
if (entry.Kind != LeaseKind.Config)
|
|
return BridgeLeaseTargets.Read(entry, target, out why);
|
|
|
|
return ReadConfig(entry);
|
|
}
|
|
|
|
private static string ReadConfig(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 bool Write(Catalog entry, string target, string canonical, out string why)
|
|
{
|
|
why = null;
|
|
|
|
if (entry.Kind != LeaseKind.Config)
|
|
return BridgeLeaseTargets.Write(entry, target, canonical, out why);
|
|
|
|
WriteConfig(entry, canonical);
|
|
return true;
|
|
}
|
|
|
|
private static void WriteConfig(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)
|
|
{
|
|
// A closed set is checked case-insensitively and answered in the catalog's own
|
|
// spelling, so `active` and `Active` both work and what is stored as the baseline
|
|
// is always a value `Enum.Parse` will accept back.
|
|
if (entry.Values != null)
|
|
{
|
|
for (int i = 0; i < entry.Values.Length; i++)
|
|
{
|
|
if (!String.Equals(entry.Values[i], raw.Trim(), StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
canonical = entry.Values[i];
|
|
return true;
|
|
}
|
|
|
|
why = "'" + raw + "' is not one of " + String.Join(", ", entry.Values);
|
|
return false;
|
|
}
|
|
|
|
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 KindName(LeaseKind k)
|
|
{
|
|
switch (k)
|
|
{
|
|
case LeaseKind.ObjectProperty: return "property";
|
|
case LeaseKind.Seasonal: return "seasonal";
|
|
default: return "config";
|
|
}
|
|
}
|
|
|
|
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 target, string current)
|
|
{
|
|
_drifted++;
|
|
|
|
Console.WriteLine("[Bridge] lease {0}: DRIFTED, the world is at {1} and was left alone",
|
|
Slot(key, target), current);
|
|
|
|
var sb = BridgeJson.Begin("lease.drifted");
|
|
if (reqId != null) sb.Str("reqId", reqId);
|
|
sb.Str("key", key).Str("target", target).Str("current", current);
|
|
BridgeLink.Emit(sb.End());
|
|
}
|
|
|
|
private static void Drop(string slot)
|
|
{
|
|
Held held;
|
|
|
|
if (_held.TryGetValue(slot, out held) && held.Deadline != null)
|
|
held.Deadline.Stop();
|
|
|
|
_held.Remove(slot);
|
|
}
|
|
|
|
// ---- the boot self-check (EVENTS.md N10) ----
|
|
|
|
/// <summary>
|
|
/// Probes every config key, and drops the ones that do not take.
|
|
///
|
|
/// §N10: *"a key that is live-read today can become `static readonly` in a later ServUO
|
|
/// release, and the failure is silent — the lease applies and nothing changes."* So each
|
|
/// key is written, read back and restored, all inside one synchronous call on the Core
|
|
/// thread. A key that does not read back what was just written is dropped from the
|
|
/// advertised catalog with a line on the console: better a capability that disappears
|
|
/// loudly than one that lies.
|
|
///
|
|
/// **The probe value is inside the key's own declared range**, so a shard that somehow
|
|
/// observed the intermediate value would see a legal one — and the restore is the same
|
|
/// `Config.Set` the lease plane uses, so a key that cannot be restored fails the probe
|
|
/// rather than being left probed.
|
|
///
|
|
/// The targeted planes cannot be probed this way and say so themselves; see
|
|
/// <see cref="BridgeLeaseTargets.SelfCheck"/>.
|
|
/// </summary>
|
|
public static void SelfCheck()
|
|
{
|
|
_droppedConfig.Clear();
|
|
|
|
for (int i = 0; i < ConfigKeys.Length; i++)
|
|
{
|
|
var entry = ConfigKeys[i];
|
|
var before = ReadConfig(entry);
|
|
var probe = Probe(entry, before);
|
|
|
|
if (probe == null)
|
|
{
|
|
// Nothing legal to write that differs from what is there. Not a failure: it
|
|
// means the range is a single value, and a key like that is leasable in the
|
|
// trivial sense and worth nothing. Left in the catalog rather than dropped,
|
|
// because the failure this check exists for is a key that does not TAKE.
|
|
continue;
|
|
}
|
|
|
|
WriteConfig(entry, probe);
|
|
var readBack = ReadConfig(entry);
|
|
WriteConfig(entry, before);
|
|
|
|
if (Same(entry, readBack, probe) && Same(entry, ReadConfig(entry), before))
|
|
continue;
|
|
|
|
_droppedConfig.Add(entry.Key);
|
|
Console.WriteLine(
|
|
"[Bridge] lease {0}: DROPPED from the catalog -- wrote {1}, read back {2}",
|
|
entry.Key, probe, readBack);
|
|
}
|
|
|
|
BridgeLeaseTargets.SelfCheck();
|
|
}
|
|
|
|
/// <summary>A legal value that differs from the current one, or null when there is none.</summary>
|
|
private static string Probe(Catalog entry, string current)
|
|
{
|
|
if (entry.Type == LeaseType.Bool)
|
|
return ParseBool(current) ? "false" : "true";
|
|
|
|
if (entry.Type == LeaseType.Text)
|
|
{
|
|
if (entry.Values == null)
|
|
return current == null ? "probe" : current + "-probe";
|
|
|
|
for (int i = 0; i < entry.Values.Length; i++)
|
|
{
|
|
if (!Same(entry, entry.Values[i], current))
|
|
return entry.Values[i];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
if (entry.Min >= entry.Max)
|
|
return null;
|
|
|
|
var low = entry.Type == LeaseType.Int
|
|
? ((long)entry.Min).ToString(CultureInfo.InvariantCulture)
|
|
: entry.Min.ToString("R", CultureInfo.InvariantCulture);
|
|
var high = entry.Type == LeaseType.Int
|
|
? ((long)entry.Max).ToString(CultureInfo.InvariantCulture)
|
|
: entry.Max.ToString("R", CultureInfo.InvariantCulture);
|
|
|
|
return Same(entry, current, low) ? high : low;
|
|
}
|
|
|
|
// ---- persistence ----
|
|
|
|
private static readonly string SavePath = Path.Combine("Saves/Bridge", "Leases.bin");
|
|
|
|
private const int SaveVersion = 1;
|
|
|
|
/// <summary>The longest target this shard will record. Bounded so a save file cannot be grown by a caller.</summary>
|
|
private const int MaxTargetLength = 120;
|
|
|
|
/// <summary>
|
|
/// Writes the holds whose VALUE survives a restart, and only those.
|
|
///
|
|
/// A config lease is deliberately absent: it is memory-only, so a restart already
|
|
/// restores it and writing the hold down would replace a free, guaranteed restore with a
|
|
/// record of a lease over a value that is already back. 11b's header makes that
|
|
/// argument; this is the same argument, applied to the planes where its premise is
|
|
/// false.
|
|
/// </summary>
|
|
private static void OnWorldSave(WorldSaveEventArgs e)
|
|
{
|
|
Persistence.Serialize(
|
|
SavePath,
|
|
writer =>
|
|
{
|
|
writer.Write(SaveVersion);
|
|
|
|
var keep = new List<Held>();
|
|
|
|
foreach (var held in _held.Values)
|
|
{
|
|
var entry = Lookup(held.Key);
|
|
|
|
if (entry == null || entry.Kind == LeaseKind.Config || held.Expired)
|
|
continue;
|
|
|
|
keep.Add(held);
|
|
}
|
|
|
|
writer.Write(keep.Count);
|
|
|
|
for (int i = 0; i < keep.Count; i++)
|
|
{
|
|
var held = keep[i];
|
|
|
|
writer.Write(held.Key ?? "");
|
|
writer.Write(held.Target ?? "");
|
|
writer.Write(held.Baseline ?? "");
|
|
writer.Write(held.Applied ?? "");
|
|
writer.Write(held.UntilMs);
|
|
writer.Write(held.RunId ?? "");
|
|
}
|
|
});
|
|
}
|
|
|
|
private static void OnWorldLoad()
|
|
{
|
|
Persistence.Deserialize(
|
|
SavePath,
|
|
reader =>
|
|
{
|
|
var version = reader.ReadInt();
|
|
|
|
if (version < 1)
|
|
return;
|
|
|
|
var count = reader.ReadInt();
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
var held = new Held
|
|
{
|
|
Key = reader.ReadString(),
|
|
Target = reader.ReadString(),
|
|
Baseline = reader.ReadString(),
|
|
Applied = reader.ReadString(),
|
|
UntilMs = reader.ReadLong(),
|
|
RunId = reader.ReadString(),
|
|
};
|
|
|
|
if (String.IsNullOrEmpty(held.Target))
|
|
held.Target = null;
|
|
|
|
if (String.IsNullOrEmpty(held.RunId))
|
|
held.RunId = null;
|
|
|
|
if (!String.IsNullOrEmpty(held.Key))
|
|
_held[Slot(held.Key, held.Target)] = held;
|
|
}
|
|
|
|
// The deadlines are armed by `OnServerStarted`, not here: `Timer` is not running
|
|
// yet at world load, and a timer created now would never fire.
|
|
});
|
|
}
|
|
}
|
|
}
|