feat(bridge): protocol 7 — the Event System's shard half (Phase 16b cutover, 1 of 6) #26

Merged
whitlocktech merged 10 commits from edge into main 2026-09-09 19:53:49 +00:00
8 changed files with 2079 additions and 54 deletions
Showing only changes of commit 41ac2ccaaa - Show all commits

View File

@@ -282,6 +282,19 @@ EventsOracleAnswerCooldownSec=5
# are pruned. # are pruned.
EventsSweepSeconds=30 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 # The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose: # (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed # Config.Get returns the default of false when a key is missing, so a deployed

View File

@@ -259,6 +259,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status());
break; break;
} }
} }

View File

@@ -117,6 +117,9 @@ namespace Server.Custom.Bridge
public static int EventsOracleGreetCooldownSec { get; private set; } public static int EventsOracleGreetCooldownSec { get; private set; }
public static int EventsOracleAnswerCooldownSec { get; private set; } public static int EventsOracleAnswerCooldownSec { get; private set; }
public static int EventsSweepSeconds { 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) ---- // ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
public static SignupMode Signup { get; private set; } public static SignupMode Signup { get; private set; }
@@ -396,6 +399,26 @@ namespace Server.Custom.Bridge
if (EventsSweepSeconds < 1) if (EventsSweepSeconds < 1)
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 // Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
// unrecognized* value falls back to Game (the safest — no website creation), so a // unrecognized* value falls back to Game (the safest — no website creation), so a
// typo can never accidentally open provisioning. // typo can never accidentally open provisioning.

View File

@@ -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
{
/// <summary>
/// Protocol 7, part b. The two lease planes whose value lives on something that is
/// <b>already in the world</b> — 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 <b>targeted</b>: 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 <b>third</b> 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.
/// </summary>
public static class BridgeLeaseTargets
{
// ---- the object-property allowlist ----
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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 ----
/// <summary>
/// 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.
/// </summary>
private static readonly EventType[] SeasonalExcluded =
{
EventType.TreasuresOfTokuno,
};
/// <summary>The status values a seasonal lease may hold. `EventStatus` has exactly three.</summary>
public static readonly string[] SeasonalValues = { "Inactive", "Active", "Seasonal" };
public const string SeasonalKey = "Seasonal.Status";
// ---- what the catalog offers ----
/// <summary>Every targeted key this shard offers, in `lease.list` order.</summary>
public static IEnumerable<BridgeLeases.Catalog> 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",
};
}
}
/// <summary>The seasonal events an author may name, for the module's option source.</summary>
public static IEnumerable<string> 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 ----
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Writes a targeted key. Answers false with a reason rather than throwing.</summary>
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 ----
/// <summary>
/// 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.
/// </summary>
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<XmlSpawner>()
.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<string> _dropped = new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
{
/// <summary>
/// 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: <b>done is done</b>.
///
/// ── 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.
/// </summary>
public static class BridgeOneShots
{
// ---- the grant allowlist ----
/// <summary>
/// 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.
/// </summary>
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 ----
/// <summary>
/// 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.
/// </summary>
private static void OnCatalog(Dictionary<string, object> 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<string, object> 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<string>();
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());
}
/// <summary>
/// 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.
/// </summary>
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<string, object> 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());
}
}
}

View File

@@ -188,6 +188,37 @@ namespace Server.Custom.Bridge
_runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused); _runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused);
} }
/// <summary>
/// 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.
/// </summary>
public static List<int> MemberSerials(string runId)
{
Run run;
if (runId == null || !_runs.TryGetValue(runId, out run))
return null;
var serials = new List<int>(run.Members.Count);
foreach (var member in run.Members.Values)
serials.Add(member.Serial);
return serials;
}
// ---- participation.open ---- // ---- participation.open ----
/// <summary> /// <summary>

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection;
using Server.Accounting; using Server.Accounting;
using Server.Commands; using Server.Commands;
@@ -41,6 +42,12 @@ namespace Server.Custom
/// password &lt;account&gt; &lt;pw&gt; set a game account's password (for a login probe) /// password &lt;account&gt; &lt;pw&gt; set a game account's password (for a login probe)
/// worldgone &lt;serial&gt; delete an object BEHIND the ownership registry's /// worldgone &lt;serial&gt; delete an object BEHIND the ownership registry's
/// back, playing the player who killed it /// 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 &lt;target&gt; &lt;prop&gt; &lt;v&gt; set a property BEHIND the lease plane's back,
/// which is the only way to reach `drifted` here
/// propread &lt;target&gt; &lt;prop&gt; read one back, to assert a restore landed
/// seasonlist every seasonal event and its status
/// save a world save /// save a world save
/// shutdown a CLEAN shutdown, so the bridge emits server.shutdown /// 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. // 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. // This deletes the object and leaves the row, which is exactly what a sword does.
case "worldgone": WorldGone(Arg(parts, 1)); break; 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; case "save": Say("saving"); Misc.AutoSave.Save(); break;
// A clean shutdown, which is the only kind that EMITS. `Stop-Process` drops the // 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 // socket and the shard says nothing, so a killed shard is indistinguishable from
@@ -201,6 +212,187 @@ namespace Server.Custom
entity.Delete(); entity.Delete();
Say("worldgone: deleted " + text + " and told nobody"); Say("worldgone: deleted " + text + " and told nobody");
} }
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>
/// 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.
/// </summary>
private static void PropSet(string target, string property, string value)
{
if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property) || value == null)
{
Say("propset <serial|uniqueId> <property> <value>");
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);
}
}
/// <summary>Reads a property back, so the rig can assert a restore actually landed.</summary>
private static void PropRead(string target, string property)
{
if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property))
{
Say("propread <serial|uniqueId> <property>");
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));
}
/// <summary>Says what the seasonal system holds, which is the seasonal lease's target list.</summary>
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) private static string Arg(string[] parts, int i)
{ {