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. }); } } }