From f6a86ff8c2729033c978b2d2a7e06868ac17596d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 08:07:12 -0500 Subject: [PATCH] feat(bridge): what an event borrows, and the two one-shots (Phase 12b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- overlay/Config/Bridge.cfg | 13 + overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 1 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 23 + .../Custom/Bridge/BridgeLeaseTargets.cs | 708 ++++++++++++++++++ overlay/Scripts/Custom/Bridge/BridgeLeases.cs | 706 +++++++++++++++-- .../Scripts/Custom/Bridge/BridgeOneShots.cs | 459 ++++++++++++ .../Custom/Bridge/BridgeParticipation.cs | 31 + tools/scaffolding/BridgeRigDriver.cs | 192 +++++ 8 files changed, 2079 insertions(+), 54 deletions(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs create mode 100644 overlay/Scripts/Custom/Bridge/BridgeOneShots.cs diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index 9b67429..5da1626 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -282,6 +282,19 @@ EventsOracleAnswerCooldownSec=5 # are pruned. EventsSweepSeconds=30 +# Item grants (Phase 12b). The first bounds how many characters one grant may reach -- +# the run's participation ledger is the recipient list, so this is a bound on the size of +# an event rather than on a number somebody typed. The second bounds one hand. +# Both REFUSE rather than clamp: the website records what was handed out. +EventsMaxGrantPerRun=200 +EventsMaxGrantStack=1000 + +# The shortest gap between world saves, counted from the last save by anybody -- +# ServUO's own autosave included. A save stops the world, so this is a rate limit rather +# than a cap, and a save asked for too soon is refused rather than queued: a queued save +# would land at a moment nobody chose. Set to 0 to allow a save at any time. +EventsMinSaveIntervalSec=300 + # The test scaffolding in tools/scaffolding/ reads its own flags from this file # (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose: # Config.Get returns the default of false when a key is missing, so a deployed diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 08c800e..3d6c131 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -259,6 +259,7 @@ namespace Server.Custom.Bridge e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status()); break; } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 70e26f6..dedf4bc 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -117,6 +117,9 @@ namespace Server.Custom.Bridge public static int EventsOracleGreetCooldownSec { get; private set; } public static int EventsOracleAnswerCooldownSec { get; private set; } public static int EventsSweepSeconds { get; private set; } + public static int EventsMaxGrantPerRun { get; private set; } + public static int EventsMaxGrantStack { get; private set; } + public static int EventsMinSaveIntervalSec { get; private set; } // ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ---- public static SignupMode Signup { get; private set; } @@ -396,6 +399,26 @@ namespace Server.Custom.Bridge if (EventsSweepSeconds < 1) EventsSweepSeconds = 1; + // Phase 12b. How many characters one grant may reach, and how many of one item may go + // into one hand. Both refuse rather than clamp, on `LeaseMaxDurationSec`'s argument: + // the website records what was handed out, and a silent clamp would make its ledger a + // description of a grant that did not happen. + EventsMaxGrantPerRun = Config.Get("Bridge.EventsMaxGrantPerRun", 200); + if (EventsMaxGrantPerRun < 0) + EventsMaxGrantPerRun = 0; + + EventsMaxGrantStack = Config.Get("Bridge.EventsMaxGrantStack", 1000); + if (EventsMaxGrantStack < 1) + EventsMaxGrantStack = 1; + + // A save stops the world, so this one is a rate limit rather than a cap. It counts from + // the last save by ANYBODY -- ServUO's own autosave included -- because an event save + // thirty seconds after the hourly one is the same freeze twice, and this shard is the + // only half that can see both. + EventsMinSaveIntervalSec = Config.Get("Bridge.EventsMinSaveIntervalSec", 300); + if (EventsMinSaveIntervalSec < 0) + EventsMinSaveIntervalSec = 0; + // Account provisioning. An absent SignupMode defaults to Hybrid; a *present but // unrecognized* value falls back to Game (the safest — no website creation), so a // typo can never accidentally open provisioning. diff --git a/overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs b/overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs new file mode 100644 index 0000000..3a4cc7d --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs @@ -0,0 +1,708 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; + +using Server.Engines.SeasonalEvents; +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// Protocol 7, part b. The two lease planes whose value lives on something that is + /// already in the world — a property on an existing object, and a seasonal event's + /// status. + /// + /// `BridgeLeases` owns the wire, the deadline, the compare-and-set and the bookkeeping; + /// this file owns everything that is specific to ServUO, which is the same split the config + /// plane has had since 11b. What is new is that both planes here are targeted: a + /// lease names a key AND the thing it applies to, because `Spawner.MaxCount` is one + /// capability over thousands of spawners rather than one value. + /// + /// ── Why a lease here must be PERSISTED, and the config plane's must not ──────────────── + /// + /// 11b's config lease is deliberately memory-only, and its header states the reason: a lease + /// that never reaches disk makes a shard restart 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`. It 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 of them back — it puts the CHANGE back and throws away the deadline timer + /// that was going to undo it. The world is then stuck at the leased value with nothing on + /// this shard remembering that it is borrowed, which is the exact failure the lease framing + /// exists to make impossible. + /// + /// So the hold is persisted, in the Bridge's third save file, beside 11b's + /// `Participation.bin` and 12a's `Owned.bin` — and, like both of those, written by the same + /// `EventSink.WorldSave` that writes what it describes, so it cannot get out of step with + /// it. The deadline is re-armed on load, from the stored absolute time. + /// + /// **A deadline that has already passed while the shard was down fires at once**, rather + /// than being dropped or extended. The promise the website was given is "back at baseline by + /// then"; a shard that was off for the whole hold has not kept it, and restoring immediately + /// is the only reading of it that is still true. + /// + /// ── Reflection, bounded by an allowlist ─────────────────────────────────────────────── + /// + /// Properties are read and written through reflection, and the allowlist below is what makes + /// that defensible rather than `[set` with extra steps. A pair not named here does not exist + /// as far as this plane is concerned, whatever a caller sends; every entry additionally + /// requires the property to carry `CommandProperty`, so nothing internal is reachable even + /// if a pair were added carelessly. Reflection rather than a hand-written switch is what + /// lets the boot self-check (§N10) actually verify a pair — a switch would compile happily + /// against a property ServUO had renamed. + /// + public static class BridgeLeaseTargets + { + // ---- the object-property allowlist ---- + + /// + /// One allowlisted property, and every type it may be applied to. + /// + /// **`Spawner` and `XmlSpawner` share all four names**, which is a fact about this tree + /// rather than a convenience: the shard's own `Spawns/*.xml` load as XmlSpawners and + /// `[add spawner` makes the native one, so a catalog that named only one of them would + /// work on a shard until the day it did not. They also share the semantics — `MaxCount` + /// is the ceiling the next tick spawns up to on both. + /// + private sealed class PropEntry + { + public string Key; + public string Label; + public string Property; + public string[] Types; + public BridgeLeases.LeaseType Type; + public double Min; + public double Max; + + /// + /// True when the CLR property is a `TimeSpan` and the wire carries seconds. + /// + /// The lease type vocabulary is int/float/bool/string and there is no duration in + /// it, so a respawn window has to cross as a number. Seconds rather than minutes + /// because the spawn files' own `DelayInSec` flag proves both are in use, and a unit + /// that cannot express five seconds cannot express the shard's own data. + /// + public bool Seconds; + } + + private static readonly string[] SpawnerTypes = + { + "Server.Mobiles.Spawner", + "Server.Mobiles.XmlSpawner", + }; + + private static readonly PropEntry[] Props = + { + new PropEntry + { + Key = "Spawner.MaxCount", + Label = "Spawner: how many at once", + Property = "MaxCount", + Types = SpawnerTypes, + Type = BridgeLeases.LeaseType.Int, + Min = 0.0, + Max = 100.0, + }, + new PropEntry + { + Key = "Spawner.MinDelay", + Label = "Spawner: shortest respawn wait", + Property = "MinDelay", + Types = SpawnerTypes, + Type = BridgeLeases.LeaseType.Int, + Min = 0.0, + Max = 86400.0, + Seconds = true, + }, + new PropEntry + { + Key = "Spawner.MaxDelay", + Label = "Spawner: longest respawn wait", + Property = "MaxDelay", + Types = SpawnerTypes, + Type = BridgeLeases.LeaseType.Int, + Min = 0.0, + Max = 86400.0, + Seconds = true, + }, + new PropEntry + { + Key = "Spawner.Running", + Label = "Spawner: running", + Property = "Running", + Types = SpawnerTypes, + Type = BridgeLeases.LeaseType.Bool, + Min = 0.0, + Max = 0.0, + }, + }; + + // ---- the seasonal allowlist ---- + + /// + /// The seasonal events an event may hold, and the one it may not. + /// + /// **`TreasuresOfTokuno` is excluded, and its exclusion is the whole argument for §N10's + /// self-check made concrete.** `SeasonalEventEntry.IsActive()` special-cases it and reads + /// `TreasuresOfTokuno.DropEra` instead of `Status`, so setting its status writes a field + /// that nothing consults. The write succeeds, the value reads back, a compare-and-set + /// restore would pass — every mechanism in this file would report a working lease over a + /// capability that does nothing at all. That is the failure N10 names ("a capability that + /// lies"), and no runtime probe can catch this one, so it is caught by reading the source + /// and excluded here by name. + /// + /// The remaining eight are real, and six of them do MORE than flip a flag: + /// `OnStatusChange()` calls a `CheckEnabled()` that generates or removes world content + /// for Doom, Khaldun, Sorcerer's Dungeon, Krampus, Rising Tide and Fellowship. §G called + /// this toggle "small and safe"; it is safe, because ServUO does it to itself from a + /// staff gump, but it is not small, and an author scheduling one should be told so. The + /// label says it. + /// + private static readonly EventType[] SeasonalExcluded = + { + EventType.TreasuresOfTokuno, + }; + + /// The status values a seasonal lease may hold. `EventStatus` has exactly three. + public static readonly string[] SeasonalValues = { "Inactive", "Active", "Seasonal" }; + + public const string SeasonalKey = "Seasonal.Status"; + + // ---- what the catalog offers ---- + + /// Every targeted key this shard offers, in `lease.list` order. + public static IEnumerable Catalog() + { + for (int i = 0; i < Props.Length; i++) + { + var p = Props[i]; + + if (_dropped.Contains(p.Key)) + continue; + + yield return new BridgeLeases.Catalog + { + Key = p.Key, + Label = p.Label, + Kind = BridgeLeases.LeaseKind.ObjectProperty, + Type = p.Type, + Min = p.Min, + Max = p.Max, + Default = p.Type == BridgeLeases.LeaseType.Bool ? "true" : "0", + TargetLabel = "Which spawner", + }; + } + + if (!_dropped.Contains(SeasonalKey)) + { + yield return new BridgeLeases.Catalog + { + Key = SeasonalKey, + Label = "Seasonal event status", + Kind = BridgeLeases.LeaseKind.Seasonal, + Type = BridgeLeases.LeaseType.Text, + Default = "Inactive", + Values = SeasonalValues, + TargetLabel = "Which seasonal event", + }; + } + } + + /// The seasonal events an author may name, for the module's option source. + public static IEnumerable SeasonalTargets() + { + foreach (EventType type in Enum.GetValues(typeof(EventType))) + { + if (Array.IndexOf(SeasonalExcluded, type) >= 0) + continue; + + if (SeasonalEventSystem.GetEntry(type) == null) + continue; + + yield return type.ToString(); + } + } + + // ---- reading and writing ---- + + /// + /// Reads a targeted key, or answers null when the target cannot be resolved. + /// + /// **Null is "I could not find it", never a value**, and the caller turns it into a + /// refusal. A missing spawner answered as `0` would let a lease be taken over nothing, + /// record `0` as the baseline, and restore that baseline onto whatever object later + /// claimed the serial. + /// + public static string Read(BridgeLeases.Catalog entry, string target, out string why) + { + why = null; + + if (entry.Kind == BridgeLeases.LeaseKind.Seasonal) + { + var seasonal = SeasonalEntry(target, out why); + return seasonal == null ? null : seasonal.Status.ToString(); + } + + var prop = Lookup(entry.Key); + + if (prop == null) + { + why = "no lease is offered for key '" + entry.Key + "'"; + return null; + } + + object obj = Resolve(target, prop, out why); + + if (obj == null) + return null; + + var info = Info(obj.GetType(), prop, out why); + + if (info == null) + return null; + + var raw = info.GetValue(obj, null); + return Render(prop, raw); + } + + /// Writes a targeted key. Answers false with a reason rather than throwing. + public static bool Write(BridgeLeases.Catalog entry, string target, string canonical, out string why) + { + why = null; + + if (entry.Kind == BridgeLeases.LeaseKind.Seasonal) + { + var seasonal = SeasonalEntry(target, out why); + + if (seasonal == null) + return false; + + EventStatus status; + + if (!TryParseStatus(canonical, out status)) + { + why = "'" + canonical + "' is not one of " + String.Join(", ", SeasonalValues); + return false; + } + + // The setter fires `OnStatusChange()`, which for six of the eight generates or + // removes world content. That is ServUO's own behaviour from its own staff gump and + // is exactly what makes the toggle worth having; it is noted here so nobody reads + // this line as a field assignment. + seasonal.Status = status; + return true; + } + + var prop = Lookup(entry.Key); + + if (prop == null) + { + why = "no lease is offered for key '" + entry.Key + "'"; + return false; + } + + object obj = Resolve(target, prop, out why); + + if (obj == null) + return false; + + var info = Info(obj.GetType(), prop, out why); + + if (info == null) + return false; + + object value; + + if (!Parse(prop, canonical, out value, out why)) + return false; + + info.SetValue(obj, value, null); + return true; + } + + // ---- target resolution ---- + + /// + /// Finds the object a target names. + /// + /// **Two ways to name one, and both are needed.** A serial is what `[props` shows a GM + /// and what a rig can type; an `XmlSpawner.UniqueId` is what the shard's own + /// `Spawns/*.xml` carry, which is the only naming the website can offer from the atlas + /// without the shard being up. A dropdown built from serials is impossible — they are + /// assigned when the world is built, and nothing off-shard knows them. + /// + /// The UniqueId lookup is a scan of `World.Items`, and it stays a scan on purpose: it + /// runs once per lease apply, which is a rare, human-scheduled operation, and a cache + /// would be a second copy of the world to keep correct across `[add` and deletion. + /// + private static object Resolve(string target, PropEntry prop, out string why) + { + why = null; + + if (String.IsNullOrEmpty(target)) + { + why = "this lease needs a target"; + return null; + } + + Item item = null; + int serial; + + if (TryParseSerial(target, out serial)) + { + item = World.FindItem((Serial)serial); + + if (item == null) + { + why = "nothing on this shard has serial " + target; + return null; + } + } + else + { + item = World.Items.Values + .OfType() + .FirstOrDefault(s => String.Equals(s.UniqueId, target, StringComparison.OrdinalIgnoreCase)); + + if (item == null) + { + why = "no spawner on this shard carries the id '" + target + "'"; + return null; + } + } + + if (item.Deleted) + { + why = "that object has been deleted"; + return null; + } + + // **The allowlist is checked against the object's OWN type, not against what was + // asked for.** This is the sentence the whole plane rests on: a serial is a number a + // caller chooses, so the only thing standing between `Spawner.MaxCount` and any item on + // the shard is this check. + var name = item.GetType().FullName; + var ok = false; + + for (int i = 0; i < prop.Types.Length && !ok; i++) + { + // Assignable rather than equal, so a shard's own subclass of Spawner is leasable — + // an operator who derived from it has not changed what `MaxCount` means. + var declared = ScriptCompiler.FindTypeByFullName(prop.Types[i]); + ok = declared != null && declared.IsInstanceOfType(item); + } + + if (!ok) + { + why = String.Format("{0} is a {1}, and this lease applies to {2}", + target, name, String.Join(" or ", prop.Types)); + return null; + } + + return item; + } + + private static SeasonalEventEntry SeasonalEntry(string target, out string why) + { + why = null; + + if (String.IsNullOrEmpty(target)) + { + why = "this lease needs a target"; + return null; + } + + EventType type; + + try + { + type = (EventType)Enum.Parse(typeof(EventType), target, true); + } + catch + { + why = "'" + target + "' is not a seasonal event on this shard"; + return null; + } + + if (Array.IndexOf(SeasonalExcluded, type) >= 0) + { + why = target + " reads its own era rather than this status, so leasing it would do nothing"; + return null; + } + + var entry = SeasonalEventSystem.GetEntry(type); + + if (entry == null) + { + why = "this shard has no entry for " + target; + return null; + } + + return entry; + } + + // ---- the boot self-check (EVENTS.md N10) ---- + + private static readonly HashSet _dropped = new HashSet(StringComparer.Ordinal); + + /// + /// Drops any targeted key that cannot possibly work, and says so on the console. + /// + /// **It cannot be the config plane's check, and that is a property of the thing rather + /// than a shortcut.** A config key is probed by writing to it and reading it back, + /// because there is exactly one of it. A property has thousands of instances and no + /// canonical one; probing would mean picking somebody's spawner at boot and writing to + /// it. So what is verified here is everything that can be verified without touching the + /// world: the type still resolves, the property still exists on it, it is still public + /// and settable, it still carries `CommandProperty`, and its CLR type is still the one + /// this file knows how to render. That is precisely the failure N10 was written for — a + /// property that a later ServUO renamed or made read-only — and it catches it at boot + /// rather than at 3am inside an unattended run. + /// + public static void SelfCheck() + { + _dropped.Clear(); + + for (int i = 0; i < Props.Length; i++) + { + var prop = Props[i]; + string why; + + if (Verify(prop, out why)) + continue; + + _dropped.Add(prop.Key); + Console.WriteLine("[Bridge] lease {0}: DROPPED from the catalog -- {1}", prop.Key, why); + } + + // The seasonal plane's own check is the one thing it can verify without writing: that + // this shard has entries at all. `SeasonalEventSystem.LoadEntries()` runs in + // `Configure()`, so an empty list here means an operator has removed the system rather + // than that the check ran too early. + if (!SeasonalTargets().Any()) + { + _dropped.Add(SeasonalKey); + Console.WriteLine("[Bridge] lease {0}: DROPPED from the catalog -- this shard has no seasonal events", SeasonalKey); + } + } + + private static bool Verify(PropEntry prop, out string why) + { + why = null; + var found = 0; + + for (int i = 0; i < prop.Types.Length; i++) + { + var type = ScriptCompiler.FindTypeByFullName(prop.Types[i]); + + if (type == null) + continue; + + string detail; + var info = Info(type, prop, out detail); + + if (info == null) + { + why = prop.Types[i] + ": " + detail; + return false; + } + + found++; + } + + if (found == 0) + { + why = "none of " + String.Join(", ", prop.Types) + " exists on this shard"; + return false; + } + + return true; + } + + /// + /// The property, if it is one this plane may touch. Null with a reason otherwise. + /// + /// `CommandProperty` is required and is not decoration: it is ServUO's own marker for + /// "a staff member may set this", so requiring it means this plane can never reach + /// further into an object than `[set` could — which is the bound §G draws, kept even + /// though the allowlist already makes it unreachable. + /// + private static PropertyInfo Info(Type type, PropEntry prop, out string why) + { + why = null; + + var info = type.GetProperty(prop.Property, BindingFlags.Public | BindingFlags.Instance); + + if (info == null) + { + why = "no property named " + prop.Property; + return null; + } + + if (!info.CanRead || !info.CanWrite) + { + why = prop.Property + " is not both readable and writable"; + return null; + } + + if (info.GetCustomAttributes(typeof(CommandPropertyAttribute), true).Length == 0) + { + why = prop.Property + " is not a CommandProperty"; + return null; + } + + if (!Matches(prop, info.PropertyType)) + { + why = prop.Property + " is a " + info.PropertyType.Name + ", which this lease cannot carry"; + return null; + } + + return info; + } + + private static bool Matches(PropEntry prop, Type clr) + { + if (prop.Seconds) + return clr == typeof(TimeSpan); + + switch (prop.Type) + { + case BridgeLeases.LeaseType.Int: return clr == typeof(int); + case BridgeLeases.LeaseType.Float: return clr == typeof(double); + case BridgeLeases.LeaseType.Bool: return clr == typeof(bool); + default: return clr == typeof(string); + } + } + + // ---- value rendering ---- + + private static string Render(PropEntry prop, object raw) + { + if (raw == null) + return ""; + + if (prop.Seconds) + return ((long)((TimeSpan)raw).TotalSeconds).ToString(CultureInfo.InvariantCulture); + + switch (prop.Type) + { + case BridgeLeases.LeaseType.Int: + return Convert.ToInt64(raw, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); + + case BridgeLeases.LeaseType.Float: + return Convert.ToDouble(raw, CultureInfo.InvariantCulture).ToString("R", CultureInfo.InvariantCulture); + + case BridgeLeases.LeaseType.Bool: + return ((bool)raw) ? "true" : "false"; + + default: + return Convert.ToString(raw, CultureInfo.InvariantCulture); + } + } + + private static bool Parse(PropEntry prop, string canonical, out object value, out string why) + { + value = null; + why = null; + + if (prop.Seconds) + { + double seconds; + + if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out seconds)) + { + why = "'" + canonical + "' is not a number of seconds"; + return false; + } + + value = TimeSpan.FromSeconds(seconds); + return true; + } + + switch (prop.Type) + { + case BridgeLeases.LeaseType.Int: + { + double n; + + if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out n)) + { + why = "'" + canonical + "' is not a number"; + return false; + } + + value = (int)n; + return true; + } + + case BridgeLeases.LeaseType.Bool: + { + value = String.Equals(canonical, "true", StringComparison.OrdinalIgnoreCase) || canonical == "1"; + return true; + } + + default: + { + double d; + + if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out d)) + { + why = "'" + canonical + "' is not a number"; + return false; + } + + value = d; + return true; + } + } + } + + // ---- helpers ---- + + private static PropEntry Lookup(string key) + { + for (int i = 0; i < Props.Length; i++) + { + if (String.Equals(Props[i].Key, key, StringComparison.Ordinal)) + return Props[i]; + } + + return null; + } + + private static bool TryParseSerial(string raw, out int serial) + { + serial = 0; + + if (String.IsNullOrEmpty(raw)) + return false; + + if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return Int32.TryParse(raw.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out serial); + } + + // A bare decimal is a serial too, but a UniqueId is a GUID and never all digits, so + // there is nothing to disambiguate. + return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out serial); + } + + private static bool TryParseStatus(string raw, out EventStatus status) + { + status = EventStatus.Inactive; + + for (int i = 0; i < SeasonalValues.Length; i++) + { + if (!String.Equals(SeasonalValues[i], raw, StringComparison.OrdinalIgnoreCase)) + continue; + + status = (EventStatus)Enum.Parse(typeof(EventStatus), SeasonalValues[i], false); + return true; + } + + return false; + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeLeases.cs b/overlay/Scripts/Custom/Bridge/BridgeLeases.cs index 6ea1045..d10effd 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeLeases.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeLeases.cs @@ -1,12 +1,25 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; namespace Server.Custom.Bridge { /// - /// Protocol 6, part b. The lease plane: a live configuration value the website may hold for - /// a bounded time, and which this shard puts back on its own when the time is up. + /// 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: @@ -23,7 +36,14 @@ namespace Server.Custom.Bridge /// unattended, scheduled world change defensible: the failure mode is a world back at /// baseline early, never a world stuck changed indefinitely. /// - /// ── What a lease is made of, and why it is memory-only ───────────────────────────────── + /// ── 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, @@ -62,7 +82,7 @@ namespace Server.Custom.Bridge /// public static class BridgeLeases { - private enum LeaseType + public enum LeaseType { Float, Int, @@ -70,15 +90,50 @@ namespace Server.Custom.Bridge 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. - private sealed class Catalog + 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. /// @@ -100,12 +155,13 @@ namespace Server.Custom.Bridge /// mean here — the failure this catalog exists to prevent is a key that applies cleanly /// and does nothing at all. /// - private static readonly Catalog[] Keys = + 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, @@ -113,10 +169,31 @@ namespace Server.Custom.Bridge }, }; + /// + /// 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; @@ -133,13 +210,36 @@ namespace Server.Custom.Bridge 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) @@ -154,9 +254,17 @@ namespace Server.Custom.Bridge 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() { @@ -202,9 +310,17 @@ namespace Server.Custom.Bridge return; } - string canonical; + 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); @@ -234,40 +350,58 @@ namespace Server.Custom.Bridge return; } + var slot = Slot(entry.Key, target); + Held existing; - if (_held.TryGetValue(entry.Key, out existing) && !existing.Expired) + if (_held.TryGetValue(slot, out existing) && !existing.Expired) { Err(reqId, "apply", - "'" + entry.Key + "' is already leased" + + "'" + 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); + 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"), }; - Write(entry, canonical); + 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(entry.Key)); - _held[entry.Key] = held; + 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})", - entry.Key, baseline, canonical, holdMs / 1000L, held.RunId ?? "-"); + 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) @@ -279,12 +413,53 @@ namespace Server.Custom.Bridge 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 ---- /// @@ -310,48 +485,88 @@ namespace Server.Custom.Bridge 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(entry.Key, out 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(entry.Key); - Drifted(reqId, entry.Key, held.Current); + 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(entry.Key); + 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)); + .Str("current", Read(entry, target, out why)); BridgeLink.Emit(already.End()); return; } var expected = BridgeJson.GetString(o, "expected"); - var current = Read(entry); + 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(entry.Key); - Drifted(reqId, entry.Key, current); + Drop(slot); + Drifted(reqId, entry.Key, target, current); return; } @@ -361,7 +576,6 @@ namespace Server.Custom.Bridge baseline = held.Baseline; string canonical; - string why; if (!Coerce(entry, baseline, out canonical, out why)) { @@ -371,16 +585,22 @@ namespace Server.Custom.Bridge return; } - Write(entry, canonical); - Drop(entry.Key); + 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}", entry.Key, canonical); + 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); @@ -410,27 +630,85 @@ namespace Server.Custom.Bridge 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\":["); - for (int i = 0; i < Keys.Length; i++) - { - var entry = Keys[i]; + var first = true; - if (i > 0) + 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); - sb.Append(",\"current\":"); - BridgeJson.Text(sb, Read(entry)); + + 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) { @@ -440,7 +718,7 @@ namespace Server.Custom.Bridge Held held; - if (_held.TryGetValue(entry.Key, out held)) + if (_held.TryGetValue(Slot(entry.Key, target), out held)) { sb.Append(",\"held\":true"); sb.Append(",\"baseline\":"); @@ -466,6 +744,42 @@ namespace Server.Custom.Bridge 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()); } @@ -477,14 +791,14 @@ namespace Server.Custom.Bridge /// 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 key) + private static void OnDeadline(string slot) { Held held; - if (!_held.TryGetValue(key, out held) || held.Expired) + if (!_held.TryGetValue(slot, out held) || held.Expired) return; - var entry = Lookup(key); + var entry = Lookup(held.Key); if (entry == null) return; @@ -494,28 +808,44 @@ namespace Server.Custom.Bridge held.ExpiredAtMs = BridgeJson.NowMs(); _expired++; - var current = Read(entry); + string why; + var current = Read(entry, held.Target, out why); - if (!Same(entry, current, held.Applied)) + 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", - key, current, held.Applied); + slot, current, held.Applied); } - else + else if (Write(entry, held.Target, held.Baseline, out why)) { - Write(entry, held.Baseline); held.Restored = true; Console.WriteLine("[Bridge] lease {0}: deadline passed, restored to {1} without being asked", - key, held.Baseline); + 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", key) + .Str("key", held.Key) + .Str("target", held.Target) .Str("runId", held.RunId) .Str("baseline", held.Baseline) .Str("applied", held.Applied) @@ -525,6 +855,42 @@ namespace Server.Custom.Bridge .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. /// @@ -577,10 +943,10 @@ namespace Server.Custom.Bridge if (key == null) return null; - for (int i = 0; i < Keys.Length; i++) + foreach (var entry in Keys()) { - if (String.Equals(Keys[i].Key, key, StringComparison.Ordinal)) - return Keys[i]; + if (String.Equals(entry.Key, key, StringComparison.Ordinal)) + return entry; } return null; @@ -596,7 +962,17 @@ namespace Server.Custom.Bridge /// 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) + 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) { @@ -616,7 +992,18 @@ namespace Server.Custom.Bridge } } - private static void Write(Catalog entry, string canonical) + 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) { @@ -672,6 +1059,24 @@ namespace Server.Custom.Bridge 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; } @@ -748,6 +1153,16 @@ namespace Server.Custom.Bridge 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) @@ -782,26 +1197,209 @@ namespace Server.Custom.Bridge BridgeLink.Emit(sb.End()); } - private static void Drifted(string reqId, string key, string current) + 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", key, current); + 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("current", current); + sb.Str("key", key).Str("target", target).Str("current", current); BridgeLink.Emit(sb.End()); } - private static void Drop(string key) + private static void Drop(string slot) { Held held; - if (_held.TryGetValue(key, out held) && held.Deadline != null) + if (_held.TryGetValue(slot, out held) && held.Deadline != null) held.Deadline.Stop(); - _held.Remove(key); + _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. + }); } } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeOneShots.cs b/overlay/Scripts/Custom/Bridge/BridgeOneShots.cs new file mode 100644 index 0000000..49e3a42 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeOneShots.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +using Server.Items; +using Server.Misc; +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// Protocol 7, part b. The two verbs that are neither owned nor borrowed: an item put into + /// someone's hands, and a world save. + /// + /// EVENTS_PLAN.md Phase 12b. Everything else the event plane does is a thing this shard can + /// take back — a creature it deletes, a value it restores. These two are not, and they are + /// in the same file because that is what they have in common: done is done. + /// + /// ── The grant, and why §8's exclusion of it was reopened separately ──────────────────── + /// + /// `ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both — + /// deliberately as two reversals rather than one, because permitting an event to create a + /// creature says nothing about permitting it to hand out loot. What makes this grant a + /// different proposition from the one §8 refused is four properties it did not have then, + /// and all four are visible in this file: + /// + /// - **Declared, not typed.** The allowlist below is the shard's, and an item not on it + /// cannot be granted however the request is spelled. There is no free-text type name + /// reaching `Activator.CreateInstance` — that is `[add`, which §G excludes. + /// - **Bounded.** `EventsMaxGrantPerRun` bounds the whole run and + /// `EventsMaxGrantStack` bounds one hand; both refuse rather than clamp. + /// - **Attributable.** The run id rides on every grant and is logged with it. + /// - **Idempotent.** Protocol 6's key means a lost acknowledgement cannot double a + /// reward, which is the failure that made §G call the grant un-retryable when it was + /// written. It is retryable now, and 11a is the whole reason. + /// + /// ── Who receives it is answered HERE, and that is the interesting decision ───────────── + /// + /// A grant needs a list of people, and the website has one — `event_run_participants`. It + /// would have had to reach through core to get it, because a module cannot read core's + /// tables, so the alternative was a new core surface handing participants to a module's + /// `perform()`. + /// + /// It is not needed: **this shard already has the list**, in 11b's run-scoped participation + /// ledger, keyed by the same character serials the website's `member_key` holds. So the + /// grant names a run and the recipients are resolved from the ledger the run has been + /// keeping all along — no new core surface, no participant list crossing the wire twice, + /// and no window in which the two disagree. + /// + /// A run with no open ledger grants to nobody and says so, rather than granting to + /// everybody online. "Everyone present" is not a thing this file will guess at. + /// + /// ── The save ────────────────────────────────────────────────────────────────────────── + /// + /// `ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a + /// phase boundary — the point in an event after which what has happened is safe from a + /// crash — and `world.save.before` / `world.save.after` are already on the wire, so the + /// acknowledgement it needs exists. + /// + /// **A save stops the world**, so unlike every other verb here it is rate-limited by the + /// shard rather than only capped: `EventsMinSaveIntervalSec` refuses a save that comes too + /// soon after the last one, whether the last one was an event's or ServUO's own autosave. + /// Refuses, never queues — a queued save would arrive at a moment nobody chose. + /// + public static class BridgeOneShots + { + // ---- the grant allowlist ---- + + /// + /// One grantable item: what an author names it, and what this shard builds. + /// + /// **The list is short and boring on purpose.** Every entry is a thing an event + /// plausibly hands out and nothing here is equipment with rolled properties — an + /// artifact generator behind an unattended schedule is a different proposition and one + /// nobody has asked for. An operator who wants more edits this array, which is a + /// deployment they control rather than a field on a web form. + /// + private sealed class GrantEntry + { + public string Key; + public string Label; + public string Type; + } + + private static readonly GrantEntry[] Grants = + { + new GrantEntry { Key = "gold", Label = "Gold", Type = "Server.Items.Gold" }, + new GrantEntry { Key = "cloak", Label = "Cloak", Type = "Server.Items.Cloak" }, + new GrantEntry { Key = "sandals", Label = "Sandals", Type = "Server.Items.Sandals" }, + new GrantEntry { Key = "candle", Label = "Candle", Type = "Server.Items.Candle" }, + new GrantEntry { Key = "earrings", Label = "Silver earrings", Type = "Server.Items.SilverEarrings" }, + new GrantEntry { Key = "fireworks", Label = "Fireworks wand", Type = "Server.Items.FireworksWand" }, + new GrantEntry { Key = "bottle", Label = "Message in a bottle", Type = "Server.Items.MessageInABottle" }, + }; + + private static long _granted, _saves, _refused; + + private static long _lastSaveMs; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("item.grant", OnGrant); + BridgeBoot.RegisterHandler("item.catalog", OnCatalog); + BridgeBoot.RegisterHandler("world.save", OnSave); + + // Counted whoever asked for it, so the interval below also covers ServUO's own + // autosave. An event save landing thirty seconds after the hourly one is the same + // freeze twice, and the shard is the only half that can see both. + EventSink.WorldSave += (e) => { _lastSaveMs = BridgeJson.NowMs(); }; + } + + public static string Status() + { + return String.Format("oneshots(granted={0} saves={1} refused={2})", _granted, _saves, _refused); + } + + // ---- item.catalog ---- + + /// + /// What this shard is willing to grant. + /// + /// A read, so the website's option source can offer real choices — and the module holds + /// the same list, so the dropdown still works with the shard down. Two copies of a + /// short allowlist, exactly like the lease bounds: the module's is what makes a bad + /// value a refusal on a form, and this one is what is true when the website is wrong. + /// + private static void OnCatalog(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "catalog")) + return; + + var sb = BridgeJson.Begin("item.catalog.ok"); + if (reqId != null) sb.Str("reqId", reqId); + + sb.Append(",\"items\":["); + + for (int i = 0; i < Grants.Length; i++) + { + if (i > 0) + sb.Append(','); + + sb.Append("{\"key\":"); + BridgeJson.Text(sb, Grants[i].Key); + sb.Append(",\"label\":"); + BridgeJson.Text(sb, Grants[i].Label); + sb.Append(",\"stackable\":").Append(Stackable(Grants[i]) ? "true" : "false"); + sb.Append('}'); + } + + sb.Append(']'); + sb.Append(",\"maxPerRun\":").Append(BridgeConfig.EventsMaxGrantPerRun); + sb.Append(",\"maxStack\":").Append(BridgeConfig.EventsMaxGrantStack); + BridgeLink.Emit(sb.End()); + } + + // ---- item.grant ---- + + private static void OnGrant(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "grant")) + return; + + var runId = BridgeJson.GetString(o, "runId"); + + if (String.IsNullOrEmpty(runId)) + { + Err(reqId, "grant", "a grant needs a runId"); + return; + } + + var entry = LookupGrant(BridgeJson.GetString(o, "item")); + + if (entry == null) + { + Err(reqId, "grant", "this shard does not grant '" + BridgeJson.GetString(o, "item") + "'"); + return; + } + + var amount = (int)BridgeJson.GetLong(o, "amount", 1L); + + if (amount < 1) + { + Err(reqId, "grant", "a grant needs a positive amount"); + return; + } + + // Refused rather than clamped, on `LeaseMaxDurationSec`'s argument from 11b: the + // website is the half that records what was handed out, and a silent clamp would make + // its ledger a description of a grant that did not happen. + if (amount > BridgeConfig.EventsMaxGrantStack) + { + Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture, + "this shard grants at most {0} at a time, and {1} were asked for", + BridgeConfig.EventsMaxGrantStack, amount)); + return; + } + + var toBank = String.Equals(BridgeJson.GetString(o, "where"), "bank", StringComparison.OrdinalIgnoreCase); + + var serials = BridgeParticipation.MemberSerials(runId); + + if (serials == null) + { + Err(reqId, "grant", "run " + runId + " has no participation ledger open on this shard"); + return; + } + + if (serials.Count == 0) + { + // Not a refusal: a run whose event nobody attended is a real outcome, and the + // website needs to record a grant that reached nobody rather than a failed step it + // will retry against the same empty ledger. + var none = BridgeJson.Begin("item.grant.ok"); + if (reqId != null) none.Str("reqId", reqId); + none.Str("runId", runId).Str("item", entry.Key); + none.Append(",\"granted\":0,\"missed\":[]"); + BridgeLink.Emit(none.End()); + return; + } + + if (serials.Count > BridgeConfig.EventsMaxGrantPerRun) + { + Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture, + "that run has {0} participants and this shard grants to at most {1}", + serials.Count, BridgeConfig.EventsMaxGrantPerRun)); + return; + } + + var hue = (int)BridgeJson.GetLong(o, "hue", 0L); + var name = BridgeJson.GetString(o, "name"); + + if (name != null && name.Length > 40) + name = name.Substring(0, 40); + + var granted = 0; + var missed = new List(); + + for (int i = 0; i < serials.Count; i++) + { + var mobile = World.FindMobile((Serial)serials[i]) as PlayerMobile; + + if (mobile == null || mobile.Deleted) + { + missed.Add(Hex(serials[i]) + ": no such character"); + continue; + } + + string why; + + if (Give(mobile, entry, amount, hue, name, toBank, out why)) + granted++; + else + missed.Add(Hex(serials[i]) + ": " + why); + } + + _granted += granted; + + Console.WriteLine("[Bridge] grant {0} x{1} to run {2}: {3} of {4}", + entry.Key, amount, runId, granted, serials.Count); + + var sb = BridgeJson.Begin("item.grant.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("runId", runId).Str("item", entry.Key); + sb.Append(",\"granted\":").Append(granted); + sb.Append(",\"missed\":["); + + for (int i = 0; i < missed.Count; i++) + { + if (i > 0) sb.Append(','); + BridgeJson.Text(sb, missed[i]); + } + + sb.Append(']'); + BridgeLink.Emit(sb.End()); + } + + /// + /// Builds and hands over one grant, or says why it could not. + /// + /// **A grant that cannot be delivered is deleted rather than dropped on the floor.** + /// `AddItem` failing on a full backpack would otherwise leave the item in the world at + /// (0,0) — a real ServUO trap — and an event that quietly littered the map with + /// undeliverable rewards would be worse than one that reported a miss. + /// + private static bool Give(PlayerMobile mobile, GrantEntry entry, int amount, int hue, string name, bool toBank, out string why) + { + why = null; + + var container = toBank ? (Container)mobile.BankBox : mobile.Backpack; + + if (container == null || container.Deleted) + { + why = toBank ? "no bank box" : "no backpack"; + return false; + } + + Item item; + + try + { + item = Build(entry); + } + catch (Exception e) + { + why = "could not be created (" + e.Message + ")"; + return false; + } + + if (item == null) + { + why = "could not be created"; + return false; + } + + if (item.Stackable) + { + item.Amount = amount; + } + else if (amount > 1) + { + // A non-stackable granted in quantity would be N items, and N items is N chances to + // overflow a backpack halfway through with no way to report which half landed. + // One is what an event means by "a commemorative cloak" anyway. + item.Delete(); + why = "is not stackable, so it can only be granted one at a time"; + return false; + } + + if (hue > 0) + item.Hue = hue; + + if (!String.IsNullOrEmpty(name)) + item.Name = name; + + if (!container.TryDropItem(mobile, item, false)) + { + item.Delete(); + why = toBank ? "bank box is full" : "backpack is full"; + return false; + } + + return true; + } + + private static Item Build(GrantEntry entry) + { + var type = ScriptCompiler.FindTypeByFullName(entry.Type); + + if (type == null) + return null; + + return Activator.CreateInstance(type) as Item; + } + + private static bool Stackable(GrantEntry entry) + { + Item probe = null; + + try + { + probe = Build(entry); + return probe != null && probe.Stackable; + } + catch + { + return false; + } + finally + { + if (probe != null) + probe.Delete(); + } + } + + // ---- world.save ---- + + private static void OnSave(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + if (!Ready(reqId, "save")) + return; + + var since = BridgeJson.NowMs() - _lastSaveMs; + var minimum = (long)BridgeConfig.EventsMinSaveIntervalSec * 1000L; + + if (_lastSaveMs > 0L && since < minimum) + { + // **Refused, not queued.** A queued save would land at a moment nobody chose, in the + // middle of whatever the next step is doing. Refusing tells the website exactly what + // happened, and a save skipped because one just happened has cost nothing. + Err(reqId, "save", String.Format(CultureInfo.InvariantCulture, + "this shard saves at most every {0} seconds, and the last save was {1} seconds ago", + BridgeConfig.EventsMinSaveIntervalSec, since / 1000L)); + return; + } + + // `world.save.before` and `world.save.after` are emitted by `BridgeEvents` from ServUO's + // own hooks, so the acknowledgement of what actually happened rides those rather than + // being asserted here. This reply says only that the save was STARTED. + _saves++; + AutoSave.Save(); + + var sb = BridgeJson.Begin("world.save.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Bool("started", true); + BridgeLink.Emit(sb.End()); + } + + // ---- helpers ---- + + private static GrantEntry LookupGrant(string key) + { + if (key == null) + return null; + + for (int i = 0; i < Grants.Length; i++) + { + if (String.Equals(Grants[i].Key, key, StringComparison.OrdinalIgnoreCase)) + return Grants[i]; + } + + return null; + } + + private static string Hex(int serial) + { + return "0x" + serial.ToString("X", CultureInfo.InvariantCulture); + } + + 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("oneshot.error"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action).Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs b/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs index 785adc0..f6a3dac 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs @@ -188,6 +188,37 @@ namespace Server.Custom.Bridge _runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused); } + /// + /// Every character serial this run has recorded, or null when the run is unknown here. + /// + /// Added by Phase 12b for the item grant, which needs a list of people and would + /// otherwise have had to reach through core for one — the website's + /// `event_run_participants` holds the same serials, but a module cannot read core's + /// tables and adding a core surface to hand them over would have been a second copy of + /// a list this shard has been keeping all along. + /// + /// **Null and empty are different answers.** Null is "no ledger is open for that run", + /// which is a refusal; empty is "the ledger is open and nobody came", which is a real + /// outcome a grant has to be able to report rather than retry. + /// + /// A closed run still answers: closing stops the counting, and a reward handed out + /// after the event has ended is the ordinary case rather than an edge one. + /// + public static List MemberSerials(string runId) + { + Run run; + + if (runId == null || !_runs.TryGetValue(runId, out run)) + return null; + + var serials = new List(run.Members.Count); + + foreach (var member in run.Members.Values) + serials.Add(member.Serial); + + return serials; + } + // ---- participation.open ---- /// diff --git a/tools/scaffolding/BridgeRigDriver.cs b/tools/scaffolding/BridgeRigDriver.cs index 48ce16f..8bb61c1 100644 --- a/tools/scaffolding/BridgeRigDriver.cs +++ b/tools/scaffolding/BridgeRigDriver.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Reflection; using Server.Accounting; using Server.Commands; @@ -41,6 +42,12 @@ namespace Server.Custom /// password <account> <pw> set a game account's password (for a login probe) /// worldgone <serial> delete an object BEHIND the ownership registry's /// back, playing the player who killed it + /// spawnerlist [n] name a few XmlSpawners, serial AND UniqueId -- + /// the two ways a property lease names its target + /// propset <target> <prop> <v> set a property BEHIND the lease plane's back, + /// which is the only way to reach `drifted` here + /// propread <target> <prop> read one back, to assert a restore landed + /// seasonlist every seasonal event and its status /// save a world save /// shutdown a CLEAN shutdown, so the bridge emits server.shutdown /// @@ -154,6 +161,10 @@ namespace Server.Custom // removes an object also drops its registry row, so the two never disagree. // This deletes the object and leaves the row, which is exactly what a sword does. case "worldgone": WorldGone(Arg(parts, 1)); break; + case "spawnerlist": SpawnerList(Arg(parts, 1)); break; + case "propset": PropSet(Arg(parts, 1), Arg(parts, 2), Arg(parts, 3)); break; + case "propread": PropRead(Arg(parts, 1), Arg(parts, 2)); break; + case "seasonlist": SeasonList(); break; case "save": Say("saving"); Misc.AutoSave.Save(); break; // A clean shutdown, which is the only kind that EMITS. `Stop-Process` drops the // socket and the shard says nothing, so a killed shard is indistinguishable from @@ -201,6 +212,187 @@ namespace Server.Custom entity.Delete(); Say("worldgone: deleted " + text + " and told nobody"); } + /// + /// Names a few spawners, with both ways of addressing one. + /// + /// A property lease is targeted by a serial or by an `XmlSpawner.UniqueId`, and the rig + /// has no other way to learn either — the website's dropdown comes from the atlas, and + /// the rig does not have one. + /// + private static void SpawnerList(string raw) + { + var want = 5; + + if (!String.IsNullOrEmpty(raw)) + Int32.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out want); + + if (want < 1) + want = 1; + + var shown = 0; + + foreach (var item in World.Items.Values) + { + if (shown >= want) + break; + + var xml = item as Mobiles.XmlSpawner; + + if (xml == null || xml.Deleted) + continue; + + Say(String.Format(CultureInfo.InvariantCulture, + "spawner 0x{0:X} uid={1} maxCount={2} running={3} name={4}", + item.Serial.Value, xml.UniqueId, xml.MaxCount, xml.Running, xml.Name ?? "-")); + + shown++; + } + + if (shown == 0) + Say("spawnerlist: this world has no XmlSpawners"); + } + + /// + /// Sets a property on an object BEHIND the lease plane's back. + /// + /// 11b's `configset` exists because `Config.Set` has one caller in the whole tree, so + /// nothing on a stock shard could drift a config lease. A spawner is the opposite — a GM + /// drifts one with `[props` in about four seconds — but the rig has no client, so it + /// needs the same door. This is the only way to reach `drifted` on a property lease + /// without one, and it is exactly what a staff member's `[set` does. + /// + private static void PropSet(string target, string property, string value) + { + if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property) || value == null) + { + Say("propset "); + return; + } + + Item item = null; + uint parsed; + var text = target.Trim(); + var isSerial = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed) + : UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed); + + if (isSerial) + { + item = World.FindItem((Serial)unchecked((int)parsed)); + } + else + { + foreach (var candidate in World.Items.Values) + { + var xml = candidate as Mobiles.XmlSpawner; + + if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase)) + continue; + + item = xml; + break; + } + } + + if (item == null || item.Deleted) + { + Say("propset: nothing at " + text); + return; + } + + var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance); + + if (info == null || !info.CanWrite) + { + Say("propset: " + item.GetType().Name + " has no writable " + property); + return; + } + + try + { + object typed; + + if (info.PropertyType == typeof(TimeSpan)) + typed = TimeSpan.FromSeconds(Double.Parse(value, CultureInfo.InvariantCulture)); + else if (info.PropertyType == typeof(bool)) + typed = String.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1"; + else + typed = Convert.ChangeType(value, info.PropertyType, CultureInfo.InvariantCulture); + + info.SetValue(item, typed, null); + Say("propset: " + property + " on " + text + " is now " + value + ", and nobody was told"); + } + catch (Exception e) + { + Say("propset: " + e.Message); + } + } + + /// Reads a property back, so the rig can assert a restore actually landed. + private static void PropRead(string target, string property) + { + if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property)) + { + Say("propread "); + return; + } + + Item item = null; + uint parsed; + var text = target.Trim(); + var isSerial = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed) + : UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed); + + if (isSerial) + { + item = World.FindItem((Serial)unchecked((int)parsed)); + } + else + { + foreach (var candidate in World.Items.Values) + { + var xml = candidate as Mobiles.XmlSpawner; + + if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase)) + continue; + + item = xml; + break; + } + } + + if (item == null || item.Deleted) + { + Say("propread: nothing at " + text); + return; + } + + var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance); + + if (info == null) + { + Say("propread: " + item.GetType().Name + " has no " + property); + return; + } + + var raw = info.GetValue(item, null); + Say("propread: " + property + " = " + Convert.ToString(raw, CultureInfo.InvariantCulture)); + } + + /// Says what the seasonal system holds, which is the seasonal lease's target list. + private static void SeasonList() + { + foreach (Engines.SeasonalEvents.EventType type in Enum.GetValues(typeof(Engines.SeasonalEvents.EventType))) + { + var entry = Engines.SeasonalEvents.SeasonalEventSystem.GetEntry(type); + + Say(entry == null + ? "season " + type + " = (no entry)" + : "season " + type + " = " + entry.Status); + } + } + private static string Arg(string[] parts, int i) { -- 2.49.1