Merge pull request 'feat(bridge): what an event borrows, and the two one-shots (Phase 12b)' (#24) from feature/events-p12b-borrowed-and-oneshots into edge
Reviewed-on: #24
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
708
overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs
Normal file
708
overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs
Normal 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
459
overlay/Scripts/Custom/Bridge/BridgeOneShots.cs
Normal file
459
overlay/Scripts/Custom/Bridge/BridgeOneShots.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 <account> <pw> set a game account's password (for a login probe)
|
/// password <account> <pw> set a game account's password (for a login probe)
|
||||||
/// worldgone <serial> delete an object BEHIND the ownership registry's
|
/// worldgone <serial> 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 <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
|
/// 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)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user