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