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