using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
namespace Server.Custom.Bridge
{
///
/// 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 on its own 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 ,
/// 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.
///
public static class BridgeLeases
{
public enum LeaseType
{
Float,
Int,
Bool,
Text
}
///
/// 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
/// .
///
public enum LeaseKind
{
Config,
ObjectProperty,
Seasonal
}
/// One allowlisted key: what it is, what it holds, and what it is worth by default.
public sealed class Catalog
{
public string Key;
public string Label;
public LeaseKind Kind;
public LeaseType Type;
public double Min;
public double Max;
///
/// 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.
///
public string[] Values;
///
/// 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`.
///
public string TargetLabel;
///
/// 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.
///
public string Default;
}
///
/// 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.
///
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",
},
};
///
/// 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.
///
private static IEnumerable 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;
}
/// A lease this shard is holding, or has finished holding and not yet been asked about.
private sealed class Held
{
public string Key;
/// What the key applies to, or null for a single-value key.
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;
}
///
/// Held leases, keyed by — 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.
///
private static readonly Dictionary _held =
new Dictionary(StringComparer.Ordinal);
/// Config keys the boot self-check dropped. See .
private static readonly HashSet _droppedConfig = new HashSet(StringComparer.Ordinal);
private static Timer _prune;
private static long _applied, _released, _drifted, _expired, _refused;
///
/// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad`
/// fires, so this cannot be deferred to Initialize — 11b's rule, unchanged.
///
[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();
}
/// The pair a lease is held under: the key, and what it applies to.
private static string Slot(string key, string target)
{
return String.IsNullOrEmpty(target) ? key : key + "#" + target;
}
/// Stops and recreates the prune timer from current config. Called by `[bridge reload`.
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 ----
///
/// 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.
///
private static void OnApply(Dictionary 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());
}
///
/// 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.
///
private static bool Target(Catalog entry, Dictionary 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 ----
///
/// 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.
///
private static void OnRelease(Dictionary 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 ----
///
/// 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.
///
private static void OnList(Dictionary 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 ----
///
/// 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.
///
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());
}
///
/// 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.
///
private static void RearmDeadlines()
{
if (_held.Count == 0)
return;
var now = BridgeJson.NowMs();
var slots = new List(_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);
}
///
/// 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.
///
private static void Prune()
{
if (_held.Count == 0)
return;
var cutoff = BridgeJson.NowMs() - (long)BridgeConfig.LeaseGraceSec * 1000L;
List drop = null;
foreach (var kv in _held)
{
if (!kv.Value.Expired || kv.Value.ExpiredAtMs > cutoff)
continue;
if (drop == null)
drop = new List();
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;
}
///
/// 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
/// , on parsed values, for exactly that reason.
///
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.
}
/// Parses and range-checks a wire value, answering the canonical text for it.
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;
}
///
/// 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.
///
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) ----
///
/// 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
/// .
///
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();
}
/// A legal value that differs from the current one, or null when there is none.
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;
/// The longest target this shard will record. Bounded so a save file cannot be grown by a caller.
private const int MaxTargetLength = 120;
///
/// 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.
///
private static void OnWorldSave(WorldSaveEventArgs e)
{
Persistence.Serialize(
SavePath,
writer =>
{
writer.Write(SaveVersion);
var keep = new List();
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.
});
}
}
}