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