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) {