Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs
wtclaude f6a86ff8c2 feat(bridge): what an event borrows, and the two one-shots (Phase 12b)
The shard half of protocol 7 part b. Two lease planes whose value lives on
something already in the world, and two verbs that cannot be taken back.

A LEASE HERE MUST BE PERSISTED, AND THE CONFIG PLANE'S MUST NOT

11b's fail-safe is stated plainly in its own header: a lease that never reaches
disk means a shard restart is 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
and 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 back -- it puts the CHANGE back and throws away the
deadline timer that was going to undo it, leaving the world at the leased value
with nothing here remembering it is borrowed.

So the Bridge gains its THIRD save file, `Saves/Bridge/Leases.bin`, written by
the same `EventSink.WorldSave` that writes what it describes, with deadlines
re-armed at load. A deadline that passed while the shard was down fires AT ONCE:
the promise was "back at baseline by then", and extending it would silently turn
a two-hour lease into however long the outage was. Config holds are still not
written down -- the same argument, applied to planes where its premise is false.

A TARGET IS A SERIAL OR A UniqueId, AND BOTH ARE NEEDED

A serial is what `[props` shows a GM. An `XmlSpawner.UniqueId` is what the
shard's own `Spawns/*.xml` carry -- and it is not a convenience: a dropdown built
from serials is IMPOSSIBLE, because serials are assigned when the world is built
and nothing off-shard knows them. A lease addressable only by serial could have
no authoring list at all.

`Spawner` and `XmlSpawner` share all four property names, which is a fact about
this tree rather than a convenience: the spawn files load as XmlSpawners while
`[add spawner` makes the native one. And it is `MaxCount`, not the `Amount`
EVENTS_PLAN.md named -- there is no such property. `MinDelay`/`MaxDelay` are
TimeSpans, so the wire carries seconds.

The allowlist is checked against the object's OWN type, which is the sentence the
whole plane rests on: a serial is a number a caller chooses, so that check is all
that stands between `Spawner.MaxCount` and any item on the shard. Reflection is
bounded three ways -- the pair must be in the catalog, the property must carry
`CommandProperty` (so this can never reach further than `[set` could), and its
CLR type must be one this file renders.

THE SELF-CHECK, AND THE ONE FAILURE NO PROBE CAN CATCH

§N10 in full: a config key is probed live (write, read back, restore) because
there is exactly one of it. A property CANNOT be -- thousands of instances and no
canonical one, so probing would mean writing to somebody's spawner at boot. What
is verified instead is everything verifiable without touching the world.

And `TreasuresOfTokuno` is excluded by name, because `IsActive()` reads its own
`DropEra` rather than `Status`: the write succeeds, the value reads back, a
compare-and-set restore passes, and the capability does nothing at all. That is
N10's "capability that lies" in its purest form and the only way to find it is to
read the source. §G also called this toggle "small and safe" -- it is safe, but
`OnStatusChange()` generates or removes world content for six of the eight.

THE ONE-SHOTS

Who receives a grant is answered HERE. The website has the list too, but a module
cannot read core's tables, so the alternative was a new core surface handing
participants to a module. Not needed: 11b's participation ledger already holds
them, keyed by the same serials. A run with no ledger is a 404; a run whose
ledger is open and empty is a 200 with `granted: 0`, because an event nobody
attended still happened. An undeliverable grant is DELETED rather than dropped --
`AddItem` failing on a full backpack would otherwise leave it at (0,0).

A save stops the world, so it is rate-limited rather than capped, counting
ServUO's own autosave as the last one. Refused, never queued: a queued save would
land at a moment nobody chose.

VERIFIED

Compiles clean (0 warnings, 0 errors). Then a full walk on the real local ServUO
57.4 world against the release sidecar:

- all six catalog keys survive the boot self-check; `current` is correctly absent
  on the targeted rows and filled when a target is named;
- a spawner reads the same by UniqueId and by serial;
- TWO RUNS ON TWO SPAWNERS BOTH SUCCEED while a second run on the SAME spawner is
  refused -- the whole reason for the targeted ref;
- a GM edit behind the plane's back yields `lease.drifted` and the world is left
  at 55, not reverted; a clean release restores to baseline;
- ToT refused with its own reason, a bad status refused, Fellowship toggled;
- grant: no ledger 404, empty ledger 200 `granted: 0`, unknown item 400, over the
  stack bound 400; save 200 then 429 inside the interval;
- BOTH HOLDS AND BOTH LEASED VALUES SURVIVE save + clean shutdown + restart, the
  deadlines re-arm, and a release across the restart still compare-and-sets;
- with only a CONFIG lease held, `Leases.bin` is 8 bytes and names nothing;
- refusals: targeted-with-no-target, untargeted-with-a-target, out of range, over
  30 days, a target that is not there, and a `ChainChest` refused as a spawner;
- 90 seconds becomes `00:01:30` and the baseline reads back as 18000;
- a deleted target reads `unreadable` and releases `targetGone: true`.

The test world was never saved after the deliberate deletion, so it is intact.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:07:12 -05:00

709 lines
27 KiB
C#

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