diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..3e22e2e
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,12 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "modernuoschemagenerator": {
+ "version": "4.1.0",
+ "commands": [
+ "ModernUOSchemaGenerator"
+ ]
+ }
+ }
+}
diff --git a/.gitignore b/.gitignore
index 72d975a..63a815b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@
/Projects/*/bin
/Projects/*/Generated
/Projects/ModernSpawner.Benchmarks/BenchmarkDotNet.Artifacts/
+# dotnet run --project writes the artifacts under the working directory, not the project.
+/BenchmarkDotNet.Artifacts/
/TestResults/
# Working notes and generated docs live here uncommitted (same convention as ModernUO)
diff --git a/CLAUDE.md b/CLAUDE.md
index 29dc380..9cd16cf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,7 +26,7 @@ pull requests; until a PR merges, the submodule may be pinned to that PR's head
```sh
dotnet build ModernSpawner.slnx # builds ModernUO Server/UOContent from the submodule too
-dotnet test Projects/ModernSpawner.Tests # 427 tests; the lifecycle collection boots a ModernUO test server
+dotnet test Projects/ModernSpawner.Tests # 587 tests; the lifecycle collection boots a ModernUO test server
dotnet build -c Analyze # analyzers + Rules.ruleset
```
@@ -36,7 +36,9 @@ dotnet build -c Analyze # analyzers + Rules.ruleset
World-backed tests (`Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs`) share a
process-wide ModernUO bootstrap in `Projects/ModernSpawner.Tests/Fixtures/ModernSpawnerTestServer.cs`
and run in a `DisableParallelization` xunit collection; use that fixture for any new test that needs a
-live spawner rather than standing up World/Core state by hand.
+live spawner rather than standing up World/Core state by hand. The 12k-spawner trigger perf harness
+(`Projects/ModernSpawner.Tests/Perf/TriggerPerfHarness.cs`) is part of that count but is a no-op unless
+`MODERNSPAWNER_PERF=1` is set, so the default run stays sub-second.
## Rules
@@ -60,14 +62,24 @@ ModernSpawner-specific:
base contract (`Entries`, `EntrySpan`, `CreateEntry`, `AddEntryCore`, …) runs over it and the lifecycle
hooks (`OnStarted`, `OnSpawned`, `OnSpawnedDeath`, entry-aware `GetSpawnPosition`) carry the modern
behaviour. Never add a parallel entry list or hide base members with `new`.
-- Triggers register through `TriggerSystem`; proximity uses `Item.HandlesOnMovement`/`OnMovement`, speech
- uses `HandlesOnSpeech`, skill uses `Server.Misc.SkillEvents.SkillUsed` (players only). Extended (beyond
- 24-tile) proximity is stubbed pending a ModernUO area-movement API.
-- Trigger list changes go through the generated helpers (`AddToTriggerDefinitions`,
- `RemoveFromTriggerDefinitionsAt`, `ClearTriggerDefinitions`), then call `EnsureTriggersActive()`; the
- `TriggerActivated` setter does this for you. Never call `TriggerSystem.ActivateTriggers` directly — it is
- not idempotent, and within `Projects/ModernSpawner` `EnsureTriggersActive` is its only caller (tests call
- it deliberately, to build the stale registrations teardown has to survive).
+- Triggers are a state machine, not a bool: dispatch (`OnMovement`/`OnSpeech`/kill/skill) never spawns —
+ a match calls `spawner.RequestCycle(trigger, in context)`, which only mutates spawner state (cooldown,
+ refractory, the pending-cycle queue) and asks `TriggerSystem` for a drain; the outermost dispatch runs
+ the cycle once it returns. All of that state (the gate set, the queue, cooldowns, kill counts, per-entry
+ deadlines) lives on the spawner, so a tick never looks anything up. See `dev-docs/architecture.md` §5 for
+ the tick-precedence and event transition table.
+- Trigger definitions are `TriggerDefinition { Id, Text }` with a stable id generated once; mutate the list
+ only through `AddTriggerDefinition`/`RemoveTriggerDefinitionAt`/`ClearTriggerDefinitions` — they assign
+ the id and call `EnsureTriggersActive()` for you. Never call `TriggerSystem.ActivateTriggers` directly:
+ it is not idempotent on its own, and within `Projects/ModernSpawner`, `ModernSpawner.EnsureTriggersActive`
+ is its only caller (tests call it deliberately, to build the stale-registration cases teardown has to
+ survive). Registration follows `TriggerActivated` and the definition list, never `Running` — `Start()`/
+ `Stop()` only arm or disarm the timer.
+- Proximity uses `Item.HandlesOnMovement`/`OnMovement`, speech uses `HandlesOnSpeech`, skill uses
+ `Server.Misc.SkillEvents.SkillUsed` (players only). Extended (beyond 24-tile) proximity is clamped to
+ `Core.GlobalMaxUpdateRange` with a warning.
+- Per-event-trigger tokens (`wake:`, `mode:`, `when:`) are a suffix, recognised only after a grammar's full
+ positional list — never write one where a positional field could be misread as a token name.
## ModernUO changes
diff --git a/ModernUO b/ModernUO
index 309fcfe..c02909e 160000
--- a/ModernUO
+++ b/ModernUO
@@ -1 +1 @@
-Subproject commit 309fcfeb27aa7cc943d44e8c6e17d2ae2e4d687a
+Subproject commit c02909e2c8cffe8834ab35ad5e5e41e3b16d26cf
diff --git a/Projects/ModernSpawner.Benchmarks/Program.cs b/Projects/ModernSpawner.Benchmarks/Program.cs
index 4894778..9e47b01 100644
--- a/Projects/ModernSpawner.Benchmarks/Program.cs
+++ b/Projects/ModernSpawner.Benchmarks/Program.cs
@@ -22,7 +22,9 @@ dotnet run -c Release [options]
--compile Run compilation cost benchmarks
--set Run property set benchmarks
--expr Run XmlSpawner vs ModernSpawner expression comparison
+ --triggers Run the D2 trigger dispatch benchmarks (set lookup, request/drain)
--quick Run with fewer iterations (for quick testing)
+ --filter
Hand the arguments to BenchmarkDotNet's own switcher, e.g. --filter *TriggerDispatch*
--help, -h Show this help
Examples:
@@ -30,6 +32,7 @@ dotnet run -c Release --all
dotnet run -c Release --expr
dotnet run -c Release --spawner --condition
dotnet run -c Release --quick --simple
+ dotnet run -c Release -- --filter *TriggerDispatch*
""");
return;
}
@@ -44,6 +47,14 @@ dotnet run -c Release --quick --simple
config = config.WithOptions(ConfigOptions.DisableOptimizationsValidator);
}
+// A --filter run is handed straight to BenchmarkDotNet's switcher, which understands globs over the
+// whole assembly. The curated flags below stay for the suites that predate it.
+if (args.Contains("--filter"))
+{
+ BenchmarkSwitcher.FromAssembly(typeof(TriggerDispatchLookupBenchmarks).Assembly).Run(args, config);
+ return;
+}
+
// Determine which benchmarks to run
var runAll = args.Contains("--all") || args.Length == 0;
var benchmarkTypes = new List();
@@ -80,6 +91,12 @@ dotnet run -c Release --quick --simple
benchmarkTypes.Add(typeof(ComplexExpressionBenchmarks));
}
+if (runAll || args.Contains("--triggers"))
+{
+ benchmarkTypes.Add(typeof(TriggerDispatchLookupBenchmarks));
+ benchmarkTypes.Add(typeof(TriggerDispatchRequestDrainBenchmarks));
+}
+
if (benchmarkTypes.Count == 0)
{
Console.WriteLine("No benchmarks selected. Use --help for options.");
diff --git a/Projects/ModernSpawner.Benchmarks/TriggerDispatchBenchmarks.cs b/Projects/ModernSpawner.Benchmarks/TriggerDispatchBenchmarks.cs
new file mode 100644
index 0000000..6928f92
--- /dev/null
+++ b/Projects/ModernSpawner.Benchmarks/TriggerDispatchBenchmarks.cs
@@ -0,0 +1,456 @@
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Order;
+
+namespace ModernSpawner.Benchmarks;
+
+///
+/// Mock trigger shapes for the D2 dispatch benchmarks. They mirror the real ones closely enough to be
+/// honest about cost - a typed list walked by index, a virtual Evaluate per trigger, a bounded
+/// of queued cycles, a reused drain list and a bitmask gate - without dragging
+/// the ModernUO engine into this project, which deliberately has no reference to it.
+///
+public abstract class MockTrigger
+{
+ /// Definition index, the position the gate bitmask keys off.
+ public int DefinitionIndex { get; set; }
+
+ /// The range a proximity-shaped trigger matches within.
+ public int Range { get; set; } = 8;
+
+ /// Evaluates the trigger against a point, the way dispatch does per event.
+ /// Event X.
+ /// Event Y.
+ /// True when the trigger matches.
+ public abstract bool Evaluate(int x, int y);
+}
+
+/// Stands in for ProximityTrigger: a range compare against the spawner location.
+public sealed class MockProximityTrigger : MockTrigger
+{
+ /// Spawner X.
+ public int SpawnerX { get; set; }
+
+ /// Spawner Y.
+ public int SpawnerY { get; set; }
+
+ ///
+ public override bool Evaluate(int x, int y)
+ {
+ var dx = x - SpawnerX;
+ var dy = y - SpawnerY;
+
+ if (dx < 0)
+ {
+ dx = -dx;
+ }
+
+ if (dy < 0)
+ {
+ dy = -dy;
+ }
+
+ return (dx > dy ? dx : dy) <= Range;
+ }
+}
+
+/// Stands in for a non-proximity trigger class, so the typed lists are not all one type.
+public sealed class MockOtherTrigger : MockTrigger
+{
+ ///
+ public override bool Evaluate(int x, int y) => false;
+}
+
+///
+/// Stands in for TriggerSet: one object per spawner holding the per-class typed lists plus the
+/// counts the tick guards read.
+///
+public sealed class MockTriggerSet
+{
+ /// The registration generation a queued cycle is stamped with.
+ public int Generation { get; set; }
+
+ /// Every parsed trigger in definition order.
+ public List All { get; } = [];
+
+ /// The proximity triggers, walked by index on every movement event.
+ public List Proximity { get; } = [];
+
+ /// The speech triggers.
+ public List Speech { get; } = [];
+
+ /// The kill triggers.
+ public List Kill { get; } = [];
+
+ /// The skill triggers.
+ public List Skill { get; } = [];
+
+ /// The gates.
+ public List Gates { get; } = [];
+
+ /// How many parsed triggers are event sources.
+ public int EventCount { get; set; }
+
+ /// How many parsed triggers are gates.
+ public int GateCount { get; set; }
+}
+
+///
+/// Stands in for the spawner half of the D2 state machine: the bitmask gate, the bounded pending-slot
+/// list and the drain bookkeeping the trigger system drives.
+///
+public sealed class MockTriggerSpawner
+{
+ /// Spawner X, for the mock proximity evaluation.
+ public int X { get; set; }
+
+ /// Spawner Y, for the mock proximity evaluation.
+ public int Y { get; set; }
+
+ /// The open-gate bitmask: gates 0-63 by definition index.
+ public ulong OpenGateBits { get; set; }
+
+ /// How many gates this spawner carries.
+ public int GateCount { get; set; }
+
+ /// How many event sources this spawner carries.
+ public int EventCount { get; set; }
+
+ /// The queue bound; zero is the XmlSpawner run-now-or-drop shape.
+ public int MaxPendingCycles { get; set; } = 1;
+
+ /// Whether this spawner is already queued for a drain in the outer dispatch.
+ public bool DrainRequested { get; set; }
+
+ /// How many cycles this spawner has drained in the outer dispatch in progress.
+ public int DrainsThisRound { get; set; }
+
+ /// The queued cycles, allocated lazily exactly as the spawner does.
+ public List? PendingSlots { get; set; }
+
+ /// Number of queued cycles.
+ public int PendingCycleCount => PendingSlots?.Count ?? 0;
+
+ /// A spawner with no gates is always open; otherwise one window must be open.
+ public bool GateOpen => GateCount == 0 || OpenGateBits != 0;
+}
+
+/// Stands in for PendingCycle: the queued slot allocated on acceptance.
+/// The spawner that owns the slot.
+/// The definition that bought the cycle.
+/// Serial of the mobile that raised the event.
+public sealed class MockPendingCycle(MockTriggerSpawner spawner, Guid triggerId, int mobileSerial)
+{
+ /// The spawner that owns this slot.
+ public MockTriggerSpawner Spawner { get; } = spawner;
+
+ /// The definition that bought this cycle.
+ public Guid TriggerId { get; } = triggerId;
+
+ /// Serial of the mobile that raised the event.
+ public int MobileSerial { get; } = mobileSerial;
+}
+
+///
+/// D2 condition 1: a movement, speech or kill dispatch does one dictionary lookup and then
+/// walks a typed list, instead of the pre-D2 shape where the dispatcher probed one dictionary per
+/// trigger class until it found the spawner.
+///
+/// N = 12,000 registered spawners, random key per invocation so the lookup pays a real cache miss.
+///
+[RankColumn]
+[MemoryDiagnoser]
+[Orderer(SummaryOrderPolicy.FastestToSlowest)]
+public class TriggerDispatchLookupBenchmarks
+{
+ private const int SpawnerCount = 12_000;
+
+ // Power-of-two probe order so advancing the cursor is a mask, not a modulo or an RNG call.
+ private const int ProbeCount = 16_384;
+ private const int ProbeMask = ProbeCount - 1;
+
+ private MockTriggerSpawner[] _probes = null!;
+ private int _cursor;
+
+ // The D2 shape: one set per spawner in one dictionary.
+ private Dictionary _sets = null!;
+
+ // The pre-D2 shape: one dictionary per trigger class, probed in turn.
+ private Dictionary> _proximity = null!;
+ private Dictionary> _speech = null!;
+ private Dictionary> _kill = null!;
+ private Dictionary> _skill = null!;
+ private Dictionary> _gates = null!;
+ private Dictionary> _all = null!;
+
+ /// Builds both registries over the same 12,000 spawners and the probe order.
+ [GlobalSetup]
+ public void Setup()
+ {
+ var spawners = new MockTriggerSpawner[SpawnerCount];
+
+ _sets = new Dictionary(SpawnerCount);
+ _proximity = new Dictionary>(SpawnerCount);
+ _speech = new Dictionary>(SpawnerCount);
+ _kill = new Dictionary>(SpawnerCount);
+ _skill = new Dictionary>(SpawnerCount);
+ _gates = new Dictionary>(SpawnerCount);
+ _all = new Dictionary>(SpawnerCount);
+
+ var gridSide = (int)Math.Ceiling(Math.Sqrt(SpawnerCount));
+
+ for (var i = 0; i < SpawnerCount; i++)
+ {
+ var spawner = new MockTriggerSpawner
+ {
+ X = 1000 + i / gridSide * 4,
+ Y = 1000 + i % gridSide * 4,
+ EventCount = 1,
+ MaxPendingCycles = 1
+ };
+
+ var trigger = new MockProximityTrigger { SpawnerX = spawner.X, SpawnerY = spawner.Y };
+
+ var set = new MockTriggerSet { Generation = i + 1, EventCount = 1 };
+ set.All.Add(trigger);
+ set.Proximity.Add(trigger);
+ _sets[spawner] = set;
+
+ // Same triggers, filed the pre-D2 way: only the class that has any gets a list, so the
+ // probe chain has to ask the other five before it knows there is nothing there.
+ _proximity[spawner] = [trigger];
+ _all[spawner] = [trigger];
+
+ spawners[i] = spawner;
+ }
+
+ // A fixed seed so the probe order is the same on main and on the branch.
+ var random = new Random(20260912);
+ _probes = new MockTriggerSpawner[ProbeCount];
+ for (var i = 0; i < ProbeCount; i++)
+ {
+ _probes[i] = spawners[random.Next(SpawnerCount)];
+ }
+ }
+
+ /// One lookup into the single set dictionary, then the typed proximity walk.
+ /// How many triggers matched, so nothing is optimized away.
+ [Benchmark(Baseline = true, Description = "One TriggerSet dictionary")]
+ public int OneSetDictionary()
+ {
+ var spawner = _probes[_cursor = (_cursor + 1) & ProbeMask];
+
+ if (!_sets.TryGetValue(spawner, out var set) || set.Proximity.Count == 0)
+ {
+ return 0;
+ }
+
+ var matched = 0;
+ var triggers = set.Proximity;
+ for (var i = 0; i < triggers.Count; i++)
+ {
+ if (triggers[i].Evaluate(spawner.X, spawner.Y))
+ {
+ matched++;
+ break;
+ }
+ }
+
+ return matched;
+ }
+
+ /// The pre-D2 shape: probe six dictionaries, then the same walk.
+ /// How many triggers matched, so nothing is optimized away.
+ [Benchmark(Description = "Six per-class dictionaries")]
+ public int SixClassDictionaries()
+ {
+ var spawner = _probes[_cursor = (_cursor + 1) & ProbeMask];
+
+ // What a dispatcher without a per-spawner set has to do: it does not know which classes this
+ // spawner registered, so every class is asked.
+ _all.TryGetValue(spawner, out _);
+ _speech.TryGetValue(spawner, out _);
+ _kill.TryGetValue(spawner, out _);
+ _skill.TryGetValue(spawner, out _);
+ _gates.TryGetValue(spawner, out _);
+
+ if (!_proximity.TryGetValue(spawner, out var triggers) || triggers.Count == 0)
+ {
+ return 0;
+ }
+
+ var matched = 0;
+ for (var i = 0; i < triggers.Count; i++)
+ {
+ if (triggers[i].Evaluate(spawner.X, spawner.Y))
+ {
+ matched++;
+ break;
+ }
+ }
+
+ return matched;
+ }
+}
+
+///
+/// D2 condition 2: request and drain. An accepted event queues a bounded slot and asks the trigger
+/// system for a drain; the outermost dispatch walks the drain list once and spends one slot per
+/// spawner. The comparison is against the naive shape the design rejected - an unbounded queue and a
+/// fresh drain list per round - so the allocation column shows what the bound and the reuse buy.
+///
+/// Pending is the queue depth (MaxPendingCycles) and also how many events each of the
+/// 12,000 spawners raises in a round, so the accepted-request count scales with it.
+///
+[RankColumn]
+[MemoryDiagnoser]
+[Orderer(SummaryOrderPolicy.FastestToSlowest)]
+public class TriggerDispatchRequestDrainBenchmarks
+{
+ private const int SpawnerCount = 12_000;
+
+ private MockTriggerSpawner[] _spawners = null!;
+ private Guid _triggerId;
+
+ // Reused across drains exactly as TriggerSystem does: the outer dispatch clears it rather than
+ // releasing it, so the steady state allocates nothing for the list itself.
+ private readonly List _drainList = [];
+
+ /// Queue depth, and how many events each spawner raises per round.
+ [Params(1, 8, 64)]
+ public int Pending { get; set; }
+
+ /// Builds the 12,000 spawners with the parameterised queue bound.
+ [GlobalSetup]
+ public void Setup()
+ {
+ _triggerId = Guid.NewGuid();
+ _spawners = new MockTriggerSpawner[SpawnerCount];
+
+ for (var i = 0; i < SpawnerCount; i++)
+ {
+ _spawners[i] = new MockTriggerSpawner
+ {
+ X = 1000 + i,
+ Y = 1000,
+ EventCount = 1,
+ GateCount = 1,
+ OpenGateBits = 1UL,
+ MaxPendingCycles = Pending
+ };
+ }
+ }
+
+ ///
+ /// The D2 shape: bounded queue, idempotent drain request, one reused drain list, one slot spent
+ /// per spawner per drain. Leaves every spawner back at empty, so invocations do not accumulate.
+ ///
+ /// How many cycles were drained.
+ [Benchmark(Baseline = true, Description = "Bounded queue + reused drain list")]
+ public int RequestAndDrain()
+ {
+ var spawners = _spawners;
+
+ for (var i = 0; i < spawners.Length; i++)
+ {
+ var spawner = spawners[i];
+
+ for (var e = 0; e < Pending; e++)
+ {
+ // The acceptance order, shortened to the parts that cost anything per event: the gate
+ // word compare, then the queue bound (E6), then the slot.
+ if (!spawner.GateOpen || spawner.PendingCycleCount >= spawner.MaxPendingCycles)
+ {
+ continue;
+ }
+
+ spawner.PendingSlots ??= [];
+ spawner.PendingSlots.Add(new MockPendingCycle(spawner, _triggerId, i));
+
+ if (!spawner.DrainRequested)
+ {
+ spawner.DrainRequested = true;
+ _drainList.Add(spawner);
+ }
+ }
+ }
+
+ var drained = 0;
+
+ // Count re-read: a cycle can append to the list while it runs.
+ for (var i = 0; i < _drainList.Count; i++)
+ {
+ var spawner = _drainList[i];
+ if (spawner == null)
+ {
+ continue;
+ }
+
+ spawner.DrainRequested = false;
+
+ var slots = spawner.PendingSlots;
+ if (slots is not { Count: > 0 })
+ {
+ continue;
+ }
+
+ // DrainOne spends exactly one slot per queued spawner per round.
+ slots.RemoveAt(0);
+ spawner.DrainsThisRound++;
+ drained++;
+
+ // The rest of the queue survives the round; this benchmark returns the spawners to empty
+ // so repeated invocations measure the same work.
+ slots.Clear();
+ spawner.DrainsThisRound = 0;
+ }
+
+ _drainList.Clear();
+ return drained;
+ }
+
+ ///
+ /// The shape D2 rejected: no queue bound, and a fresh drain list every round. Same events, same
+ /// spawners, so the delta is the bound and the reuse.
+ ///
+ /// How many cycles were drained.
+ [Benchmark(Description = "Unbounded queue + fresh drain list")]
+ public int RequestAndDrainUnbounded()
+ {
+ var spawners = _spawners;
+ var drainList = new List();
+
+ for (var i = 0; i < spawners.Length; i++)
+ {
+ var spawner = spawners[i];
+
+ for (var e = 0; e < Pending; e++)
+ {
+ spawner.PendingSlots ??= [];
+ spawner.PendingSlots.Add(new MockPendingCycle(spawner, _triggerId, i));
+ drainList.Add(spawner);
+ }
+ }
+
+ var drained = 0;
+
+ for (var i = 0; i < drainList.Count; i++)
+ {
+ var spawner = drainList[i];
+ var slots = spawner.PendingSlots;
+ if (slots is not { Count: > 0 })
+ {
+ continue;
+ }
+
+ slots.RemoveAt(0);
+ drained++;
+ }
+
+ for (var i = 0; i < spawners.Length; i++)
+ {
+ spawners[i].PendingSlots?.Clear();
+ }
+
+ return drained;
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs
index 9d7fad2..e2e0dbc 100644
--- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs
+++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
+using Server.Engines.ModernSpawner.Triggers;
using Server.Engines.Spawners;
using Server.Mobiles;
using Xunit;
@@ -42,9 +43,20 @@ private static ModernSpawnerDto MakeDto(bool triggerActivated, params string[] t
HomeRange = 5,
Entries = [new ModernSpawnerEntry("Rabbit")],
TriggerActivated = triggerActivated,
- Triggers = new List(triggers)
+ Triggers = MakeTriggerDtos(triggers)
};
+ private static List MakeTriggerDtos(params string[] triggers)
+ {
+ var list = new List(triggers.Length);
+ foreach (var text in triggers)
+ {
+ list.Add(new TriggerDefinitionDto { Id = Guid.CreateVersion7(), Text = text });
+ }
+
+ return list;
+ }
+
[Fact]
public void Constructor_NamesLandInModernEntries()
{
@@ -137,18 +149,36 @@ public void Dupe_ClonesModernFields()
// [SerializedIgnoreDupe] keeps the reflection dupe off the trigger list, so OnAfterDuped has
// to copy it by hand - otherwise the copy is TriggerActivated with nothing to activate.
spawner.TriggerActivated = true;
- spawner.AddToTriggerDefinitions("proximity:8:true:false:5:0");
+ spawner.AddTriggerDefinition("proximity:8:true:false:5:0");
+
+ // Runtime state the copy must NOT inherit: a duped spawner starts its trigger life clean.
+ spawner.MaxPendingCycles = 2;
+ spawner.RefractoryUntil = Core.Now + TimeSpan.FromMinutes(5);
+ spawner.EnqueuePendingForTest(spawner.TriggerDefinitions[0].Id, (Serial)0x4000BEEFu);
+ spawner.GetTriggerState(spawner.TriggerDefinitions[0].Id).KillCount = 4;
var copy = new ModernSpawner();
spawner.Dupe(copy);
Assert.True(copy.TriggerActivated);
- Assert.Equal("proximity:8:true:false:5:0", Assert.Single(copy.TriggerDefinitions));
+ Assert.Equal("proximity:8:true:false:5:0", Assert.Single(copy.TriggerDefinitions).Text);
+ // Ids travel with the copy, so its runtime state keys line up with its definitions.
+ Assert.Equal(spawner.TriggerDefinitions[0].Id, copy.TriggerDefinitions[0].Id);
// Its own list, not the source's - editing one spawner's triggers must not touch the other.
Assert.NotSame(spawner.TriggerDefinitions, copy.TriggerDefinitions);
+ Assert.NotSame(spawner.TriggerDefinitions[0], copy.TriggerDefinitions[0]);
// And registered, so the copy actually listens for the trigger it carries.
Assert.True(copy.HandlesOnMovement);
+ // ...but none of the source's runtime state came with it: no queued cycles, no lockout, and
+ // a fresh (empty) state for the definition it inherited.
+ Assert.Equal(0, copy.PendingCycleCount);
+ Assert.Equal(default, copy.RefractoryUntil);
+ var copiedState = copy.GetTriggerState(copy.TriggerDefinitions[0].Id);
+ Assert.NotNull(copiedState);
+ Assert.Equal(0, copiedState.KillCount);
+ Assert.Equal(default, copiedState.CooldownUntil);
+
var clone = Assert.Single(copy.ModernEntries);
Assert.Equal("SET/Name/on spawn", clone.OnSpawnScript);
Assert.Equal("SET/Name/on despawn", clone.OnDespawnScript);
@@ -177,7 +207,9 @@ public void Dto_RoundTrip_CarriesEntriesTriggersAndCycleState()
var spawner = Place("Rabbit");
spawner.ModernEntries[0].LootTemplate = "goblin";
spawner.CycleMode = SpawnCycleMode.Sequential;
- spawner.AddToTriggerDefinitions("proximity:8:true");
+ // The base all-dead-then-respawn flag: binary-persisted, and easy to lose on the DTO path.
+ spawner.Group = true;
+ spawner.AddTriggerDefinition("proximity:8:true");
var json = SpawnerJsonSerializer.SerializeCompact>([spawner.ToDto()]);
var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options);
@@ -185,7 +217,8 @@ public void Dto_RoundTrip_CarriesEntriesTriggersAndCycleState()
Assert.Equal("goblin", loaded.ModernEntries[0].LootTemplate);
Assert.Equal(SpawnCycleMode.Sequential, loaded.CycleMode);
- Assert.Equal("proximity:8:true", Assert.Single(loaded.TriggerDefinitions));
+ Assert.True(loaded.Group);
+ Assert.Equal("proximity:8:true", Assert.Single(loaded.TriggerDefinitions).Text);
DeleteSpawned(loaded);
loaded.Delete();
@@ -252,14 +285,12 @@ public void Kill_DispatchesOnDespawnScriptAndKillTrigger()
// kill:requiredKills:requireAllDead:resetOnTrigger:filterType:requirePlayerKiller:cooldownSeconds
spawner.TriggerActivated = true;
- spawner.AddToTriggerDefinitions("kill:1:false:true:any:false:0");
+ spawner.AddTriggerDefinition("kill:1:false:true:any:false:0");
- // Triggers are registered from OnStarted; the constructor leaves the spawner running without
- // ever passing through it, so cycle it to get ActivateTriggers.
- spawner.Stop();
- spawner.Start();
+ // The TriggerActivated setter registers on the spot (A1), so no Stop/Start cycle is needed.
+ Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
Assert.True(spawner.Running);
- Assert.False(spawner.Triggered);
+ Assert.Equal(0, spawner.PendingCycleCount);
spawner.Spawn();
var rabbit = (BaseCreature)Assert.Single(spawner.Spawned).Key;
@@ -269,11 +300,387 @@ public void Kill_DispatchesOnDespawnScriptAndKillTrigger()
// OnSpawnedDeath compiled and ran the entry's OnDespawnScript against the dying creature...
Assert.Equal("despawn script ran", rabbit.Name);
- // ...and handed the kill to TriggerSystem, whose KillTrigger fired Trigger() on the spawner.
- Assert.True(spawner.Triggered);
+ // ...and handed the kill to TriggerSystem, whose KillTrigger bought a cycle. The dying rabbit
+ // is still in the registry at that point, so the spawner is full and the cycle is held as a
+ // queued slot (E2) rather than run on the spot.
+ Assert.Equal(1, spawner.PendingCycleCount);
rabbit.Corpse?.Delete();
DeleteSpawned(spawner);
spawner.Delete();
}
+
+ [Fact]
+ public void Binary_RoundTrip_CarriesTriggerIdsRuntimeStateAndPendingSlots()
+ {
+ var spawner = Place("Rabbit");
+ spawner.TriggerActivated = true;
+ spawner.AddTriggerDefinition("proximity:8:true");
+ spawner.AddTriggerDefinition("kill:1:false:true:any:false:0");
+ spawner.MaxPendingCycles = 2;
+ spawner.RefractoryMin = TimeSpan.FromSeconds(3);
+ spawner.RefractoryMax = TimeSpan.FromSeconds(9);
+
+ var proximityId = spawner.TriggerDefinitions[0].Id;
+ var killId = spawner.TriggerDefinitions[1].Id;
+ Assert.NotEqual(Guid.Empty, proximityId);
+ Assert.NotEqual(proximityId, killId);
+
+ var cooldownUntil = new DateTime(2026, 9, 12, 3, 4, 5, DateTimeKind.Utc);
+ var nextEligible = new DateTime(2026, 9, 12, 6, 7, 8, DateTimeKind.Utc);
+ var refractoryUntil = new DateTime(2026, 9, 12, 9, 10, 11, DateTimeKind.Utc);
+
+ var killState = spawner.GetTriggerState(killId);
+ Assert.NotNull(killState);
+ killState.KillCount = 3;
+ killState.CooldownUntil = cooldownUntil;
+
+ spawner.RefractoryUntil = refractoryUntil;
+ spawner.ModernEntries[0].NextEligible = nextEligible;
+
+ var mobile = (Serial)0x40001234u;
+ Assert.True(spawner.EnqueuePendingForTest(proximityId, mobile));
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ var writer = new BufferWriter(true);
+ spawner.Serialize(writer);
+ var bytes = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
+
+ var loaded = new ModernSpawner((Serial)0x40004243u);
+ loaded.Deserialize(new BufferReader(bytes));
+
+ Assert.Equal(2, loaded.TriggerDefinitions.Count);
+ Assert.Equal(proximityId, loaded.TriggerDefinitions[0].Id);
+ Assert.Equal("proximity:8:true", loaded.TriggerDefinitions[0].Text);
+ Assert.Equal(killId, loaded.TriggerDefinitions[1].Id);
+ Assert.Equal("kill:1:false:true:any:false:0", loaded.TriggerDefinitions[1].Text);
+
+ Assert.Equal(2, loaded.MaxPendingCycles);
+ Assert.Equal(TimeSpan.FromSeconds(3), loaded.RefractoryMin);
+ Assert.Equal(TimeSpan.FromSeconds(9), loaded.RefractoryMax);
+ Assert.Equal(refractoryUntil, loaded.RefractoryUntil);
+
+ var slot = Assert.Single(loaded.PendingCycles);
+ Assert.Equal(proximityId, slot.TriggerId);
+ Assert.Equal(mobile, slot.TriggeringMobile);
+
+ var loadedKillState = loaded.GetTriggerState(killId);
+ Assert.NotNull(loadedKillState);
+ Assert.Equal(3, loadedKillState.KillCount);
+ Assert.Equal(cooldownUntil, loadedKillState.CooldownUntil);
+
+ Assert.Equal(nextEligible, loaded.ModernEntries[0].NextEligible);
+
+ loaded.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+
+ [Fact]
+ public void Dto_RoundTrip_CarriesTriggerIdsAndLimits()
+ {
+ var spawner = Place("Rabbit");
+ spawner.AddTriggerDefinition("proximity:8:true");
+ spawner.AddTriggerDefinition("speech:aGVsbG8=:true:false:10:true:5");
+ spawner.MaxPendingCycles = 3;
+ spawner.RefractoryMin = TimeSpan.FromSeconds(4);
+ spawner.RefractoryMax = TimeSpan.FromSeconds(12);
+
+ var firstId = spawner.TriggerDefinitions[0].Id;
+ var secondId = spawner.TriggerDefinitions[1].Id;
+
+ // Runtime state exists on the source but must never reach the export.
+ spawner.EnqueuePendingForTest(firstId, (Serial)0x40005678u);
+ spawner.GetTriggerState(secondId).KillCount = 7;
+ spawner.ModernEntries[0].NextEligible = new DateTime(2026, 9, 12, 1, 2, 3, DateTimeKind.Utc);
+
+ var json = SpawnerJsonSerializer.SerializeCompact>([spawner.ToDto()]);
+
+ Assert.DoesNotContain("\"pendingCycles\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"triggerStates\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"killCount\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"cooldownUntil\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"nextEligible\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"refractoryUntil\"", json, StringComparison.Ordinal);
+
+ var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options);
+ var loaded = (ModernSpawner)dtos[0].ToSpawner();
+
+ Assert.Equal(2, loaded.TriggerDefinitions.Count);
+ Assert.Equal(firstId, loaded.TriggerDefinitions[0].Id);
+ Assert.Equal("proximity:8:true", loaded.TriggerDefinitions[0].Text);
+ Assert.Equal(secondId, loaded.TriggerDefinitions[1].Id);
+ Assert.Equal("speech:aGVsbG8=:true:false:10:true:5", loaded.TriggerDefinitions[1].Text);
+
+ Assert.Equal(3, loaded.MaxPendingCycles);
+ Assert.Equal(TimeSpan.FromSeconds(4), loaded.RefractoryMin);
+ Assert.Equal(TimeSpan.FromSeconds(12), loaded.RefractoryMax);
+
+ // Runtime state is world-save only: an imported spawner starts clean.
+ Assert.Equal(0, loaded.PendingCycleCount);
+ Assert.Equal(default, loaded.ModernEntries[0].NextEligible);
+
+ DeleteSpawned(loaded);
+ loaded.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+
+ [Fact]
+ public void RemoveTriggerDefinitionAt_DropsItsStateAndPendingSlots()
+ {
+ var spawner = Place("Rabbit");
+ spawner.TriggerActivated = true;
+ spawner.AddTriggerDefinition("proximity:8:true");
+ spawner.AddTriggerDefinition("kill:1:false:true:any:false:0");
+
+ var proximityId = spawner.TriggerDefinitions[0].Id;
+ var killId = spawner.TriggerDefinitions[1].Id;
+ spawner.MaxPendingCycles = 4;
+ spawner.EnqueuePendingForTest(proximityId, Serial.Zero);
+ spawner.EnqueuePendingForTest(killId, Serial.Zero);
+ Assert.Equal(2, spawner.PendingCycleCount);
+ Assert.Equal(2, spawner.TriggerStates.Count);
+
+ spawner.RemoveTriggerDefinitionAt(0);
+
+ Assert.Equal(killId, Assert.Single(spawner.TriggerDefinitions).Id);
+ Assert.Equal(killId, Assert.Single(spawner.PendingCycles).TriggerId);
+ Assert.Equal(killId, Assert.Single(spawner.TriggerStates).Id);
+
+ spawner.ClearTriggerDefinitions();
+
+ Assert.Empty(spawner.TriggerDefinitions);
+ Assert.Empty(spawner.TriggerStates);
+ Assert.Equal(0, spawner.PendingCycleCount);
+
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+
+ [Fact]
+ public void MaxPendingCycles_ClampsAndTrimsOldestSlots()
+ {
+ var spawner = Place("Rabbit");
+ spawner.AddTriggerDefinition("proximity:8:true");
+ var id = spawner.TriggerDefinitions[0].Id;
+
+ spawner.MaxPendingCycles = 3;
+ spawner.EnqueuePendingForTest(id, (Serial)0x40000001u);
+ spawner.EnqueuePendingForTest(id, (Serial)0x40000002u);
+ spawner.EnqueuePendingForTest(id, (Serial)0x40000003u);
+ // Bounded: the fourth is rejected rather than growing the queue.
+ Assert.False(spawner.EnqueuePendingForTest(id, (Serial)0x40000004u));
+ Assert.Equal(3, spawner.PendingCycleCount);
+
+ // Lowering trims the oldest slots first (A5).
+ spawner.MaxPendingCycles = 1;
+ Assert.Equal(1, spawner.PendingCycleCount);
+ Assert.Equal((Serial)0x40000003u, spawner.PendingCycles[0].TriggeringMobile);
+
+ // Clamped at zero, never negative.
+ spawner.MaxPendingCycles = -5;
+ Assert.Equal(0, spawner.MaxPendingCycles);
+ Assert.Equal(0, spawner.PendingCycleCount);
+
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+
+ ///
+ /// Writes the layer plus a v0 payload.
+ /// The base layer comes from a stock entry because ModernSpawnerEntry.Serialize opens with
+ /// base.Serialize(writer), which is exactly what a stock entry writes.
+ ///
+ private static void WriteLegacyEntry(BufferWriter writer, BaseSpawner parent)
+ {
+ new SpawnerEntry(parent, "Rabbit").Serialize(writer);
+
+ writer.WriteEncodedInt(0); // ModernSpawnerEntry v0
+ writer.Write("SET/Name/on spawn"); // 0 OnSpawnScript
+ writer.Write("SET/Name/on despawn"); // 1 OnDespawnScript
+ writer.Write(TimeSpan.FromSeconds(11)); // 2 MinDelay
+ writer.Write(TimeSpan.FromSeconds(22)); // 3 MaxDelay
+ writer.Write("circle"); // 4 PositioningRule
+ writer.Write("wave one"); // 5 SpawnGroup
+ writer.Write(true); // 6 RequireLOS
+ writer.Write(new Point3D(3, -4, 5)); // 7 SpawnAreaOffset
+ writer.Write(7); // 8 SpawnRange
+ writer.Write("goblin"); // 9 LootTemplate
+ writer.Write(3); // 10 Subgroup
+ }
+
+ [Fact]
+ public void Binary_V0Save_MigratesDefinitionsToIdsAndDropsTriggered()
+ {
+ // A v0 world save, byte for byte. The Item/BaseSpawner/Spawner layers come from a stock
+ // Spawner because ModernSpawner.Serialize opens with base.Serialize(writer).
+ var legacy = new Spawner();
+ legacy.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
+
+ var writer = new BufferWriter(true);
+ legacy.Serialize(writer);
+
+ writer.WriteEncodedInt(0); // ModernSpawner v0
+ writer.WriteEncodedInt(1); // 0 SpawnEntries count
+ WriteLegacyEntry(writer, legacy);
+ writer.Write(Serial.Zero); // 1 OnActivateScriptSerial
+ writer.Write(Serial.Zero); // 2 OnDeactivateScriptSerial
+ writer.Write(Serial.Zero); // 3 OnBeforeSpawnScriptSerial
+ writer.Write(Serial.Zero); // 4 OnAfterSpawnScriptSerial
+ writer.Write(false); // 5 UseSmartPositioning
+ writer.Write(true); // 6 ReturnToSpawnOnIdle
+ writer.Write(33); // 7 MaxZDelta
+ writer.WriteEncodedInt(2); // 8 TriggerDefinitions count
+ writer.Write("proximity:8:true");
+ writer.Write("kill:1:false:true:any:false:0");
+ writer.Write(true); // 9 TriggerActivated
+ writer.Write(true); // 10 Triggered - dropped by the migration
+ writer.Write("legacy notes"); // 11 Notes
+ writer.WriteEnum(SpawnCycleMode.Sequential); // 12 CycleMode
+ writer.Write(4); // 13 CurrentSubgroup
+ writer.Write(TimeSpan.FromMinutes(7)); // 14 SequentialResetTime
+ writer.Write(2); // 15 SequentialResetTo
+ writer.Write(true); // 16 HoldSequence
+
+ var bytes = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
+
+ var loaded = new ModernSpawner((Serial)0x40004244u);
+ loaded.Deserialize(new BufferReader(bytes));
+
+ // Every kept spawner field survives, in the right slot.
+ Assert.False(loaded.UseSmartPositioning);
+ Assert.True(loaded.ReturnToSpawnOnIdle);
+ Assert.Equal(33, loaded.MaxZDelta);
+ Assert.True(loaded.TriggerActivated);
+ Assert.Equal("legacy notes", loaded.Notes);
+ Assert.Equal(SpawnCycleMode.Sequential, loaded.CycleMode);
+ Assert.Equal(4, loaded.CurrentSubgroup);
+ Assert.Equal(TimeSpan.FromMinutes(7), loaded.SequentialResetTime);
+ Assert.Equal(2, loaded.SequentialResetTo);
+ Assert.True(loaded.HoldSequence);
+
+ // The string list became identified definitions, in order, with fresh distinct ids...
+ Assert.Equal(2, loaded.TriggerDefinitions.Count);
+ Assert.Equal("proximity:8:true", loaded.TriggerDefinitions[0].Text);
+ Assert.Equal("kill:1:false:true:any:false:0", loaded.TriggerDefinitions[1].Text);
+ Assert.NotEqual(Guid.Empty, loaded.TriggerDefinitions[0].Id);
+ Assert.NotEqual(loaded.TriggerDefinitions[0].Id, loaded.TriggerDefinitions[1].Id);
+
+ // ...each with bound, empty runtime state, and the new fields at their defaults.
+ Assert.Equal(2, loaded.TriggerStates.Count);
+ Assert.NotNull(loaded.GetTriggerState(loaded.TriggerDefinitions[0].Id));
+ Assert.NotNull(loaded.GetTriggerState(loaded.TriggerDefinitions[1].Id));
+ Assert.Equal(0, loaded.GetTriggerState(loaded.TriggerDefinitions[1].Id).KillCount);
+ Assert.Equal(1, loaded.MaxPendingCycles);
+ Assert.Equal(0, loaded.PendingCycleCount);
+ Assert.Equal(TimeSpan.Zero, loaded.RefractoryMin);
+ Assert.Equal(TimeSpan.Zero, loaded.RefractoryMax);
+ Assert.Equal(default, loaded.RefractoryUntil);
+
+ // The nested entry migrated too: every v0 field kept, NextEligible new and clear.
+ var entry = Assert.Single(loaded.ModernEntries);
+ Assert.Equal("Rabbit", entry.SpawnedName);
+ Assert.Equal("SET/Name/on spawn", entry.OnSpawnScript);
+ Assert.Equal("SET/Name/on despawn", entry.OnDespawnScript);
+ Assert.Equal(TimeSpan.FromSeconds(11), entry.MinDelay);
+ Assert.Equal(TimeSpan.FromSeconds(22), entry.MaxDelay);
+ Assert.Equal("circle", entry.PositioningRule);
+ Assert.Equal("wave one", entry.SpawnGroup);
+ Assert.True(entry.RequireLOS);
+ Assert.Equal(new Point3D(3, -4, 5), entry.SpawnAreaOffset);
+ Assert.Equal(7, entry.SpawnRange);
+ Assert.Equal("goblin", entry.LootTemplate);
+ Assert.Equal(3, entry.Subgroup);
+ Assert.Equal(default, entry.NextEligible);
+
+ loaded.Delete();
+ legacy.Delete();
+ }
+ [Fact]
+ public void Dto_DuplicateTriggerIds_AreGivenDistinctIds()
+ {
+ // A hand-edited export, or a trigger block copied between spawners: two definitions arriving
+ // with one id would alias onto a single TriggerRuntimeState and onto each other's slots.
+ var shared = Guid.CreateVersion7();
+ var dto = MakeDto(true);
+ var loaded = (ModernSpawner)(dto with
+ {
+ Triggers =
+ [
+ new TriggerDefinitionDto { Id = shared, Text = "proximity:8:true" },
+ new TriggerDefinitionDto { Id = shared, Text = "kill:1:false:true:any:false:0" }
+ ]
+ }).ToSpawner();
+ loaded.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
+
+ Assert.Equal(2, loaded.TriggerDefinitions.Count);
+ Assert.NotEqual(loaded.TriggerDefinitions[0].Id, loaded.TriggerDefinitions[1].Id);
+ Assert.Equal(shared, loaded.TriggerDefinitions[0].Id);
+ Assert.NotEqual(Guid.Empty, loaded.TriggerDefinitions[1].Id);
+
+ // One state per definition, each reachable by its own id.
+ Assert.Equal(2, loaded.TriggerStates.Count);
+ Assert.NotSame(
+ loaded.GetTriggerState(loaded.TriggerDefinitions[0].Id),
+ loaded.GetTriggerState(loaded.TriggerDefinitions[1].Id));
+
+ // The same guard covers the wrapper, not just the DTO path.
+ loaded.AddTriggerDefinition(shared, "speech:aGVsbG8=:true:false:10:true:5");
+ Assert.Equal(3, loaded.TriggerDefinitions.Count);
+ Assert.NotEqual(shared, loaded.TriggerDefinitions[2].Id);
+ Assert.Equal(3, loaded.TriggerStates.Count);
+
+ DeleteSpawned(loaded);
+ loaded.Delete();
+ }
+
+ [Fact]
+ public void Dto_LegacyTriggerStringShape_StillImports()
+ {
+ // Files exported before definitions had ids wrote "triggers": [ "proximity:8:true" ].
+ const string legacy = """
+ [
+ {
+ "$type": "ModernSpawner",
+ "location": "(1500, 1500, 0)",
+ "map": "Felucca",
+ "count": 1,
+ "minDelay": "00:05:00",
+ "maxDelay": "00:10:00",
+ "homeRange": 5,
+ "entries": [ { "name": "Rabbit", "probability": 100, "maxCount": 1 } ],
+ "triggerActivated": true,
+ "triggers": [ "proximity:8:true", "kill:1:false:true:any:false:0" ]
+ }
+ ]
+ """;
+
+ var dtos = JsonSerializer.Deserialize>(legacy, SpawnerJsonSerializer.Options);
+ var loaded = (ModernSpawner)dtos[0].ToSpawner();
+
+ Assert.Equal(2, loaded.TriggerDefinitions.Count);
+ Assert.Equal("proximity:8:true", loaded.TriggerDefinitions[0].Text);
+ Assert.Equal("kill:1:false:true:any:false:0", loaded.TriggerDefinitions[1].Text);
+
+ // Ids are minted on the way in, so the definitions are usable state keys immediately.
+ Assert.NotEqual(Guid.Empty, loaded.TriggerDefinitions[0].Id);
+ Assert.NotEqual(loaded.TriggerDefinitions[0].Id, loaded.TriggerDefinitions[1].Id);
+ Assert.Equal(2, loaded.TriggerStates.Count);
+
+ // Re-exporting writes the object shape, which reads back with the ids intact.
+ var json = SpawnerJsonSerializer.SerializeCompact>([loaded.ToDto()]);
+ Assert.Contains("\"text\": \"proximity:8:true\"", json, StringComparison.Ordinal);
+
+ var reloaded = (ModernSpawner)JsonSerializer
+ .Deserialize>(json, SpawnerJsonSerializer.Options)[0].ToSpawner();
+
+ Assert.Equal(loaded.TriggerDefinitions[0].Id, reloaded.TriggerDefinitions[0].Id);
+ Assert.Equal(loaded.TriggerDefinitions[1].Id, reloaded.TriggerDefinitions[1].Id);
+
+ DeleteSpawned(reloaded);
+ reloaded.Delete();
+ DeleteSpawned(loaded);
+ loaded.Delete();
+ }
}
diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs
index 243f13d..e42f261 100644
--- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs
+++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Xml;
using Server.Engines.ModernSpawner.Migration;
using Server.Engines.ModernSpawner.Triggers;
@@ -27,7 +28,7 @@ private static ModernSpawner Place()
public void TogglingTriggerActivated_RegistersAndUnregisters()
{
var spawner = Place();
- spawner.AddToTriggerDefinitions(Proximity);
+ spawner.AddTriggerDefinition(Proximity);
Assert.False(spawner.HandlesOnMovement);
spawner.TriggerActivated = true;
@@ -45,11 +46,11 @@ public void AddingDefinitionToActivatedRunningSpawner_RegistersImmediately()
spawner.TriggerActivated = true;
Assert.False(spawner.HandlesOnMovement);
- spawner.AddToTriggerDefinitions(Proximity);
+ spawner.AddTriggerDefinition(Proximity);
spawner.EnsureTriggersActive();
Assert.True(spawner.HandlesOnMovement);
- spawner.RemoveFromTriggerDefinitions(Proximity);
+ spawner.RemoveTriggerDefinitionAt(0);
spawner.EnsureTriggersActive();
Assert.False(spawner.HandlesOnMovement);
spawner.Delete();
@@ -59,7 +60,7 @@ public void AddingDefinitionToActivatedRunningSpawner_RegistersImmediately()
public void DeletingAnActivatedSpawner_LeavesNothingRegistered()
{
var spawner = Place();
- spawner.AddToTriggerDefinitions(Proximity);
+ spawner.AddTriggerDefinition(Proximity);
spawner.TriggerActivated = true;
Assert.True(spawner.HandlesOnMovement);
@@ -71,26 +72,33 @@ public void DeletingAnActivatedSpawner_LeavesNothingRegistered()
}
[Fact]
- public void Stop_UnregistersEvenWhenFlagWasClearedAfterRegistration()
+ public void Stop_KeepsTheRegistrationAndDispatchAlive()
{
var spawner = Place();
- spawner.AddToTriggerDefinitions(Proximity);
+ spawner.AddTriggerDefinition(Proximity);
spawner.TriggerActivated = true;
+
+ // A2: stopping stops the timer and nothing else. The registration, and with it movement
+ // dispatch, has to survive - a wake trigger can only start a stopped spawner if it still
+ // hears the event that would wake it.
spawner.Stop();
- Assert.False(TriggerSystem.Instance.IsRegistered(spawner));
+ Assert.False(spawner.Running);
+ Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
+ Assert.True(spawner.HandlesOnMovement);
- // A registration that outlived its flag - the state a raw field write or a pre-fix gump edit
- // could leave behind. Teardown does not consult the flag, so it still has to be cleaned up.
- spawner.TriggerActivated = false;
spawner.Start();
+ Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
+
+ // Clearing the flag is what unregisters, whether the spawner is running or not.
+ spawner.TriggerActivated = false;
Assert.False(TriggerSystem.Instance.IsRegistered(spawner));
- TriggerSystem.Instance.ActivateTriggers(spawner);
+ spawner.TriggerActivated = true;
Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
- spawner.Stop();
- Assert.False(TriggerSystem.Instance.IsRegistered(spawner));
+ // ...and so does deletion, whatever the flag says at that moment.
spawner.Delete();
+ Assert.False(TriggerSystem.Instance.IsRegistered(spawner));
}
[Fact]
@@ -108,8 +116,8 @@ public void GumpTimeTriggerDefinitions_ParseAndRegister()
Assert.NotNull(TriggerSystem.Instance.ParseTrigger(gameTime));
var spawner = Place();
- spawner.AddToTriggerDefinitions(wallTime);
- spawner.AddToTriggerDefinitions(gameTime);
+ spawner.AddTriggerDefinition(wallTime);
+ spawner.AddTriggerDefinition(gameTime);
spawner.TriggerActivated = true;
Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
@@ -120,7 +128,7 @@ public void GumpTimeTriggerDefinitions_ParseAndRegister()
public void DeletingAStoppedSpawner_WithStaleRegistration_Unregisters()
{
var spawner = Place();
- spawner.AddToTriggerDefinitions(Proximity);
+ spawner.AddTriggerDefinition(Proximity);
spawner.Stop(); // Running false: OnStopped is out of the picture
TriggerSystem.Instance.ActivateTriggers(spawner); // stale registration behind a false flag
Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
@@ -158,7 +166,11 @@ public void Migrator_RunningFalse_ProducesStoppedSpawnerWithNoRegisteredTriggers
{
var stopped = XmlSpawnerMigrator.ParseXmlSpawnerNode(ParseNode(XmlSpawnerNode("false")));
Assert.False(stopped.Running);
- Assert.False(TriggerSystem.Instance.IsRegistered(stopped));
+
+ // A1/A2: registration follows TriggerActivated, not Running, so an imported spawner that
+ // arrives stopped still listens - it just does not spawn on a timer until it is started.
+ Assert.True(stopped.TriggerActivated);
+ Assert.True(TriggerSystem.Instance.IsRegistered(stopped));
stopped.Delete();
// Running="true" (the same construction path) must still register and run, so the fix for the
@@ -195,7 +207,7 @@ public void Migrator_MapsSkillTriggerAttribute(string xml, string expected)
var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
try
{
- Assert.Contains(expected, spawner.TriggerDefinitions);
+ Assert.Contains(spawner.TriggerDefinitions, d => d.Text == expected);
}
finally
{
@@ -216,11 +228,140 @@ public void Migrator_RejectsMalformedSkillTriggerAttribute(string xml)
var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
try
{
- Assert.DoesNotContain(spawner.TriggerDefinitions, d => d.StartsWith("skill:", StringComparison.Ordinal));
+ Assert.DoesNotContain(spawner.TriggerDefinitions, d => d.Text.StartsWith("skill:", StringComparison.Ordinal));
// The proximity definition from the same node is untouched, so this is a targeted rejection
// rather than the whole trigger block being lost.
- Assert.Contains("proximity:8:true:false:5:0", spawner.TriggerDefinitions);
+ Assert.Contains(spawner.TriggerDefinitions, d => d.Text == "proximity:8:true:false:5:0");
+ Assert.True(spawner.TriggerActivated);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ // --- Task 4: refractory, SpawnOnTrigger, conjunctive proximity+speech+property, IsGroup and TOD ---
+
+ private static string BasePointAttributes =>
+ "X=\"1500\" Y=\"1500\" Z=\"0\" Map=\"Felucca\" Running=\"false\"";
+
+ [Fact]
+ public void Migrator_MapsMinMaxRefractoryAttributes_AsMinutes()
+ {
+ var node = ParseNode($"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ Assert.Equal(TimeSpan.FromMinutes(2), spawner.RefractoryMin);
+ Assert.Equal(TimeSpan.FromMinutes(5), spawner.RefractoryMax);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_InvertedRefractoryRange_IsClampedAndNoted()
+ {
+ // A source file with max below min: the lockout becomes the fixed minimum, and the operator
+ // is told rather than left to wonder why the range they wrote is not the one they got.
+ var node = ParseNode($"");
+ var notes = new List();
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node, notes);
+ try
+ {
+ Assert.Equal(TimeSpan.FromMinutes(5), spawner.RefractoryMin);
+ Assert.Equal(TimeSpan.FromMinutes(5), spawner.RefractoryMax);
+ Assert.Contains(notes, n => n.Contains("clamped", StringComparison.OrdinalIgnoreCase));
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_SpawnOnTriggerAbsent_IsRunNowOrDrop()
+ {
+ // Default (and SpawnOnTrigger="True") reproduce XmlSpawner: no queue, no mode:tick suffix.
+ var node = ParseNode($"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ Assert.Equal(0, spawner.MaxPendingCycles);
+ Assert.Contains(spawner.TriggerDefinitions, d => d.Text == "proximity:8:true:false:5:0");
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_SpawnOnTriggerFalse_DefersToNextTickWithOneSlot()
+ {
+ var node = ParseNode($"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ Assert.Equal(1, spawner.MaxPendingCycles);
+ Assert.Contains(spawner.TriggerDefinitions, d => d.Text == "proximity:8:true:false:5:0:mode:tick");
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_ProximityAndSpeech_CollapseIntoOneConjunctiveSpeechTrigger()
+ {
+ var node = ParseNode(
+ $"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ // One speech trigger carrying the proximity range - not a separate proximity trigger too.
+ Assert.DoesNotContain(spawner.TriggerDefinitions, d => d.Text.StartsWith("proximity:", StringComparison.Ordinal));
+ var speech = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("speech:", StringComparison.Ordinal));
+ var parsed = SpeechTrigger.Parse(speech.Text);
+ Assert.Equal("open", parsed.Keyword);
+ Assert.Equal(12, parsed.Range);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_ProximitySpeechAndPlayerProperty_AttachesWhenExpression()
+ {
+ var node = ParseNode(
+ $"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ var speech = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("speech:", StringComparison.Ordinal));
+ Assert.EndsWith(":when:trigMob.Karma > 0", speech.Text);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_PlayerPropertyNameAlone_AttachesWhenToAProximityTrigger()
+ {
+ var node = ParseNode($"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ var proximity = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("proximity:", StringComparison.Ordinal));
+ Assert.EndsWith(":when:trigMob.Karma > 0", proximity.Text);
Assert.True(spawner.TriggerActivated);
}
finally
@@ -228,4 +369,144 @@ public void Migrator_RejectsMalformedSkillTriggerAttribute(string xml)
spawner.Delete();
}
}
+
+ [Fact]
+ public void Migrator_UnrepresentablePlayerProperty_EmitsTriggerWithoutWhenAndAddsNote()
+ {
+ var node = ParseNode(
+ $"");
+ var notes = new List();
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node, notes);
+ try
+ {
+ var proximity = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("proximity:", StringComparison.Ordinal));
+ Assert.DoesNotContain(":when:", proximity.Text);
+ Assert.Contains(notes, n => n.Contains("PlayerPropertyName") && n.Contains("GETONTHIS,Karma>0"));
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ // Fix round 1: mode:tick must precede when: in the emitted definition text - TriggerTokens.Strip
+ // treats when: as consuming everything after it, so a token appended past it is swallowed into the
+ // expression source and never parses, silently dropping the deferral and leaving the when: dead.
+
+ [Fact]
+ public void Migrator_SpawnOnTriggerFalseWithPlayerPropertyAndSpeech_ParsesModeTickWithACompilingWhen()
+ {
+ var node = ParseNode(
+ $"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ var speech = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("speech:", StringComparison.Ordinal));
+ var trigger = TriggerSystem.Instance.ParseTrigger(speech.Text);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ Assert.NotNull(trigger.When);
+ Assert.True(trigger.When.IsValid);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_SpawnOnTriggerFalseWithPlayerPropertyAlone_ParsesModeTickWithACompilingWhen()
+ {
+ var node = ParseNode(
+ $"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ var proximity = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("proximity:", StringComparison.Ordinal));
+ var trigger = TriggerSystem.Instance.ParseTrigger(proximity.Text);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ Assert.NotNull(trigger.When);
+ Assert.True(trigger.When.IsValid);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_SpawnOnTriggerFalseWithUnrepresentablePlayerPropertyAndSpeech_ParsesModeTickWithNoWhen()
+ {
+ var node = ParseNode(
+ $"");
+ var notes = new List();
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node, notes);
+ try
+ {
+ var speech = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("speech:", StringComparison.Ordinal));
+ Assert.DoesNotContain(":when:", speech.Text);
+ Assert.EndsWith(":mode:tick", speech.Text);
+
+ var trigger = TriggerSystem.Instance.ParseTrigger(speech.Text);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ Assert.Null(trigger.When);
+ Assert.Contains(notes, n => n.Contains("PlayerPropertyName") && n.Contains("GETONTHIS,Karma>0"));
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_IsGroup_SetsBaseGroupOnly_NotAllEntriesCycleMode()
+ {
+ var node = ParseNode($"");
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node);
+ try
+ {
+ Assert.True(spawner.Group);
+ Assert.NotEqual(SpawnCycleMode.AllEntries, spawner.CycleMode);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Theory]
+ [InlineData("0", "wall_time_window:")] // Realtime
+ [InlineData("1", "game_time_window:")] // Gametime
+ public void Migrator_MapsTodModeToTheMatchingGateAndAddsDespawnNote(string todMode, string expectedPrefix)
+ {
+ // TODStart/TODEnd are TotalMinutes (dev-docs §2): 480 = 8:00, 1020 = 17:00.
+ var node = ParseNode(
+ $"");
+ var notes = new List();
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node, notes);
+ try
+ {
+ Assert.Contains(spawner.TriggerDefinitions, d => d.Text.StartsWith(expectedPrefix, StringComparison.Ordinal));
+ Assert.Contains(notes, n => n.Contains("despawned live spawns") && n.Contains("D10"));
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Migrator_Duration_AddsReportNoteOnly()
+ {
+ var node = ParseNode($"");
+ var notes = new List();
+ var spawner = XmlSpawnerMigrator.ParseXmlSpawnerNode(node, notes);
+ try
+ {
+ Assert.Contains(notes, n => n.Contains("Duration") && n.Contains("D10"));
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
}
diff --git a/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs b/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs
index 3ca03bb..3f4d563 100644
--- a/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs
+++ b/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs
@@ -11,15 +11,19 @@ namespace Server.Engines.ModernSpawner.Tests;
/// End-to-end cover for skill triggers: a real handler raises
/// , forwards it, and the spawner
/// fires only for players, only in range, only for the configured outcome, and only once per cooldown.
+/// An accepted event buys one cycle that the outermost dispatch runs on its way out (E1), so what an
+/// accepted skill use leaves behind is a spawn, not a queued slot.
///
[Collection("Sequential ModernSpawner Tests")]
public class SkillTriggerTests
{
private static ModernSpawner Place(string definition)
{
- var spawner = new ModernSpawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit");
+ // Room for several cycles: the spawner must never fill up, or a later event would be held as
+ // a queued slot (E2) instead of running.
+ var spawner = new ModernSpawner(5, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit");
spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
- spawner.AddToTriggerDefinitions(definition);
+ spawner.AddTriggerDefinition(definition);
spawner.TriggerActivated = true;
return spawner;
}
@@ -40,9 +44,10 @@ public void PlayerSkillUse_InRange_FiresTrigger()
var player = PlacePlayer(new Point3D(1503, 1500, 0));
try
{
- Assert.False(spawner.Triggered);
+ Assert.Empty(spawner.Spawned);
SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
- Assert.True(spawner.Triggered);
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
}
finally
{
@@ -59,7 +64,7 @@ public void PlayerSkillUse_OutOfRange_DoesNotFire()
try
{
SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
- Assert.False(spawner.Triggered);
+ Assert.Empty(spawner.Spawned);
}
finally
{
@@ -76,9 +81,9 @@ public void FailureOnlyTrigger_IgnoresSuccess()
try
{
SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
- Assert.False(spawner.Triggered);
+ Assert.Empty(spawner.Spawned);
SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, -0.1);
- Assert.True(spawner.Triggered);
+ Assert.Single(spawner.Spawned);
}
finally
{
@@ -96,7 +101,7 @@ public void CreatureSkillUse_DoesNotFire()
try
{
SkillCheck.Mobile_SkillCheckDirectTarget(rabbit, SkillName.Mining, null, 1.0);
- Assert.False(spawner.Triggered);
+ Assert.Empty(spawner.Spawned);
}
finally
{
@@ -113,14 +118,16 @@ public void Cooldown_SuppressesSecondFiringUntilElapsed()
try
{
SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
- Assert.True(spawner.Triggered);
+ Assert.Single(spawner.Spawned);
+
+ // The cooldown is trigger state, so it survives ResetTrigger and refuses the next event.
spawner.ResetTrigger();
SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
- Assert.False(spawner.Triggered);
+ Assert.Single(spawner.Spawned);
ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(6));
SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
- Assert.True(spawner.Triggered);
+ Assert.Equal(2, spawner.Spawned.Count);
}
finally
{
diff --git a/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs
new file mode 100644
index 0000000..d17553f
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs
@@ -0,0 +1,1907 @@
+using System;
+using System.Collections.Generic;
+using Server.Engines.ModernSpawner.Positioning;
+using Server.Engines.ModernSpawner.Tests.Fixtures;
+using Server.Engines.ModernSpawner.Triggers;
+using Server.Misc;
+using Server.Mobiles;
+using Xunit;
+
+namespace Server.Engines.ModernSpawner.Tests;
+
+///
+/// The D2 transition table, one test per row group of the design's §11 matrix. Everything is driven
+/// through the production entry points - ,
+/// , ,
+/// , the gates'
+/// edge callbacks - against a live world with a seeded clock.
+///
+[Collection("Sequential ModernSpawner Tests")]
+public class TriggerStateMachineTests
+{
+ // The seeded clock sits at noon on 2020-01-01 (a Wednesday in January), so a window restricted to
+ // December can never be open and an all-day window always is. AdvanceClock only ever moves the
+ // suite forward by minutes, so neither can flip under another test.
+ private const string ClosedWindow = "wall_time_window:0:0:23:59:127:2048";
+ private const string OpenWindow = "wall_time_window:0:0:23:59:127:4095";
+
+ // proximity:range:playersOnly:requireLos:cooldownSeconds:minAccess
+ private const string Proximity = "proximity:8:true:false:0:0";
+
+ private static readonly Point3D SpawnerLocation = new(1500, 1500, 0);
+
+ private static ModernSpawner Place(int count, params string[] definitions)
+ {
+ var spawner = new ModernSpawner(
+ count,
+ TimeSpan.FromMinutes(5),
+ TimeSpan.FromMinutes(10),
+ 0,
+ default,
+ "Rabbit"
+ );
+
+ spawner.MoveToWorld(SpawnerLocation, Map.Felucca);
+
+ for (var i = 0; i < definitions.Length; i++)
+ {
+ spawner.AddTriggerDefinition(definitions[i]);
+ }
+
+ if (definitions.Length > 0)
+ {
+ spawner.TriggerActivated = true;
+ }
+
+ return spawner;
+ }
+
+ private static PlayerMobile PlacePlayer(int x = 1503, int y = 1500)
+ {
+ // Mobile.Player is not set by the constructor (production sets it at login) and every event
+ // trigger filters on it, so the test host sets it the way ModernUO's own mobile tests do.
+ var player = new PlayerMobile { Name = "Walker", Player = true };
+ player.MoveToWorld(new Point3D(x, y, 0), Map.Felucca);
+ return player;
+ }
+
+ private static void Move(ModernSpawner spawner, Mobile player) =>
+ spawner.OnMovement(player, new Point3D(player.X + 1, player.Y, player.Z));
+
+ private static void DeleteSpawned(ModernSpawner spawner)
+ {
+ foreach (var spawned in new List(spawner.Spawned.Keys))
+ {
+ spawned.Delete();
+ }
+ }
+
+ #region T2, G1-G4 - the gate set
+
+ [Fact]
+ public void T2_GateClosed_TickSpawnsNothing()
+ {
+ var spawner = Place(3, ClosedWindow);
+ try
+ {
+ Assert.Equal(1, spawner.GateCount);
+ Assert.False(spawner.GateOpen);
+
+ spawner.OnTick();
+ Assert.Empty(spawner.Spawned);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void G1_GateOpens_RunsTheWindowOpenCycle()
+ {
+ var spawner = Place(3, ClosedWindow);
+ try
+ {
+ Assert.False(spawner.GateOpen);
+
+ // The open edge a gate reports when its window starts.
+ spawner.OnGateOpened(0);
+
+ Assert.True(spawner.GateOpen);
+ Assert.Single(spawner.Spawned);
+
+ // And the tick is authorized again.
+ spawner.OnTick();
+ Assert.True(spawner.IsAuthorizedForTick);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void G2_G3_G4_OverlappingGates_TrackTheOpenSetById()
+ {
+ var spawner = Place(4, ClosedWindow, ClosedWindow);
+ try
+ {
+ Assert.Equal(2, spawner.GateCount);
+
+ spawner.OnGateOpened(0);
+ Assert.True(spawner.GateOpen);
+ var afterFirstOpen = spawner.Spawned.Count;
+ Assert.Equal(1, afterFirstOpen);
+
+ // G2: the set was already non-empty, so the second open edge has no side effects.
+ spawner.OnGateOpened(1);
+ Assert.True(spawner.GateOpen);
+ Assert.Equal(afterFirstOpen, spawner.Spawned.Count);
+
+ // G2 again for an id already present.
+ spawner.OnGateOpened(1);
+ Assert.Equal(afterFirstOpen, spawner.Spawned.Count);
+
+ // G3: one of two gates closing leaves the spawner open, and live spawns stay.
+ spawner.OnGateClosed(0);
+ Assert.True(spawner.GateOpen);
+ Assert.Equal(afterFirstOpen, spawner.Spawned.Count);
+
+ spawner.OnGateClosed(1);
+ Assert.False(spawner.GateOpen);
+ Assert.Equal(afterFirstOpen, spawner.Spawned.Count);
+
+ // G4: a stale close edge for an id that is not in the set is a no-op.
+ spawner.OnGateClosed(1);
+ Assert.False(spawner.GateOpen);
+ Assert.Equal(afterFirstOpen, spawner.Spawned.Count);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void G1_RepeatedGateOpenings_KeepDrainingPastTheRecursionBudget()
+ {
+ // Eleven E2 -> G1 sequences on one spawner. The per-dispatch recursion budget is ten, so a
+ // gate opening that spent it without ever handing the drain list back would stop draining on
+ // the eleventh window.
+ const int openings = 11;
+
+ var spawner = Place(openings + 5, Proximity, ClosedWindow);
+ var player = PlacePlayer();
+ try
+ {
+ for (var i = 0; i < openings; i++)
+ {
+ Assert.False(spawner.GateOpen);
+
+ Move(spawner, player);
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ spawner.OnGateOpened(1);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ Assert.Equal(i + 1, spawner.Spawned.Count);
+
+ spawner.OnGateClosed(1);
+ }
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region E1, E0 and the acceptance order
+
+ [Fact]
+ public void E1_EventOnRunningSpawner_RunsOneCycleAfterDispatch()
+ {
+ var spawner = Place(3, Proximity);
+ var player = PlacePlayer();
+ spawner.ModernEntries[0].PositioningRule = DispatchProbeRule.Name;
+ DispatchProbeRule.Watch(spawner);
+ try
+ {
+ Move(spawner, player);
+
+ // The cycle ran...
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+
+ // ...and it ran with the dispatch already unwound: the trigger system was not inside a
+ // dispatch when the cycle body asked for a spawn position.
+ Assert.Equal(1, DispatchProbeRule.Calls);
+ Assert.False(DispatchProbeRule.WasDispatching);
+ }
+ finally
+ {
+ DispatchProbeRule.Reset();
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void E0_SecondEventWithinCooldown_DoesNothing()
+ {
+ var spawner = Place(4, "proximity:8:true:false:5:0");
+ var player = PlacePlayer();
+ try
+ {
+ Move(spawner, player);
+ Assert.Single(spawner.Spawned);
+
+ Move(spawner, player);
+ Assert.Single(spawner.Spawned);
+
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(6));
+ Move(spawner, player);
+ Assert.Equal(2, spawner.Spawned.Count);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void E0_Refractory_BlocksADifferentTriggerType()
+ {
+ var spawner = Place(4, Proximity, "skill:Mining:10:0:false:0");
+ var player = PlacePlayer();
+ spawner.RefractoryMin = TimeSpan.FromSeconds(10);
+ spawner.RefractoryMax = TimeSpan.FromSeconds(10);
+ try
+ {
+ Move(spawner, player);
+ Assert.Single(spawner.Spawned);
+
+ // The spawner-wide lockout is not per trigger: a skill event is refused too.
+ SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
+ Assert.Single(spawner.Spawned);
+
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(11));
+ SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
+ Assert.Equal(2, spawner.Spawned.Count);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region E2, T3, T6 - an event while full
+
+ [Fact]
+ public void E2_EventWhileFull_KeepsTheSlotAndTheNextTickConsumesIt()
+ {
+ var spawner = Place(1, Proximity);
+ var player = PlacePlayer();
+ try
+ {
+ // Manual spawn fills the spawner without touching the trigger machinery (M1).
+ spawner.Spawn();
+ Assert.True(spawner.IsFull);
+
+ Move(spawner, player);
+
+ // E2: the cycle is bought and kept, not run and not dropped.
+ Assert.Equal(1, spawner.PendingCycleCount);
+ Assert.Single(spawner.Spawned);
+
+ // T3: a tick while full parks rather than consuming the slot.
+ spawner.OnTick();
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ DeleteSpawned(spawner);
+ Assert.Empty(spawner.Spawned);
+
+ // The entry's own deadline was set by the manual spawn; it has to elapse before a tick
+ // cycle is allowed to select it again (T5).
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromMinutes(11));
+
+ // T6: the tick pops the queued slot and runs it.
+ spawner.OnTick();
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region E4, E5 - events on a stopped spawner
+
+ [Fact]
+ public void E5_EventOnStoppedSpawner_QueuesASlotThatTheFirstTickRuns()
+ {
+ var spawner = Place(3, Proximity);
+ var player = PlacePlayer();
+ try
+ {
+ spawner.Stop();
+ Assert.False(spawner.Running);
+
+ // A2: registration, and with it movement dispatch, survives Stop().
+ Assert.True(spawner.HandlesOnMovement);
+ Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
+
+ Move(spawner, player);
+
+ Assert.Equal(1, spawner.PendingCycleCount);
+ Assert.Empty(spawner.Spawned);
+ Assert.False(spawner.Running);
+
+ // T0: a tick on a stopped spawner does nothing at all, slot or no slot.
+ spawner.OnTick();
+ Assert.Empty(spawner.Spawned);
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ spawner.Start();
+ spawner.OnTick();
+
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void E4_WakeTrigger_StartsTheStoppedSpawnerAndRunsTheCycle()
+ {
+ var spawner = Place(3, Proximity + ":wake:true");
+ var player = PlacePlayer();
+ try
+ {
+ spawner.Stop();
+ Assert.False(spawner.Running);
+
+ Move(spawner, player);
+
+ Assert.True(spawner.Running);
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void E5_SkillEventOnStoppedSpawner_QueuesASlot()
+ {
+ var spawner = Place(3, "skill:Mining:10:0:false:0");
+ var player = PlacePlayer();
+ try
+ {
+ spawner.Stop();
+ Assert.False(spawner.Running);
+
+ // A2 covers skill dispatch too: a stopped spawner still hears the attempt.
+ SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
+
+ Assert.Equal(1, spawner.PendingCycleCount);
+ Assert.Empty(spawner.Spawned);
+ Assert.False(spawner.Running);
+
+ spawner.Start();
+ spawner.OnTick();
+ Assert.Single(spawner.Spawned);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void E4_WakeSkillTrigger_StartsTheStoppedSpawner()
+ {
+ var spawner = Place(3, "skill:Mining:10:0:false:0:wake:true");
+ var player = PlacePlayer();
+ try
+ {
+ spawner.Stop();
+ Assert.False(spawner.Running);
+
+ SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0);
+
+ Assert.True(spawner.Running);
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region E6, Max = 0 - the queue bound
+
+ [Fact]
+ public void E6_QueueFull_NoCooldownAdvance()
+ {
+ var spawner = Place(3, "proximity:8:true:false:5:0", ClosedWindow);
+ var player = PlacePlayer();
+ try
+ {
+ Assert.False(spawner.GateOpen);
+ Assert.Equal(1, spawner.MaxPendingCycles);
+
+ var id = spawner.TriggerDefinitions[0].Id;
+
+ Move(spawner, player);
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ var cooldownUntil = spawner.GetTriggerState(id).CooldownUntil;
+ Assert.NotEqual(default, cooldownUntil);
+
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(6));
+
+ // The queue is full, so the event is refused before any acceptance side effect: the
+ // cooldown does not move, so it is not silently eaten either.
+ Move(spawner, player);
+ Assert.Equal(1, spawner.PendingCycleCount);
+ Assert.Equal(cooldownUntil, spawner.GetTriggerState(id).CooldownUntil);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void E6_QueueBound_ThreeEventsBuyOneCycle()
+ {
+ var spawner = Place(4, Proximity, ClosedWindow);
+ var player = PlacePlayer();
+ try
+ {
+ Move(spawner, player);
+ Move(spawner, player);
+ Move(spawner, player);
+
+ Assert.Equal(1, spawner.PendingCycleCount);
+ Assert.Empty(spawner.Spawned);
+
+ // The window opening spends exactly the one cycle that was bought.
+ spawner.OnGateOpened(1);
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Max0_LegacyDrop_NoCooldownAdvance()
+ {
+ var spawner = Place(3, "proximity:8:true:false:5:0", ClosedWindow);
+ var player = PlacePlayer();
+ spawner.MaxPendingCycles = 0;
+ try
+ {
+ var id = spawner.TriggerDefinitions[0].Id;
+ Assert.False(spawner.GateOpen);
+
+ // Nothing can run now and nothing may latch, so the event is refused outright.
+ Move(spawner, player);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ Assert.Equal(default, spawner.GetTriggerState(id).CooldownUntil);
+
+ // ...and the window opening finds nothing waiting for it.
+ spawner.OnGateOpened(1);
+ Assert.Empty(spawner.Spawned);
+
+ // With the gate open the same event runs immediately and does advance the cooldown.
+ Move(spawner, player);
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ Assert.NotEqual(default, spawner.GetTriggerState(id).CooldownUntil);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Max0_NestedEventMidCycle_LeavesNoLatchedRunNow()
+ {
+ // MaxPendingCycles == 0 has no queue, so an accepted event leaves a one-shot run-now request.
+ // One raised from inside a tick cycle cannot run - a cycle is already in flight - and it must
+ // be dropped there and then, not left sitting on the spawner for some later drain to spend.
+ var spawner = Place(10);
+ spawner.MaxPendingCycles = 0;
+ spawner.ModernEntries[0].PositioningRule = ExternalTriggerProbeRule.Name;
+ ExternalTriggerProbeRule.Watch(spawner);
+ try
+ {
+ spawner.OnTick();
+
+ // The tick's own cycle ran; the nested request did not nest.
+ Assert.Single(spawner.Spawned);
+
+ // Nothing is left for a later drain to find.
+ ExternalTriggerProbeRule.Reset();
+ TriggerSystem.Instance.RequestDrain(spawner);
+ Assert.Single(spawner.Spawned);
+ }
+ finally
+ {
+ ExternalTriggerProbeRule.Reset();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region E3 - mode:tick and mode:now
+
+ [Fact]
+ public void E3_ModeTick_RunsAtNextTickHonouringEntryDeadlines()
+ {
+ var spawner = Place(3, Proximity + ":mode:tick");
+ var player = PlacePlayer();
+ try
+ {
+ spawner.ModernEntries[0].NextEligible = Core.Now + TimeSpan.FromHours(1);
+
+ Move(spawner, player);
+
+ // mode:tick only buys the cycle; nothing runs inside or right after the dispatch.
+ Assert.Equal(1, spawner.PendingCycleCount);
+ Assert.Empty(spawner.Spawned);
+
+ // The tick honours the entry deadline, so the slot waits.
+ spawner.OnTick();
+ Assert.Empty(spawner.Spawned);
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromHours(1) + TimeSpan.FromMinutes(1));
+
+ spawner.OnTick();
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void E3_ModeNow_BypassesEntryDeadlines()
+ {
+ var spawner = Place(3, Proximity);
+ var player = PlacePlayer();
+ try
+ {
+ spawner.ModernEntries[0].NextEligible = Core.Now + TimeSpan.FromHours(1);
+
+ Move(spawner, player);
+
+ // The drained event cycle ignores the per-entry deadline (XmlSpawner resets entry timers
+ // on a trigger), so the spawn happens despite the entry being parked.
+ Assert.Single(spawner.Spawned);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region The deferred slot carries the triggering mobile
+
+ [Fact]
+ public void PlayerRelative_UsesDeferredSlotMobile()
+ {
+ var spawner = Place(3, Proximity, ClosedWindow);
+ var player = PlacePlayer();
+ spawner.ModernEntries[0].PositioningRule = DispatchProbeRule.Name;
+ DispatchProbeRule.Watch(spawner);
+ try
+ {
+ // The gate is closed, so the event is queued and the cycle runs later (E2 -> G1).
+ Move(spawner, player);
+ Assert.Equal(1, spawner.PendingCycleCount);
+ Assert.Equal(player.Serial, spawner.PendingCycles[0].TriggeringMobile);
+
+ spawner.OnGateOpened(1);
+
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(1, DispatchProbeRule.Calls);
+
+ // The slot's serial was resolved back into the mobile positioning sees, which is what
+ // player_relative reads off PositioningContext.
+ Assert.Same(player, DispatchProbeRule.LastTriggeringMobile);
+ }
+ finally
+ {
+ DispatchProbeRule.Reset();
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region A3, A4 - definition edits, activation and hydration
+
+ [Fact]
+ public void A3_Reorder_KeepsStateById()
+ {
+ var spawner = Place(3, Proximity, "kill:3:false:true:any:false:0");
+ try
+ {
+ var proximityId = spawner.TriggerDefinitions[0].Id;
+ var killId = spawner.TriggerDefinitions[1].Id;
+
+ var killState = spawner.GetTriggerState(killId);
+ killState.KillCount = 2;
+ var proximityState = spawner.GetTriggerState(proximityId);
+ proximityState.CooldownUntil = Core.Now + TimeSpan.FromMinutes(30);
+ var cooldownUntil = proximityState.CooldownUntil;
+
+ // A gump delete of the first definition, which is also what a reorder does to the list.
+ spawner.RemoveTriggerDefinitionAt(0);
+
+ Assert.Equal(killId, Assert.Single(spawner.TriggerDefinitions).Id);
+ Assert.Equal(2, spawner.GetTriggerState(killId).KillCount);
+ Assert.Null(spawner.GetTriggerState(proximityId));
+
+ // Putting it back with the same id gives it fresh state, and the kill state is untouched.
+ spawner.AddTriggerDefinition(proximityId, Proximity);
+ Assert.Equal(2, spawner.TriggerDefinitions.Count);
+ Assert.Equal(proximityId, spawner.TriggerDefinitions[1].Id);
+ Assert.Equal(default, spawner.GetTriggerState(proximityId).CooldownUntil);
+ Assert.NotEqual(cooldownUntil, spawner.GetTriggerState(proximityId).CooldownUntil);
+ Assert.Equal(2, spawner.GetTriggerState(killId).KillCount);
+
+ // The parsed triggers are bound to the state by id, not by position.
+ var set = TriggerSystem.Instance.GetSet(spawner);
+ var kill = Assert.Single(set.Kill);
+ Assert.Equal(killId, kill.Id);
+ Assert.Equal(2, kill.State.KillCount);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void A4_Deactivate_ClearsPendingAndGates()
+ {
+ var spawner = Place(3, Proximity, ClosedWindow);
+ var player = PlacePlayer();
+ try
+ {
+ // Open the window, spend one event through it, then close it again and buy a cycle that
+ // has to wait: the spawner now holds both an open-gate history and a queued slot.
+ spawner.OnGateOpened(1);
+ Assert.True(spawner.GateOpen);
+
+ Move(spawner, player);
+ Assert.Single(spawner.Spawned);
+ DeleteSpawned(spawner);
+
+ spawner.OnGateClosed(1);
+ Assert.False(spawner.GateOpen);
+
+ Move(spawner, player);
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ spawner.TriggerActivated = false;
+
+ // A4: the queue is dropped and the registration is gone; a deactivated spawner is a plain
+ // timer spawner, so its gate reads open.
+ Assert.Equal(0, spawner.PendingCycleCount);
+ Assert.False(TriggerSystem.Instance.IsRegistered(spawner));
+ Assert.True(spawner.GateOpen);
+ Assert.Equal(0, spawner.GateCount);
+
+ // ...and it runs on its timer again. It was parked behind a closed gate with an empty
+ // queue, and nothing else would have armed it.
+ Assert.True(spawner.NextSpawn > TimeSpan.Zero);
+
+ // Re-activating must not resurrect the old open bit: the window is closed, so the gate is.
+ spawner.TriggerActivated = true;
+ Assert.Equal(1, spawner.GateCount);
+ Assert.False(spawner.GateOpen);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void A3_Activate_HydratesGatesSilently()
+ {
+ var spawner = Place(3);
+ try
+ {
+ spawner.AddTriggerDefinition(OpenWindow);
+ spawner.TriggerActivated = true;
+
+ // The window is already open at registration: the bit is set, but the open edge that
+ // registration itself produced must not buy a cycle.
+ Assert.True(spawner.GateOpen);
+ Assert.Equal(1, spawner.GateCount);
+ Assert.Empty(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void A3_RequestThroughARetiredRegistration_IsDropped()
+ {
+ var spawner = Place(4, Proximity);
+ var player = PlacePlayer();
+ try
+ {
+ var stale = TriggerSystem.Instance.GetSet(spawner);
+ var staleTrigger = Assert.Single(stale.Proximity);
+ var staleGeneration = stale.Generation;
+
+ // A definition edit replaces the whole registration; the parsed trigger above belongs to
+ // the one that was retired.
+ spawner.AddTriggerDefinition("speech:aGVsbG8=:true:false:10:true:0");
+ var current = TriggerSystem.Instance.GetSet(spawner);
+ Assert.NotEqual(staleGeneration, current.Generation);
+ Assert.NotSame(staleTrigger, Assert.Single(current.Proximity));
+
+ var context = TriggerContext.ForProximity(spawner, player);
+
+ // Submitted through the retired set, it is dropped rather than run against state it is no
+ // longer bound to...
+ Assert.False(spawner.RequestCycle(staleTrigger, staleGeneration, in context));
+ Assert.Empty(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+
+ // ...while the live registration still works.
+ Assert.True(spawner.RequestCycle(current.Proximity[0], current.Generation, in context));
+ Assert.Single(spawner.Spawned);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void A3_Reorder_SwapsTwoDefinitionsAndKeepsEachState()
+ {
+ // There is no move API, and none is needed: removing a definition and adding it back with its
+ // own id is exactly the swap a gump reorder performs on a two-definition list, and it goes
+ // through the same RemoveTriggerDefinitionAt / AddTriggerDefinition(Guid, string) pair the
+ // gump uses.
+ var spawner = Place(4, Proximity, "kill:3:false:true:any:false:0");
+ try
+ {
+ var proximityId = spawner.TriggerDefinitions[0].Id;
+ var proximityText = spawner.TriggerDefinitions[0].Text;
+ var killId = spawner.TriggerDefinitions[1].Id;
+
+ var cooldownUntil = Core.Now + TimeSpan.FromMinutes(30);
+ spawner.GetTriggerState(proximityId).CooldownUntil = cooldownUntil;
+ spawner.GetTriggerState(killId).KillCount = 2;
+
+ spawner.RemoveTriggerDefinitionAt(0);
+ spawner.AddTriggerDefinition(proximityId, proximityText);
+
+ // Swapped in the list...
+ Assert.Equal(2, spawner.TriggerDefinitions.Count);
+ Assert.Equal(killId, spawner.TriggerDefinitions[0].Id);
+ Assert.Equal(proximityId, spawner.TriggerDefinitions[1].Id);
+
+ // ...and the kill state stayed with its own definition across the move.
+ Assert.Equal(2, spawner.GetTriggerState(killId).KillCount);
+
+ // The parsed triggers are bound by id, not by position, and the gate index each reports
+ // follows the new order.
+ var set = TriggerSystem.Instance.GetSet(spawner);
+ var kill = Assert.Single(set.Kill);
+ Assert.Equal(killId, kill.Id);
+ Assert.Equal(0, kill.DefinitionIndex);
+ Assert.Equal(2, kill.State.KillCount);
+
+ var proximity = Assert.Single(set.Proximity);
+ Assert.Equal(proximityId, proximity.Id);
+ Assert.Equal(1, proximity.DefinitionIndex);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void A3_MapMove_ReregistersAndRehydratesAGameTimeGate()
+ {
+ var spawner = Place(3, "game_time_window:0:24");
+ try
+ {
+ var before = TriggerSystem.Instance.GetSet(spawner);
+ Assert.Equal(1, spawner.GateCount);
+
+ // A whole-day window is open wherever the spawner stands, so hydration is observable on
+ // both maps and the assertion does not depend on either map's clock.
+ Assert.True(spawner.GateOpen);
+
+ spawner.MoveToWorld(new Point3D(2000, 2000, 0), Map.Trammel);
+
+ // A3: the move re-registered, so the gate recomputed its window against the new map's
+ // clock rather than keeping the old map's answer.
+ var after = TriggerSystem.Instance.GetSet(spawner);
+ Assert.NotNull(after);
+ Assert.NotSame(before, after);
+ Assert.NotEqual(before.Generation, after.Generation);
+ Assert.Equal(1, spawner.GateCount);
+ Assert.True(spawner.GateOpen);
+
+ // ...and silently: hydration never buys a cycle.
+ Assert.Empty(spawner.Spawned);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region L1 - world load
+
+ [Fact]
+ public void L1_Restart_NoSpawnDuringLoad()
+ {
+ var spawner = Place(3, Proximity, OpenWindow);
+ try
+ {
+ var proximityId = spawner.TriggerDefinitions[0].Id;
+ spawner.MaxPendingCycles = 2;
+ spawner.RefractoryMin = TimeSpan.FromSeconds(7);
+ spawner.RefractoryMax = TimeSpan.FromSeconds(7);
+ spawner.RefractoryUntil = Core.Now + TimeSpan.FromSeconds(7);
+ spawner.GetTriggerState(proximityId).CooldownUntil = Core.Now + TimeSpan.FromMinutes(3);
+ spawner.EnqueuePendingForTest(proximityId, (Serial)0x40001111u);
+
+ var writer = new BufferWriter(true);
+ spawner.Serialize(writer);
+ var bytes = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
+
+ var loaded = new ModernSpawner((Serial)0x40004321u);
+ loaded.Deserialize(new BufferReader(bytes));
+ try
+ {
+ // Nothing may spawn while the world is loading, before or after the deferred pass.
+ Assert.Empty(loaded.Spawned);
+
+ // The spawner was running when it was saved, and RebuildSpawned re-arms its timer
+ // from the saved End, so it comes back running rather than parked forever.
+ Assert.True(loaded.Running);
+ Assert.True(loaded.NextSpawn > TimeSpan.Zero);
+
+ loaded.OnWorldLoaded();
+
+ Assert.Empty(loaded.Spawned);
+ Assert.True(loaded.Running);
+ Assert.True(TriggerSystem.Instance.IsRegistered(loaded));
+
+ // Runtime state survived the restart...
+ Assert.Equal(1, loaded.PendingCycleCount);
+ Assert.Equal(proximityId, loaded.PendingCycles[0].TriggerId);
+ Assert.NotEqual(default, loaded.RefractoryUntil);
+ Assert.NotEqual(default, loaded.GetTriggerState(proximityId).CooldownUntil);
+
+ // ...and the window was recomputed from the clock rather than persisted.
+ Assert.Equal(1, loaded.GateCount);
+ Assert.True(loaded.GateOpen);
+ }
+ finally
+ {
+ DeleteSpawned(loaded);
+ loaded.Delete();
+ }
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void L2_Restart_DeactivatedSpawnerRegistersNothingAndHoldsNoCycles()
+ {
+ var spawner = Place(3, Proximity);
+ try
+ {
+ var id = spawner.TriggerDefinitions[0].Id;
+ spawner.EnqueuePendingForTest(id, Serial.Zero);
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ // Saved with the master switch off, but still carrying the slot the save was taken with.
+ spawner.TriggerActivated = false;
+ spawner.EnqueuePendingForTest(id, Serial.Zero);
+
+ var writer = new BufferWriter(true);
+ spawner.Serialize(writer);
+ var bytes = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray();
+
+ var loaded = new ModernSpawner((Serial)0x40004322u);
+ loaded.Deserialize(new BufferReader(bytes));
+ try
+ {
+ loaded.OnWorldLoaded();
+
+ Assert.False(loaded.TriggerActivated);
+ Assert.False(TriggerSystem.Instance.IsRegistered(loaded));
+ Assert.Equal(0, loaded.PendingCycleCount);
+ Assert.Empty(loaded.Spawned);
+ }
+ finally
+ {
+ DeleteSpawned(loaded);
+ loaded.Delete();
+ }
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region M1, M3, M4 - manual operations
+
+ [Fact]
+ public void M1_ManualSpawn_BypassesClosedGate()
+ {
+ var spawner = Place(3, ClosedWindow);
+ try
+ {
+ spawner.OnTick();
+ Assert.Empty(spawner.Spawned);
+
+ spawner.Spawn();
+ Assert.Single(spawner.Spawned);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void M3_Reset_ClearsRuntimeStateButKeepsRegistration()
+ {
+ var spawner = Place(3, Proximity, "kill:3:false:true:any:false:0");
+ try
+ {
+ var proximityId = spawner.TriggerDefinitions[0].Id;
+ var killId = spawner.TriggerDefinitions[1].Id;
+
+ spawner.Spawn();
+ spawner.EnqueuePendingForTest(proximityId, Serial.Zero);
+ spawner.GetTriggerState(proximityId).CooldownUntil = Core.Now + TimeSpan.FromMinutes(5);
+ spawner.GetTriggerState(killId).KillCount = 2;
+ spawner.RefractoryUntil = Core.Now + TimeSpan.FromMinutes(5);
+
+ spawner.Reset();
+
+ Assert.Equal(0, spawner.PendingCycleCount);
+ Assert.Equal(default, spawner.GetTriggerState(proximityId).CooldownUntil);
+ Assert.Equal(0, spawner.GetTriggerState(killId).KillCount);
+ Assert.Equal(default, spawner.RefractoryUntil);
+ Assert.Empty(spawner.Spawned);
+ Assert.False(spawner.Running);
+
+ // M3 keeps the registration: a stopped registration is live.
+ Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void M4_ResetTrigger_DropsPendingButKeepsCooldowns()
+ {
+ var spawner = Place(3, Proximity);
+ try
+ {
+ var id = spawner.TriggerDefinitions[0].Id;
+ spawner.EnqueuePendingForTest(id, Serial.Zero);
+ spawner.GetTriggerState(id).CooldownUntil = Core.Now + TimeSpan.FromMinutes(5);
+ var cooldownUntil = spawner.GetTriggerState(id).CooldownUntil;
+
+ spawner.ResetTrigger();
+
+ Assert.Equal(0, spawner.PendingCycleCount);
+ Assert.Equal(cooldownUntil, spawner.GetTriggerState(id).CooldownUntil);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void M_ExternalTrigger_RunsACycleWithoutDefinitions()
+ {
+ var spawner = Place(3);
+ try
+ {
+ // The script / command entry point is an event source of its own, even with no
+ // definitions at all.
+ spawner.Trigger();
+
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region T1 - group mode
+
+ [Fact]
+ public void T1_Group_PartialDead_NoRespawn()
+ {
+ var spawner = Place(2);
+ spawner.Group = true;
+ try
+ {
+ spawner.Respawn();
+ Assert.Equal(2, spawner.Spawned.Count);
+
+ var first = new List(spawner.Spawned.Keys)[0];
+ first.Delete();
+ Assert.Single(spawner.Spawned);
+
+ // The group is not all dead, so the tick parks rather than topping the pack back up.
+ spawner.OnTick();
+ Assert.Single(spawner.Spawned);
+
+ DeleteSpawned(spawner);
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromMinutes(11));
+
+ spawner.OnTick();
+ Assert.Equal(2, spawner.Spawned.Count);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void T1_Group_AllDead_OneBulkRespawnWithScriptsOnce()
+ {
+ var helper = Place(10);
+ helper.Name = "D2BulkHelper";
+
+ var spawner = Place(3);
+ spawner.Group = true;
+ spawner.SetOnBeforeSpawnScript($"SPAWN/{helper.Name}");
+ try
+ {
+ Assert.True(spawner.OnBeforeSpawnScript.IsValid);
+
+ spawner.OnTick();
+
+ // Three spawns from one bulk respawn...
+ Assert.Equal(3, spawner.Spawned.Count);
+
+ // ...and the before-spawn script ran exactly once for the whole respawn, not once per
+ // spawn, which the helper spawner counts for us.
+ Assert.Single(helper.Spawned);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ DeleteSpawned(helper);
+ helper.Delete();
+ }
+ }
+
+ [Fact]
+ public void T1_Group_EventOnPopulatedPack_KeepsTheSlot()
+ {
+ var spawner = Place(3, Proximity);
+ spawner.Group = true;
+ var player = PlacePlayer();
+ try
+ {
+ spawner.Respawn();
+ Assert.Equal(3, spawner.Spawned.Count);
+
+ // Room for one more, so the event is accepted rather than held by E2's IsFull branch...
+ var first = new List(spawner.Spawned.Keys)[0];
+ first.Delete();
+ Assert.Equal(2, spawner.Spawned.Count);
+ Assert.False(spawner.IsFull);
+
+ Move(spawner, player);
+
+ // ...and T1 applies to the event's cycle exactly as it does to a tick: the pack is not
+ // dead, so nothing is respawned and the cycle stays bought.
+ Assert.Equal(2, spawner.Spawned.Count);
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ DeleteSpawned(spawner);
+
+ // With the pack dead the same slot buys the one bulk respawn.
+ TriggerSystem.Instance.RequestDrain(spawner);
+ Assert.Equal(3, spawner.Spawned.Count);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void T1_Group_EventOnDeadPack_RunsOneBulkRespawnWithScriptsOnce()
+ {
+ var helper = Place(10);
+ helper.Name = "D2GroupEventHelper";
+
+ var spawner = Place(3, Proximity);
+ spawner.Group = true;
+ spawner.SetOnBeforeSpawnScript($"SPAWN/{helper.Name}");
+ var player = PlacePlayer();
+ try
+ {
+ Assert.Empty(spawner.Spawned);
+
+ Move(spawner, player);
+
+ // One bulk respawn from the event's cycle...
+ Assert.Equal(3, spawner.Spawned.Count);
+ Assert.Equal(0, spawner.PendingCycleCount);
+
+ // ...with the before-spawn script run once for the whole respawn, which the helper counts.
+ Assert.Single(helper.Spawned);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ DeleteSpawned(helper);
+ helper.Delete();
+ }
+ }
+
+ [Fact]
+ public void T1_Group_GateOpensWithALivePack_ParksWithoutArming()
+ {
+ var spawner = Place(4, ClosedWindow);
+ spawner.Group = true;
+ try
+ {
+ spawner.OnGateOpened(0);
+ Assert.Equal(4, spawner.Spawned.Count);
+
+ // Room for one more so the timer is allowed to run at all, with a delay nothing else in
+ // this test would pick.
+ var first = new List(spawner.Spawned.Keys)[0];
+ first.Delete();
+ spawner.DoTimer(TimeSpan.FromHours(5));
+ Assert.Equal(TimeSpan.FromHours(5), spawner.NextSpawn);
+
+ spawner.OnGateClosed(0);
+ spawner.OnGateOpened(0);
+
+ // The pack is not dead, so the window opening buys nothing and parks - it must not re-arm
+ // at the entry deadlines either, or the spawner would wake up only to park again.
+ Assert.Equal(3, spawner.Spawned.Count);
+ Assert.Equal(TimeSpan.FromHours(5), spawner.NextSpawn);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void T1_Group_AfterScriptRunsOnceOnABulkRespawn()
+ {
+ var helper = Place(10);
+ helper.Name = "D2AfterScriptHelper";
+
+ var spawner = Place(3);
+ spawner.Group = true;
+ spawner.SetOnAfterSpawnScript($"SPAWN/{helper.Name}");
+ try
+ {
+ Assert.True(spawner.OnAfterSpawnScript.IsValid);
+
+ spawner.OnTick();
+
+ Assert.Equal(3, spawner.Spawned.Count);
+
+ // Once for the whole respawn, not once per spawn inside it.
+ Assert.Single(helper.Spawned);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ DeleteSpawned(helper);
+ helper.Delete();
+ }
+ }
+
+ #endregion
+
+ #region T5 - per-entry deadlines
+
+ [Fact]
+ public void T5_EntryDeadlines_OnlyDueSelectable()
+ {
+ var spawner = new ModernSpawner(
+ 4,
+ TimeSpan.FromMinutes(5),
+ TimeSpan.FromMinutes(10),
+ 0,
+ default,
+ "Rabbit",
+ "Bird"
+ );
+ spawner.MoveToWorld(SpawnerLocation, Map.Felucca);
+ try
+ {
+ var rabbit = spawner.ModernEntries[0];
+ var bird = spawner.ModernEntries[1];
+
+ rabbit.NextEligible = Core.Now + TimeSpan.FromHours(1);
+
+ spawner.OnTick();
+
+ Assert.Empty(rabbit.Spawned);
+ Assert.Single(bird.Spawned);
+
+ // T6 re-armed for the next moment an entry could be selected, not for never.
+ Assert.True(spawner.NextSpawn > TimeSpan.Zero);
+
+ // Both parked: the tick arms at the earliest of the two deadlines instead of spawning,
+ // and is not clamped down to the spawner's own max delay.
+ rabbit.NextEligible = Core.Now + TimeSpan.FromHours(1);
+ bird.NextEligible = Core.Now + TimeSpan.FromHours(2);
+ DeleteSpawned(spawner);
+
+ spawner.OnTick();
+ Assert.Empty(spawner.Spawned);
+ Assert.Equal(TimeSpan.FromHours(1), spawner.NextSpawn);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void T5_FailedPlacement_BacksOff()
+ {
+ var spawner = new ModernSpawner(
+ 2,
+ TimeSpan.FromMinutes(5),
+ TimeSpan.FromMinutes(10),
+ 0,
+ default,
+ "ThisTypeDoesNotExist"
+ );
+ spawner.MoveToWorld(SpawnerLocation, Map.Felucca);
+ try
+ {
+ var entry = spawner.ModernEntries[0];
+ Assert.Equal(default, entry.NextEligible);
+
+ var now = Core.Now;
+ spawner.OnTick();
+
+ Assert.Empty(spawner.Spawned);
+
+ // A failed placement backs the entry off by min(30s, MinDelay) rather than the full delay.
+ Assert.Equal(now + TimeSpan.FromSeconds(30), entry.NextEligible);
+ Assert.False(entry.IsDue(now));
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void T5_SuccessfulSpawn_SetsTheEntryDeadline()
+ {
+ var spawner = Place(4);
+ try
+ {
+ var entry = spawner.ModernEntries[0];
+ entry.MinDelay = TimeSpan.FromSeconds(30);
+ entry.MaxDelay = TimeSpan.FromSeconds(30);
+
+ var now = Core.Now;
+ spawner.OnTick();
+
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(now + TimeSpan.FromSeconds(30), entry.NextEligible);
+
+ // The only entry is parked for thirty seconds, so that is what the timer is armed at.
+ Assert.Equal(TimeSpan.FromSeconds(30), spawner.NextSpawn);
+
+ // The entry is parked, so the next tick spawns nothing until the delay elapses.
+ spawner.OnTick();
+ Assert.Single(spawner.Spawned);
+
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(31));
+ spawner.OnTick();
+ Assert.Equal(2, spawner.Spawned.Count);
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region Kill triggers through the real death hook
+
+ [Fact]
+ public void Kill_ThresholdCountsThroughTheAcceptancePath()
+ {
+ var spawner = Place(4, "kill:2:false:true:any:false:0");
+ try
+ {
+ var id = spawner.TriggerDefinitions[0].Id;
+ spawner.Spawn();
+ var rabbit = (BaseCreature)Assert.Single(spawner.Spawned).Key;
+
+ // The first kill counts but does not reach the threshold, so it buys nothing.
+ spawner.NotifySpawnedDeath(rabbit, null);
+ Assert.Equal(1, spawner.GetTriggerState(id).KillCount);
+ Assert.Single(spawner.Spawned);
+
+ // The second reaches it, runs a cycle, and resets the counter.
+ spawner.NotifySpawnedDeath(rabbit, null);
+ Assert.Equal(0, spawner.GetTriggerState(id).KillCount);
+ Assert.Equal(2, spawner.Spawned.Count);
+
+ rabbit.Corpse?.Delete();
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Kill_BelowThresholdKillCountsWhileTheTriggerIsOnCooldown()
+ {
+ // kill:requiredKills:requireAllDead:resetOnTrigger:filterType:requirePlayerKiller:cooldownSeconds
+ var spawner = Place(6, "kill:3:false:true:any:false:60");
+ try
+ {
+ var id = spawner.TriggerDefinitions[0].Id;
+ var state = spawner.GetTriggerState(id);
+
+ spawner.Spawn();
+ var rabbit = (BaseCreature)Assert.Single(spawner.Spawned).Key;
+ var spawnedBefore = spawner.Spawned.Count;
+
+ state.CooldownUntil = Core.Now + TimeSpan.FromSeconds(60);
+
+ // The cooldown gates the trigger firing, not the kills that build up to it.
+ spawner.NotifySpawnedDeath(rabbit, null);
+ spawner.NotifySpawnedDeath(rabbit, null);
+ Assert.Equal(2, state.KillCount);
+ Assert.Equal(spawnedBefore, spawner.Spawned.Count);
+
+ // The kill that reaches the threshold is refused by the cooldown - and it counts anyway,
+ // exactly once: the cooldown gates the cycle, never the counter.
+ spawner.NotifySpawnedDeath(rabbit, null);
+ Assert.Equal(3, state.KillCount);
+ Assert.Equal(spawnedBefore, spawner.Spawned.Count);
+
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(61));
+
+ // With the cooldown elapsed the next kill is accepted, and acceptance is what resets.
+ spawner.NotifySpawnedDeath(rabbit, null);
+ Assert.Equal(0, state.KillCount);
+ Assert.Equal(spawnedBefore + 1, spawner.Spawned.Count);
+
+ rabbit.Corpse?.Delete();
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Kill_RequireAllDead_FiresOnTheKillThatClearsThePack()
+ {
+ // kill:requiredKills:requireAllDead:resetOnTrigger:filterType:requirePlayerKiller:cooldownSeconds
+ var spawner = Place(4, "kill:1:true:true:any:false:0");
+ try
+ {
+ spawner.Spawn();
+ var rabbit = (BaseCreature)Assert.Single(spawner.Spawned).Key;
+
+ // The dying creature is still in the registry when the spawner is notified, so "all dead"
+ // has to be read excluding it - otherwise the kill that clears the pack never qualifies.
+ spawner.NotifySpawnedDeath(rabbit, null);
+
+ Assert.Equal(2, spawner.Spawned.Count);
+
+ rabbit.Corpse?.Delete();
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Kill_RequireAllDead_DoesNotFireWhileAnotherSpawnIsAlive()
+ {
+ var spawner = Place(4, "kill:1:true:true:any:false:0");
+ try
+ {
+ spawner.Spawn();
+ spawner.Spawn();
+ Assert.Equal(2, spawner.Spawned.Count);
+
+ var rabbit = (BaseCreature)new List(spawner.Spawned.Keys)[0];
+ spawner.NotifySpawnedDeath(rabbit, null);
+
+ // One other spawn is still alive, so the pack is not clear and nothing is bought.
+ Assert.Equal(2, spawner.Spawned.Count);
+ Assert.Equal(0, spawner.PendingCycleCount);
+
+ rabbit.Corpse?.Delete();
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region The when: condition
+
+ [Fact]
+ public void When_FailingConditionRejectsTheEventAndLeavesTheCooldown()
+ {
+ var spawner = Place(4, "proximity:8:true:false:5:0:when:trigmob.Fame > 100");
+ var player = PlacePlayer();
+ try
+ {
+ var id = spawner.TriggerDefinitions[0].Id;
+
+ player.Fame = 0;
+ Move(spawner, player);
+
+ // Rejected before any acceptance side effect: no cycle, and the cooldown never moved.
+ Assert.Empty(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ Assert.Equal(default, spawner.GetTriggerState(id).CooldownUntil);
+
+ player.Fame = 500;
+ Move(spawner, player);
+
+ Assert.Single(spawner.Spawned);
+ Assert.NotEqual(default, spawner.GetTriggerState(id).CooldownUntil);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void E0_RefusedTrigger_DoesNotStopTheDispatch()
+ {
+ // Two proximity triggers. The first matches the movement but its when: refuses it, so the
+ // dispatch has to go on and give the second one its turn.
+ var spawner = Place(
+ 4,
+ "proximity:8:true:false:0:0:when:trigmob.Fame > 100",
+ Proximity
+ );
+
+ var player = PlacePlayer();
+ try
+ {
+ var refusedId = spawner.TriggerDefinitions[0].Id;
+ var acceptedId = spawner.TriggerDefinitions[1].Id;
+
+ player.Fame = 0;
+ Move(spawner, player);
+
+ Assert.Single(spawner.Spawned);
+
+ // The second trigger is the one that paid: only it advanced its cooldown.
+ Assert.Equal(default, spawner.GetTriggerState(refusedId).CooldownUntil);
+ Assert.Equal(default, spawner.GetTriggerState(acceptedId).CooldownUntil);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ #region §5 - reentrancy, the drain list and the recursion bound
+
+ [Fact]
+ public void Script_SPAWN_InsideCycle_DrainsAfter()
+ {
+ var helper = Place(10, Proximity);
+ helper.Name = "D2ScriptHelper";
+
+ var spawner = Place(3, Proximity);
+ spawner.ModernEntries[0].OnSpawnScript = $"SPAWN/{helper.Name}";
+ var player = PlacePlayer();
+ try
+ {
+ Move(spawner, player);
+
+ // The trigger-bought cycle ran, and the script it executed spawned through the helper.
+ Assert.Single(spawner.Spawned);
+ Assert.Single(helper.Spawned);
+
+ // The helper's own spawn was manual (M1): it bypassed the helper's trigger machinery
+ // rather than buying a cycle there.
+ Assert.Equal(0, helper.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ DeleteSpawned(helper);
+ helper.Delete();
+ }
+ }
+
+ [Fact]
+ public void Script_ReentrantEvent_IsQueuedAndBoundedByTheRecursionLimit()
+ {
+ var spawner = Place(50, Proximity);
+ var player = PlacePlayer();
+ spawner.ModernEntries[0].PositioningRule = ReentrantProbeRule.Name;
+ ReentrantProbeRule.Watch(spawner, player);
+ try
+ {
+ Move(spawner, player);
+
+ // Each cycle raises another event from inside itself. Those are queued, never nested, and
+ // the drain stops at the product spec's recursion limit of 10 per spawner per round.
+ Assert.Equal(10, spawner.Spawned.Count);
+
+ // The event raised by the tenth cycle is still waiting rather than lost.
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ // The nested event never ran inside the cycle that raised it: every probe call saw a
+ // spawn count one lower than the cycle it belonged to.
+ Assert.True(ReentrantProbeRule.NeverNested);
+ }
+ finally
+ {
+ ReentrantProbeRule.Reset();
+ player.Delete();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Script_TickInitiatedCycle_DrainsFollowUpsInTheSameTick()
+ {
+ // A cycle started by the tick is not inside a drain, so its follow-ups have no outer loop
+ // waiting for them: the cycle itself has to hand them to the drain list on the way out, or
+ // they would sit until the next tick.
+ var spawner = Place(50);
+ spawner.ModernEntries[0].PositioningRule = ExternalTriggerProbeRule.Name;
+ ExternalTriggerProbeRule.Watch(spawner);
+ try
+ {
+ spawner.OnTick();
+
+ // The tick's own cycle plus ten drained follow-ups: the eleventh request is refused by the
+ // recursion limit and waits for the next tick rather than running away.
+ Assert.Equal(11, spawner.Spawned.Count);
+ Assert.Equal(1, spawner.PendingCycleCount);
+
+ // ...and that next tick actually comes: the capped drain armed the timer for it rather
+ // than leaving the slot to whatever else might happen to arm the spawner.
+ Assert.True(spawner.Running);
+
+ ExternalTriggerProbeRule.Reset();
+
+ // The eleven spawns pushed the entry's own deadline out, and a tick cycle honours it
+ // (T5 before T6), so let it elapse before asking for the retained slot.
+ ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromMinutes(11));
+ spawner.OnTick();
+
+ Assert.Equal(12, spawner.Spawned.Count);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ ExternalTriggerProbeRule.Reset();
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ #endregion
+
+ ///
+ /// A positioning rule that records what the cycle body could see when it asked for a position.
+ /// Registered like any other rule, so it runs exactly where player_relative would.
+ ///
+ private sealed class DispatchProbeRule : IPositioningRule
+ {
+ /// The rule name entries reference.
+ public static readonly string Name = "d2_dispatch_probe";
+
+ private static ModernSpawner _watched;
+
+ static DispatchProbeRule() => PositioningRules.Register(new DispatchProbeRule());
+
+ ///
+ public string RuleName => Name;
+
+ ///
+ public string Description => "Test probe: records dispatch state and the triggering mobile.";
+
+ /// How many times the probe ran for the watched spawner.
+ public static int Calls { get; private set; }
+
+ /// Whether the trigger system was still dispatching when the cycle body ran.
+ public static bool WasDispatching { get; private set; }
+
+ /// The triggering mobile the cycle threaded into positioning.
+ public static Mobile LastTriggeringMobile { get; private set; }
+
+ /// Starts recording for one spawner.
+ /// The spawner whose cycles are observed.
+ public static void Watch(ModernSpawner spawner)
+ {
+ Reset();
+ _watched = spawner;
+ }
+
+ /// Stops recording and clears what was recorded.
+ public static void Reset()
+ {
+ _watched = null;
+ Calls = 0;
+ WasDispatching = false;
+ LastTriggeringMobile = null;
+ }
+
+ ///
+ public Point3D GetPosition(PositioningContext context)
+ {
+ if (context.Spawner == _watched)
+ {
+ Calls++;
+ WasDispatching |= TriggerSystem.Instance.IsDispatching;
+ LastTriggeringMobile = context.TriggeringMobile;
+ }
+
+ // Point3D.Zero falls through to the default positioner, so the spawn still lands.
+ return Point3D.Zero;
+ }
+ }
+
+ ///
+ /// A positioning rule that raises a real proximity event from inside the cycle it is positioning
+ /// for, which is the reentrancy §5 bounds: the event must queue rather than nest.
+ ///
+ private sealed class ReentrantProbeRule : IPositioningRule
+ {
+ /// The rule name entries reference.
+ public static readonly string Name = "d2_reentrant_probe";
+
+ private static ModernSpawner _watched;
+ private static Mobile _mover;
+
+ static ReentrantProbeRule() => PositioningRules.Register(new ReentrantProbeRule());
+
+ ///
+ public string RuleName => Name;
+
+ ///
+ public string Description => "Test probe: raises a proximity event from inside a cycle.";
+
+ /// Whether every nested event stayed queued instead of spawning inside its cycle.
+ public static bool NeverNested { get; private set; } = true;
+
+ /// Starts raising nested events for one spawner.
+ /// The spawner whose cycles raise the nested event.
+ /// The mobile the nested movement event names.
+ public static void Watch(ModernSpawner spawner, Mobile mover)
+ {
+ Reset();
+ _watched = spawner;
+ _mover = mover;
+ }
+
+ /// Stops raising events and clears what was recorded.
+ public static void Reset()
+ {
+ _watched = null;
+ _mover = null;
+ NeverNested = true;
+ }
+
+ ///
+ public Point3D GetPosition(PositioningContext context)
+ {
+ var spawner = context.Spawner;
+ if (spawner != _watched || _mover == null)
+ {
+ return Point3D.Zero;
+ }
+
+ // The spawn this cycle is positioning is already in the registry; anything more would be a
+ // cycle that ran nested inside this one.
+ var before = spawner.Spawned.Count;
+
+ spawner.OnMovement(_mover, new Point3D(_mover.X + 1, _mover.Y, _mover.Z));
+
+ NeverNested &= spawner.Spawned.Count == before;
+
+ return Point3D.Zero;
+ }
+ }
+
+ ///
+ /// A positioning rule that raises an external event - the script / command entry point -
+ /// from inside the cycle it is positioning for. Unlike the proximity probe this needs no trigger
+ /// definitions, so the spawner it watches ticks on its own timer.
+ ///
+ private sealed class ExternalTriggerProbeRule : IPositioningRule
+ {
+ /// The rule name entries reference.
+ public static readonly string Name = "d2_external_trigger_probe";
+
+ private static ModernSpawner _watched;
+
+ static ExternalTriggerProbeRule() => PositioningRules.Register(new ExternalTriggerProbeRule());
+
+ ///
+ public string RuleName => Name;
+
+ ///
+ public string Description => "Test probe: calls Trigger() from inside a cycle.";
+
+ /// Starts raising external events for one spawner.
+ /// The spawner whose cycles raise the event.
+ public static void Watch(ModernSpawner spawner)
+ {
+ Reset();
+ _watched = spawner;
+ }
+
+ /// Stops raising events.
+ public static void Reset() => _watched = null;
+
+ ///
+ public Point3D GetPosition(PositioningContext context)
+ {
+ if (context.Spawner == _watched)
+ {
+ context.Spawner.Trigger();
+ }
+
+ return Point3D.Zero;
+ }
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs b/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs
new file mode 100644
index 0000000..9675698
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs
@@ -0,0 +1,266 @@
+using System;
+using System.IO;
+using Server.Engines.ModernSpawner.Serialization;
+using Server.Engines.ModernSpawner.Triggers;
+using Xunit;
+
+namespace Server.Engines.ModernSpawner.Tests;
+
+///
+/// Task 4: 's ServUO Points ingestion gains the same D2 mappings as
+/// - refractory, SpawnOnTrigger,
+/// the conjunctive proximity+speech+property trigger, IsGroup and time-of-day. This needs a resolvable
+/// , so it runs against the real world in the sequential collection (see
+/// ).
+///
+[Collection("Sequential ModernSpawner Tests")]
+public class XmlSpawnerImporterMappingTests
+{
+ private static string BuildXml(string name, string extraElements) => $"""
+
+
+ {name}
+
+ 1500
+ 1500
+ 0
+ 1500
+ 1500
+ 0
+ 0
+ 4
+ 5
+ 5
+ 10
+ False
+ 0
+ False
+ False
+ {extraElements}
+
+
+ """;
+
+ private static (ModernSpawner Spawner, XmlSpawnerImporter.ImportResult Result) Import(string extraElements)
+ {
+ var name = "ImporterMappingTest-" + Guid.NewGuid();
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(tempFile, BuildXml(name, extraElements));
+ var result = XmlSpawnerImporter.ImportFromFile(tempFile, respawn: false);
+ Assert.Equal(1, result.Imported);
+ return (FindByName(name), result);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ private static ModernSpawner FindByName(string name)
+ {
+ foreach (var item in World.Items.Values)
+ {
+ if (item is ModernSpawner spawner && spawner.Name == name)
+ {
+ return spawner;
+ }
+ }
+
+ Assert.Fail($"No imported ModernSpawner named '{name}' was found in the world.");
+ return null;
+ }
+
+ [Fact]
+ public void MinMaxRefractory_MapToRefractoryMinMax_AsMinutes()
+ {
+ var (spawner, _) = Import("25");
+ try
+ {
+ Assert.Equal(TimeSpan.FromMinutes(2), spawner.RefractoryMin);
+ Assert.Equal(TimeSpan.FromMinutes(5), spawner.RefractoryMax);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void SpawnOnTriggerAbsent_IsRunNowOrDrop()
+ {
+ var (spawner, _) = Import("8");
+ try
+ {
+ Assert.Equal(0, spawner.MaxPendingCycles);
+ Assert.Contains(spawner.TriggerDefinitions, d => d.Text == "proximity:8:true:false:5:0");
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void SpawnOnTriggerFalse_DefersToNextTickWithOneSlot()
+ {
+ var (spawner, _) = Import("8False");
+ try
+ {
+ Assert.Equal(1, spawner.MaxPendingCycles);
+ Assert.Contains(spawner.TriggerDefinitions, d => d.Text == "proximity:8:true:false:5:0:mode:tick");
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void ProximityAndSpeech_CollapseIntoOneConjunctiveSpeechTrigger()
+ {
+ var (spawner, _) = Import("12open");
+ try
+ {
+ Assert.DoesNotContain(spawner.TriggerDefinitions, d => d.Text.StartsWith("proximity:", StringComparison.Ordinal));
+ var speech = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("speech:", StringComparison.Ordinal));
+ var parsed = Server.Engines.ModernSpawner.Triggers.SpeechTrigger.Parse(speech.Text);
+ Assert.Equal("open", parsed.Keyword);
+ Assert.Equal(12, parsed.Range);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void PlayerPropertyName_WithoutSpeech_AttachesWhenToProximity()
+ {
+ var (spawner, _) = Import("8Karma>0");
+ try
+ {
+ var proximity = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("proximity:", StringComparison.Ordinal));
+ Assert.EndsWith(":when:trigMob.Karma > 0", proximity.Text);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ // Fix round 1: mode:tick must precede when: in the emitted definition text - TriggerTokens.Strip
+ // treats when: as consuming everything after it, so a token appended past it is swallowed into the
+ // expression source and never parses, silently dropping the deferral and leaving the when: dead.
+
+ [Fact]
+ public void SpawnOnTriggerFalseWithPlayerPropertyAndSpeech_ParsesModeTickWithACompilingWhen()
+ {
+ var (spawner, _) = Import(
+ "12open" +
+ "Karma>0False");
+ try
+ {
+ var speech = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("speech:", StringComparison.Ordinal));
+ var trigger = TriggerSystem.Instance.ParseTrigger(speech.Text);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ Assert.NotNull(trigger.When);
+ Assert.True(trigger.When.IsValid);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void SpawnOnTriggerFalseWithPlayerPropertyAlone_ParsesModeTickWithACompilingWhen()
+ {
+ var (spawner, _) = Import(
+ "Karma>0False");
+ try
+ {
+ var proximity = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("proximity:", StringComparison.Ordinal));
+ var trigger = TriggerSystem.Instance.ParseTrigger(proximity.Text);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ Assert.NotNull(trigger.When);
+ Assert.True(trigger.When.IsValid);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void SpawnOnTriggerFalseWithUnrepresentablePlayerPropertyAndSpeech_ParsesModeTickWithNoWhen()
+ {
+ var (spawner, result) = Import(
+ "8open" +
+ "GETONTHIS,Karma>0False");
+ try
+ {
+ var speech = Assert.Single(spawner.TriggerDefinitions, d => d.Text.StartsWith("speech:", StringComparison.Ordinal));
+ Assert.DoesNotContain(":when:", speech.Text);
+ Assert.EndsWith(":mode:tick", speech.Text);
+
+ var trigger = TriggerSystem.Instance.ParseTrigger(speech.Text);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ Assert.Null(trigger.When);
+ Assert.Contains(result.Notes, n => n.Contains("PlayerPropertyName") && n.Contains("GETONTHIS,Karma>0"));
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void IsGroup_SetsBaseGroupOnly_NotAllEntriesCycleMode()
+ {
+ var (spawner, _) = Import("True");
+ try
+ {
+ Assert.True(spawner.Group);
+ Assert.NotEqual(SpawnCycleMode.AllEntries, spawner.CycleMode);
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Theory]
+ [InlineData("0", "wall_time_window:")] // Realtime
+ [InlineData("1", "game_time_window:")] // Gametime
+ public void TodMode_MapsToTheMatchingGateAndAddsDespawnNote(string todMode, string expectedPrefix)
+ {
+ // TODStart/TODEnd are TotalMinutes (dev-docs §2): 480 = 8:00, 1020 = 17:00.
+ var (spawner, result) = Import(
+ $"4801020{todMode}");
+ try
+ {
+ Assert.Contains(spawner.TriggerDefinitions, d => d.Text.StartsWith(expectedPrefix, StringComparison.Ordinal));
+ Assert.Contains(result.Notes, n => n.Contains("despawned live spawns") && n.Contains("D10"));
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void Duration_AddsReportNoteOnly()
+ {
+ var (spawner, result) = Import("30");
+ try
+ {
+ Assert.Contains(result.Notes, n => n.Contains("Duration") && n.Contains("D10"));
+ }
+ finally
+ {
+ spawner.Delete();
+ }
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Migration/XmlSpawnerPropertyExpressionTests.cs b/Projects/ModernSpawner.Tests/Migration/XmlSpawnerPropertyExpressionTests.cs
new file mode 100644
index 0000000..3cbfb33
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Migration/XmlSpawnerPropertyExpressionTests.cs
@@ -0,0 +1,67 @@
+using Server.Engines.ModernSpawner.Migration;
+using Xunit;
+
+namespace Server.Engines.ModernSpawner.Tests.Migration;
+
+///
+/// translates the XmlSpawner PlayerPropertyName grammar
+/// (dev-docs/xmlspawner-migration.md §5) into the ModernSpawner expression engine's syntax, for use as a
+/// trigger's when: condition. Pure string translation - no world needed.
+///
+public class XmlSpawnerPropertyExpressionTests
+{
+ [Theory]
+ [InlineData("Karma>0", "trigMob.Karma > 0")]
+ [InlineData("Karma=0", "trigMob.Karma == 0")]
+ [InlineData("Karma!=0", "trigMob.Karma != 0")]
+ [InlineData("Karma<0", "trigMob.Karma < 0")]
+ [InlineData("Karma>=0", "trigMob.Karma >= 0")]
+ [InlineData("Karma<=0", "trigMob.Karma <= 0")]
+ [InlineData("TRIGMOB.Karma>0", "trigMob.Karma > 0")]
+ [InlineData("Female=True", "trigMob.Female == true")]
+ [InlineData("~Karma>0", "not (trigMob.Karma > 0)")]
+ public void TryTranslate_SingleComparison_TranslatesOperatorAndOperands(string xml, string expected)
+ {
+ Assert.True(XmlSpawnerPropertyExpression.TryTranslate(xml, out var expression, out var reason));
+ Assert.Equal(expected, expression);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void TryTranslate_Conjunction_NestsRightAssociatively()
+ {
+ // BaseXmlSpawner.CheckPropertyString nests A & B | C as A & (B | C).
+ Assert.True(XmlSpawnerPropertyExpression.TryTranslate("Karma>0&Fame>0|Female=True", out var expression, out _));
+ Assert.Equal("(trigMob.Karma > 0 and (trigMob.Fame > 0 or trigMob.Female == true))", expression);
+ }
+
+ [Fact]
+ public void TryTranslate_StringValue_QuotesTheUnquotedXmlSpawnerLiteral()
+ {
+ // XmlSpawner property tests are never quoted; the value side falls back to a string literal
+ // once it fails every other literal/property shape.
+ Assert.True(XmlSpawnerPropertyExpression.TryTranslate("Name=SomePlayer", out var expression, out var reason));
+ Assert.Equal("trigMob.Name == \"SomePlayer\"", expression);
+ Assert.Null(reason);
+ }
+
+ [Theory]
+ [InlineData("GETONTHIS,Karma>0")]
+ [InlineData("PLAYERSINRANGE,5>0")]
+ [InlineData("Karma>RND,1,100")]
+ [InlineData("not an expression")]
+ public void TryTranslate_UnsupportedConstruct_FailsWithReason(string xml)
+ {
+ Assert.False(XmlSpawnerPropertyExpression.TryTranslate(xml, out var expression, out var reason));
+ Assert.Null(expression);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void TryTranslate_EmptyString_Fails()
+ {
+ Assert.False(XmlSpawnerPropertyExpression.TryTranslate("", out var expression, out var reason));
+ Assert.Null(expression);
+ Assert.NotNull(reason);
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Perf/TriggerPerfHarness.cs b/Projects/ModernSpawner.Tests/Perf/TriggerPerfHarness.cs
new file mode 100644
index 0000000..a7f218d
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Perf/TriggerPerfHarness.cs
@@ -0,0 +1,506 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using Server.Engines.ModernSpawner.Perf;
+using Server.Engines.ModernSpawner.Tests.Fixtures;
+using Server.Mobiles;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Server.Engines.ModernSpawner.Tests.Perf;
+
+///
+/// The D2 performance gate (design §9), as a world-backed timing harness rather than a lap walked on a
+/// live shard: 12,000 s on Felucca, a player walked along a fixed lap, and
+/// a tick over the whole population with the gate closed and again with it open.
+///
+///
+/// It is opt-in. The whole suite runs it as a no-op unless MODERNSPAWNER_PERF=1 is set, so the
+/// default run stays sub-second:
+///
+///
+/// MODERNSPAWNER_PERF=1 dotnet test Projects/ModernSpawner.Tests \
+/// --filter "FullyQualifiedName~TriggerPerfHarness" --logger "console;verbosity=detailed"
+///
+///
+/// Results are printed and written to perf-results.json beside the test binaries, so a run on
+/// main and a run on the branch can be diffed field by field.
+///
+///
+///
+/// What each phase measures, and why the population is shaped the way it is:
+///
+/// -
+/// Each spawner carries a closed wall-time gate at definition index 0 and a proximity trigger at index
+/// 1, plus one Rabbit entry capped at a single spawn. The gate is what makes a "gate closed" tick a
+/// real state rather than a hypothetical, and while it is closed DrainOne refuses every queued
+/// cycle, so no entity is ever spawned and the numbers are not swamped by creature construction.
+/// The spawn path has its own instrumentation () and its own
+/// scenario commands; this harness is about the per-event and per-tick overhead §9 bounds.
+///
+/// -
+/// Movement dispatch is what the engine's sector pass would do: for every step, every spawner within
+/// of the player gets .
+/// The candidate set is computed from the grid index (
+/// documents the layout), never by scanning all 12,000. Setting the player's location is outside the
+/// measured window: Mobile.Location is sector bookkeeping the engine would have paid anyway,
+/// and it does not itself dispatch to items (Mobile.Move does).
+///
+/// -
+/// The lap is measured twice. On the first pass each spawner the player reaches accepts one event and
+/// allocates the single queued cycle its bound allows, so that pass is first contact. By the second
+/// pass every one of them is holding that slot and the event is refused at the bound, which is the
+/// steady state a shard actually sees - lookup, evaluate, refuse - and the path §9 requires to be
+/// allocation-free. Both are reported: a regression could land in either.
+///
+/// -
+/// Both tick phases run with the queues cleared, so each tick exits in the authorization row rather
+/// than running a cycle: closed exits on the gate, open exits on "an event source with nothing queued".
+/// A tick that reaches the cycle body is a spawn measurement, not a tick measurement.
+///
+///
+///
+[Collection("Sequential ModernSpawner Tests")]
+[Trait("Category", "Perf")]
+public class TriggerPerfHarness
+{
+ /// Set this to 1 to actually run the harness.
+ private const string PerfEnvironmentVariable = "MODERNSPAWNER_PERF";
+
+ private const int SpawnerCount = 12_000;
+ private const int LapSteps = 2_000;
+
+ // Enough to JIT every path before the first measured pass; the rest of the warming is the first
+ // measured pass itself, which is deliberately the cold-state one.
+ private const int JitWarmupSteps = 100;
+ private const int Spacing = 4;
+
+ // A December-only window while the suite clock sits at 2020-01-01 noon: closed, and it cannot open
+ // under the harness. Definition index 0, which is the index OnGateOpened is given below.
+ private const string ClosedGate = "wall_time_window:0:0:23:59:127:2048";
+
+ // proximity:range:playersOnly:requireLos:cooldownSeconds:minAccess. Access level 0 is Player; the
+ // positional field is parsed as an int, so the enum name is not a spelling it accepts.
+ private const string Proximity = "proximity:8:true:false:0:0";
+
+ private static readonly Point3D Origin = new(1000, 1000, 0);
+
+ private static readonly double NanosecondsPerTick = 1_000_000_000.0 / Stopwatch.Frequency;
+
+ private readonly ITestOutputHelper _output;
+
+ /// Creates the harness.
+ /// xUnit's output sink; the harness prints its table through it.
+ public TriggerPerfHarness(ITestOutputHelper output)
+ {
+ _output = output;
+ ModernSpawnerTestServer.Initialize();
+ }
+
+ ///
+ /// Seeds 12,000 spawners, walks the lap, ticks them twice and reports. A no-op without
+ /// MODERNSPAWNER_PERF=1.
+ ///
+ [Fact]
+ public void TwelveThousandSpawners_MovementDispatchAndTickCost()
+ {
+ if (Environment.GetEnvironmentVariable(PerfEnvironmentVariable) != "1")
+ {
+ _output.WriteLine(
+ $"Skipped: the 12k trigger perf harness only runs with {PerfEnvironmentVariable}=1 set, " +
+ "so the default suite stays fast.");
+ return;
+ }
+
+ RunHarness();
+ }
+
+ private void RunHarness()
+ {
+ var spawners = new List(SpawnerCount);
+ PlayerMobile player = null;
+
+ try
+ {
+ var seedStart = Stopwatch.GetTimestamp();
+ var seeded = SpawnerPerfCommands.SeedGrid(
+ Map.Felucca,
+ Origin,
+ SpawnerCount,
+ Spacing,
+ spawners,
+ ClosedGate,
+ Proximity);
+ var seedMs = (Stopwatch.GetTimestamp() - seedStart) * NanosecondsPerTick / 1_000_000.0;
+
+ Assert.Equal(SpawnerCount, seeded);
+ Assert.Equal(SpawnerCount, spawners.Count);
+ Assert.False(spawners[0].GateOpen);
+ Assert.Equal(1, spawners[0].GateCount);
+ Assert.Equal(1, spawners[0].EventCount);
+
+ var gridSide = SpawnerPerfCommands.GridSide(SpawnerCount);
+ var lap = BuildLap(LapSteps, gridSide);
+
+ // Mobile.Player is not set by the constructor - production sets it at login - and the
+ // proximity trigger filters on it, so the harness sets it the way the trigger tests do.
+ player = new PlayerMobile { Name = "PerfWalker", Player = true };
+ player.MoveToWorld(lap[0], Map.Felucca);
+
+ // Warm up once, so nothing below is paying for JIT.
+ WalkLap(spawners, player, lap, gridSide, JitWarmupSteps);
+ TickAll(spawners);
+
+ // First contact: every spawner the lap reaches accepts one event and allocates the single
+ // queued cycle its bound allows.
+ var dispatchFirstContact = WalkLap(spawners, player, lap, gridSide, LapSteps);
+
+ // Steady state: the same lap again with every one of those slots still queued, so each
+ // event is refused at the bound.
+ var dispatchSteadyState = WalkLap(spawners, player, lap, gridSide, LapSteps);
+
+ ClearQueues(spawners);
+ var tickGateClosed = TickAll(spawners);
+
+ for (var i = 0; i < spawners.Count; i++)
+ {
+ // Definition index 0 is the gate. With an event source registered and nothing queued
+ // this only records the open edge and arms the timer; it runs no cycle.
+ spawners[i].OnGateOpened(0);
+ }
+
+ Assert.True(spawners[0].GateOpen);
+
+ ClearQueues(spawners);
+ var tickGateOpen = TickAll(spawners);
+
+ // One more pass with the counters on, to show what the SpawnerMetrics scope costs and to
+ // prove MeasureTick is wired into OnTick.
+ SpawnerMetrics.Reset();
+ SpawnerMetrics.Enable();
+ ClearQueues(spawners);
+ var tickInstrumented = TickAll(spawners);
+ var snapshot = SpawnerMetrics.Capture();
+ SpawnerMetrics.Disable();
+ SpawnerMetrics.Reset();
+
+ Assert.True(dispatchSteadyState.Operations > 0);
+ Assert.Equal(SpawnerCount, snapshot.TickCalls);
+
+ Report(
+ seedMs,
+ dispatchFirstContact,
+ dispatchSteadyState,
+ tickGateClosed,
+ tickGateOpen,
+ tickInstrumented,
+ snapshot);
+ }
+ finally
+ {
+ player?.Delete();
+
+ for (var i = 0; i < spawners.Count; i++)
+ {
+ var spawner = spawners[i];
+ if (spawner.Deleted)
+ {
+ continue;
+ }
+
+ foreach (var spawned in new List(spawner.Spawned.Keys))
+ {
+ spawned.Delete();
+ }
+
+ spawner.Delete();
+ }
+
+ spawners.Clear();
+ }
+ }
+
+ ///
+ /// The lap: a one-tile-per-step serpentine across the middle of the grid, so the candidate counts
+ /// per step are representative rather than all edge cases, and identical on every run.
+ ///
+ /// How many steps the lap has.
+ /// The grid side the spawners were seeded in.
+ /// The lap, one point per step.
+ private static Point3D[] BuildLap(int steps, int gridSide)
+ {
+ var extent = (gridSide - 1) * Spacing;
+ var lap = new Point3D[steps];
+
+ var x = 0;
+ var y = extent / 2;
+ var dx = 1;
+
+ for (var i = 0; i < steps; i++)
+ {
+ lap[i] = new Point3D(Origin.X + x, Origin.Y + y, Origin.Z);
+
+ x += dx;
+
+ if (x > extent)
+ {
+ x = extent;
+ dx = -1;
+ y++;
+ }
+ else if (x < 0)
+ {
+ x = 0;
+ dx = 1;
+ y++;
+ }
+
+ if (y > extent)
+ {
+ y = 0;
+ }
+ }
+
+ return lap;
+ }
+
+ private static PhaseResult WalkLap(
+ List spawners,
+ Mobile player,
+ Point3D[] lap,
+ int gridSide,
+ int steps
+ )
+ {
+ var range = Core.GlobalMaxUpdateRange;
+ var previous = player.Location;
+
+ var dispatches = 0L;
+ var elapsed = 0L;
+ var allocated = 0L;
+
+ for (var step = 0; step < steps; step++)
+ {
+ var point = lap[step];
+
+ // Outside the measured window: this is the engine's own sector bookkeeping, and unlike
+ // Mobile.Move it does not dispatch OnMovement to items, so it cannot double-count.
+ player.Location = point;
+
+ var bytesBefore = GC.GetAllocatedBytesForCurrentThread();
+ var start = Stopwatch.GetTimestamp();
+
+ dispatches += DispatchStep(spawners, gridSide, range, player, previous);
+
+ elapsed += Stopwatch.GetTimestamp() - start;
+ allocated += GC.GetAllocatedBytesForCurrentThread() - bytesBefore;
+
+ previous = point;
+ }
+
+ return new PhaseResult
+ {
+ Operations = dispatches,
+ ElapsedTicks = elapsed,
+ AllocatedBytes = allocated
+ };
+ }
+
+ ///
+ /// Dispatches one step to every spawner the engine's sector pass would reach, found by grid index.
+ ///
+ /// How many spawners were dispatched to.
+ private static int DispatchStep(
+ List spawners,
+ int gridSide,
+ int range,
+ Mobile player,
+ Point3D oldLocation
+ )
+ {
+ var location = player.Location;
+
+ var minI = LowIndex(location.X - range - Origin.X);
+ var maxI = HighIndex(location.X + range - Origin.X, gridSide);
+ var minJ = LowIndex(location.Y - range - Origin.Y);
+ var maxJ = HighIndex(location.Y + range - Origin.Y, gridSide);
+
+ var dispatched = 0;
+
+ for (var i = minI; i <= maxI; i++)
+ {
+ var rowStart = i * gridSide;
+
+ for (var j = minJ; j <= maxJ; j++)
+ {
+ var index = rowStart + j;
+ if (index >= spawners.Count)
+ {
+ // The last grid row is only partly filled when the count is not a square.
+ break;
+ }
+
+ spawners[index].OnMovement(player, oldLocation);
+ dispatched++;
+ }
+ }
+
+ return dispatched;
+ }
+
+ /// The first grid index at or past tiles from the origin.
+ /// Tile offset from the grid origin on one axis.
+ /// The index, never below zero.
+ private static int LowIndex(int offset) => offset <= 0 ? 0 : (offset + Spacing - 1) / Spacing;
+
+ /// The last grid index at or before tiles from the origin.
+ /// Tile offset from the grid origin on one axis.
+ /// The grid side, which bounds the index.
+ /// The index, or -1 when the offset is behind the origin.
+ private static int HighIndex(int offset, int gridSide)
+ {
+ if (offset < 0)
+ {
+ return -1;
+ }
+
+ var index = offset / Spacing;
+ return index >= gridSide ? gridSide - 1 : index;
+ }
+
+ private static PhaseResult TickAll(List spawners)
+ {
+ var bytesBefore = GC.GetAllocatedBytesForCurrentThread();
+ var start = Stopwatch.GetTimestamp();
+
+ for (var i = 0; i < spawners.Count; i++)
+ {
+ spawners[i].OnTick();
+ }
+
+ var elapsed = Stopwatch.GetTimestamp() - start;
+ var allocated = GC.GetAllocatedBytesForCurrentThread() - bytesBefore;
+
+ return new PhaseResult
+ {
+ Operations = spawners.Count,
+ ElapsedTicks = elapsed,
+ AllocatedBytes = allocated
+ };
+ }
+
+ ///
+ /// Empties every queue so a tick phase measures the authorization rows rather than a cycle.
+ ///
+ /// The seeded population.
+ private static void ClearQueues(List spawners)
+ {
+ for (var i = 0; i < spawners.Count; i++)
+ {
+ spawners[i].ResetTrigger();
+ }
+ }
+
+ private void Report(
+ double seedMs,
+ PhaseResult dispatchFirstContact,
+ PhaseResult dispatchSteadyState,
+ PhaseResult tickGateClosed,
+ PhaseResult tickGateOpen,
+ PhaseResult tickInstrumented,
+ SpawnerMetrics.Snapshot snapshot
+ )
+ {
+ _output.WriteLine("--- ModernSpawner D2 trigger perf harness ---");
+ _output.WriteLine(
+ $"spawners={SpawnerCount} lapSteps={LapSteps} spacing={Spacing} " +
+ $"updateRange={Core.GlobalMaxUpdateRange} seedMs={Format(seedMs)}");
+ _output.WriteLine(Line("dispatch, first contact", dispatchFirstContact));
+ _output.WriteLine(Line("dispatch, steady state", dispatchSteadyState));
+ _output.WriteLine(Line("tick, gate closed", tickGateClosed));
+ _output.WriteLine(Line("tick, gate open", tickGateOpen));
+ _output.WriteLine(
+ Line("tick, instrumented", tickInstrumented) +
+ $" (SpawnerMetrics reports {Format(snapshot.TickAvgUs * 1000.0)} ns/call over {snapshot.TickCalls} calls)");
+
+ var path = Path.Combine(AppContext.BaseDirectory, "perf-results.json");
+ File.WriteAllText(
+ path,
+ BuildJson(
+ seedMs,
+ dispatchFirstContact,
+ dispatchSteadyState,
+ tickGateClosed,
+ tickGateOpen,
+ tickInstrumented,
+ snapshot));
+ _output.WriteLine($"results written to {path}");
+ }
+
+ private static string Line(string label, PhaseResult result) =>
+ $"{label,-23}: {result.Operations,10} calls {Format(result.NanosecondsPerOperation),10} ns/call " +
+ $"{Format(result.BytesPerOperation),8} bytes/call ({Format(result.TotalMilliseconds)} ms total)";
+
+ private static string BuildJson(
+ double seedMs,
+ PhaseResult dispatchFirstContact,
+ PhaseResult dispatchSteadyState,
+ PhaseResult tickGateClosed,
+ PhaseResult tickGateOpen,
+ PhaseResult tickInstrumented,
+ SpawnerMetrics.Snapshot snapshot
+ ) =>
+ $$"""
+ {
+ "harness": "ModernSpawner.Tests/Perf/TriggerPerfHarness",
+ "utc": "{{DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)}}",
+ "spawnerCount": {{SpawnerCount}},
+ "lapSteps": {{LapSteps}},
+ "jitWarmupSteps": {{JitWarmupSteps}},
+ "spacing": {{Spacing}},
+ "globalMaxUpdateRange": {{Core.GlobalMaxUpdateRange}},
+ "seedMilliseconds": {{Format(seedMs)}},
+ "movementDispatchFirstContact": {{Json(dispatchFirstContact)}},
+ "movementDispatchSteadyState": {{Json(dispatchSteadyState)}},
+ "tickGateClosed": {{Json(tickGateClosed)}},
+ "tickGateOpen": {{Json(tickGateOpen)}},
+ "tickInstrumented": {{Json(tickInstrumented)}},
+ "spawnerMetricsTickAvgNs": {{Format(snapshot.TickAvgUs * 1000.0)}},
+ "spawnerMetricsTickCalls": {{snapshot.TickCalls}},
+ "entitiesSpawned": {{snapshot.EntitiesSpawned}}
+ }
+
+ """;
+
+ private static string Json(PhaseResult result) =>
+ $$"""
+ { "calls": {{result.Operations}}, "nsPerCall": {{Format(result.NanosecondsPerOperation)}}, "bytesPerCall": {{Format(result.BytesPerOperation)}}, "totalMs": {{Format(result.TotalMilliseconds)}}, "totalBytes": {{result.AllocatedBytes}} }
+ """;
+
+ private static string Format(double value) => value.ToString("F2", CultureInfo.InvariantCulture);
+
+ /// One measured phase: how many operations, how long they took, what they allocated.
+ private readonly struct PhaseResult
+ {
+ /// How many calls the phase made.
+ public long Operations { get; init; }
+
+ /// ticks spent inside the measured calls.
+ public long ElapsedTicks { get; init; }
+
+ /// Managed bytes allocated on this thread inside the measured calls.
+ public long AllocatedBytes { get; init; }
+
+ /// Nanoseconds per call.
+ public double NanosecondsPerOperation =>
+ Operations == 0 ? 0 : ElapsedTicks * NanosecondsPerTick / Operations;
+
+ /// Bytes allocated per call.
+ public double BytesPerOperation => Operations == 0 ? 0 : (double)AllocatedBytes / Operations;
+
+ /// Total milliseconds the phase spent in the measured calls.
+ public double TotalMilliseconds => ElapsedTicks * NanosecondsPerTick / 1_000_000.0;
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs b/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs
index b774db2..f3d1973 100644
--- a/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs
+++ b/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs
@@ -358,20 +358,20 @@ public void Options_SequentialCycleMode_RoundTrip()
}
[Fact]
- public void Options_GroupCycleMode_RoundTrip()
+ public void Options_AllEntriesCycleMode_RoundTrip()
{
var source = new SpawnerExportData
{
Options = new OptionsData
{
- CycleMode = SpawnCycleMode.Group
+ CycleMode = SpawnCycleMode.AllEntries
}
};
var restored = RoundTrip(source);
Assert.NotNull(restored.Options);
- Assert.Equal(SpawnCycleMode.Group, restored.Options.CycleMode);
+ Assert.Equal(SpawnCycleMode.AllEntries, restored.Options.CycleMode);
}
[Fact]
@@ -403,4 +403,16 @@ public void Empty_Entries_And_Triggers_Default_To_Empty_Lists()
Assert.NotNull(restored.Triggers);
Assert.Empty(restored.Triggers);
}
+
+ [Fact]
+ public void SpawnCycleMode_Group_AliasParses()
+ {
+ // "Group" was the exported name before the rename; saved files keep parsing into AllEntries.
+ Assert.Equal(
+ SpawnCycleMode.AllEntries,
+ JsonSerializer.Deserialize("\"Group\"", Options));
+
+ // ...and the canonical name is what gets written back out.
+ Assert.Equal("\"AllEntries\"", JsonSerializer.Serialize(SpawnCycleMode.AllEntries, Options));
+ }
}
diff --git a/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs b/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs
index 3064c59..27039fb 100644
--- a/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs
+++ b/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs
@@ -5,23 +5,17 @@ namespace Server.Engines.ModernSpawner.Tests.Serialization;
public class XmlSpawnerLegacyMappingTests
{
+ // IsGroup maps to base Group only, never to the AllEntries cycle mode (design §7): the two flags used
+ // to be conflated, which made an imported group spawner run one attempt per entry per cycle on top of
+ // its bulk respawn instead of the plain random/sequential draw XmlSpawner's own "group" spawner used.
+ // SequentialSpawn is the only flag that still drives the cycle mode.
[Theory]
- [InlineData(false, -1, SpawnCycleMode.Random)]
- [InlineData(false, 0, SpawnCycleMode.Sequential)]
- [InlineData(false, 1, SpawnCycleMode.Sequential)]
- [InlineData(false, 5, SpawnCycleMode.Sequential)]
- [InlineData(true, -1, SpawnCycleMode.Group)]
- public void MapLegacyCycleMode_ReturnsExpectedMode(bool isGroup, int sequentialSpawn, SpawnCycleMode expected)
+ [InlineData(-1, SpawnCycleMode.Random)]
+ [InlineData(0, SpawnCycleMode.Sequential)]
+ [InlineData(1, SpawnCycleMode.Sequential)]
+ [InlineData(5, SpawnCycleMode.Sequential)]
+ public void MapLegacyCycleMode_ReturnsExpectedMode(int sequentialSpawn, SpawnCycleMode expected)
{
- Assert.Equal(expected, XmlSpawnerImporter.MapLegacyCycleMode(isGroup, sequentialSpawn));
- }
-
- [Fact]
- public void MapLegacyCycleMode_IsGroup_Wins_Over_SequentialSpawn()
- {
- // IsGroup takes precedence even if SequentialSpawn is also set.
- Assert.Equal(
- SpawnCycleMode.Group,
- XmlSpawnerImporter.MapLegacyCycleMode(isGroup: true, sequentialSpawn: 3));
+ Assert.Equal(expected, XmlSpawnerImporter.MapLegacyCycleMode(sequentialSpawn));
}
}
diff --git a/Projects/ModernSpawner.Tests/Triggers/KillTriggerCounterTests.cs b/Projects/ModernSpawner.Tests/Triggers/KillTriggerCounterTests.cs
new file mode 100644
index 0000000..6434892
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Triggers/KillTriggerCounterTests.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using Server.Engines.ModernSpawner.Triggers;
+using Xunit;
+
+namespace Server.Engines.ModernSpawner.Tests.Triggers;
+
+///
+/// The kill counter was split in two when became pure:
+/// reads and reports
+/// whether this kill reaches the threshold, and
+/// does the writing. These pin the two halves against each
+/// other above threshold 1, and pin that the count now lives on the spawner rather than on the parsed
+/// trigger object.
+///
+[Collection("Sequential ModernSpawner Tests")]
+public class KillTriggerCounterTests
+{
+ private static KillTrigger Unbound(string definition)
+ {
+ var trigger = KillTrigger.Parse(definition);
+ Assert.NotNull(trigger);
+
+ // Registration is what normally binds this; assigning it by hand keeps the counter tests pure.
+ trigger.State = new TriggerRuntimeState(null, Guid.NewGuid());
+ return trigger;
+ }
+
+ private static TriggerContext AnyKill(ModernSpawner spawner = null) =>
+ TriggerContext.ForKill(spawner, new Entity(Serial.Zero), null);
+
+ private static ModernSpawner Place(string definition)
+ {
+ var spawner = new ModernSpawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit");
+ spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
+ spawner.AddTriggerDefinition(definition);
+ spawner.TriggerActivated = true;
+ return spawner;
+ }
+
+ private static void DeleteSpawned(ModernSpawner spawner)
+ {
+ foreach (var spawned in new List(spawner.Spawned.Keys))
+ {
+ spawned.Delete();
+ }
+ }
+
+ // One dispatch's worth of bookkeeping, in the order TriggerSystem.OnEntityKilled uses it: Evaluate
+ // first (it reads KillCount + 1), then the advance, which resets only on an accepted kill.
+ private static bool Kill(KillTrigger trigger, in TriggerContext context)
+ {
+ var accepted = trigger.Evaluate(in context);
+ if (trigger.CountsKill(in context))
+ {
+ trigger.AdvanceKillCount(accepted);
+ }
+
+ return accepted;
+ }
+
+ [Fact]
+ public void ThresholdOfThree_FiresOnlyOnTheThirdKill()
+ {
+ var trigger = Unbound("kill:3");
+ var context = AnyKill();
+
+ Assert.False(Kill(trigger, in context));
+ Assert.Equal(1, trigger.State.KillCount);
+
+ Assert.False(Kill(trigger, in context));
+ Assert.Equal(2, trigger.State.KillCount);
+
+ Assert.True(Kill(trigger, in context));
+ }
+
+ [Fact]
+ public void ResetOnTrigger_ZeroesTheCountOnlyWhenTheKillWasAccepted()
+ {
+ var trigger = Unbound("kill:2");
+ Assert.True(trigger.ResetOnTrigger);
+
+ var context = AnyKill();
+
+ Assert.False(Kill(trigger, in context));
+ Assert.Equal(1, trigger.State.KillCount);
+
+ // The accepted kill is the one that clears the counter, so the next threshold starts over.
+ Assert.True(Kill(trigger, in context));
+ Assert.Equal(0, trigger.State.KillCount);
+
+ Assert.False(Kill(trigger, in context));
+ Assert.Equal(1, trigger.State.KillCount);
+ }
+
+ [Fact]
+ public void ResetOnTriggerFalse_KeepsCountingPastTheThreshold()
+ {
+ // kill:requiredKills:requireAllDead:resetOnTrigger:filterType:requirePlayerKiller:cooldownSeconds
+ var trigger = Unbound("kill:2:false:false:any:false:0");
+ Assert.False(trigger.ResetOnTrigger);
+
+ var context = AnyKill();
+
+ Assert.False(Kill(trigger, in context));
+ Assert.True(Kill(trigger, in context));
+ Assert.Equal(2, trigger.State.KillCount);
+
+ Assert.True(Kill(trigger, in context));
+ Assert.Equal(3, trigger.State.KillCount);
+ }
+
+ [Fact]
+ public void FilteredOutKill_DoesNotCount()
+ {
+ var trigger = Unbound("kill:2:false:true:Dragon:false:0");
+ var context = AnyKill();
+
+ Assert.False(Kill(trigger, in context));
+ Assert.False(trigger.CountsKill(in context));
+ Assert.Equal(0, trigger.State.KillCount);
+ }
+
+ [Fact]
+ public void RequireAllDead_BlocksTheCycleButTheKillStillCounts()
+ {
+ var spawner = Place("kill:1:true:true:any:false:0");
+
+ try
+ {
+ spawner.Spawn();
+ Assert.Single(spawner.Spawned);
+
+ var trigger = Assert.Single(TriggerSystem.Instance.GetSet(spawner).Kill);
+ Assert.NotNull(trigger.State);
+
+ var context = AnyKill(spawner);
+
+ // A spawn is still alive, so the threshold cannot fire - but the kill is not thrown away.
+ Assert.False(Kill(trigger, in context));
+ Assert.Equal(1, trigger.State.KillCount);
+
+ DeleteSpawned(spawner);
+ Assert.Empty(spawner.Spawned);
+
+ Assert.True(Kill(trigger, in context));
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void KillProgress_SurvivesDeactivateAndActivate()
+ {
+ var spawner = Place("kill:3");
+
+ try
+ {
+ var first = Assert.Single(TriggerSystem.Instance.GetSet(spawner).Kill);
+ var context = AnyKill(spawner);
+
+ Assert.False(Kill(first, in context));
+ Assert.False(Kill(first, in context));
+ Assert.Equal(2, first.State.KillCount);
+
+ // Re-registration parses a fresh trigger object; the count is spawner state keyed by the
+ // definition id, so it has to come back bound to the new object.
+ spawner.TriggerActivated = false;
+ Assert.False(TriggerSystem.Instance.IsRegistered(spawner));
+
+ spawner.TriggerActivated = true;
+ var second = Assert.Single(TriggerSystem.Instance.GetSet(spawner).Kill);
+
+ Assert.NotSame(first, second);
+ Assert.Equal(2, second.State.KillCount);
+ Assert.Equal(spawner.TriggerDefinitions[0].Id, second.Id);
+
+ Assert.True(Kill(second, in context));
+ }
+ finally
+ {
+ DeleteSpawned(spawner);
+ spawner.Delete();
+ }
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs b/Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs
new file mode 100644
index 0000000..b549212
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Text;
+using Server.Engines.ModernSpawner.Triggers;
+using Server.Mobiles;
+using Xunit;
+
+namespace Server.Engines.ModernSpawner.Tests.Triggers;
+
+///
+/// A speech trigger's regex is compiled at registration, and registration runs from world load. An
+/// invalid pattern is a configuration mistake, so it must be logged and ignored rather than thrown out of
+/// , where it would abort the load and leave a
+/// half-activated trigger set behind.
+///
+[Collection("Sequential ModernSpawner Tests")]
+public class SpeechTriggerRegexTests
+{
+ private static string Definition(string pattern)
+ {
+ // speech:keyword:ignoreCase:useRegex:range:playersOnly:cooldownSeconds
+ var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(pattern));
+ return $"speech:{encoded}:true:true:10:true:0";
+ }
+
+ private static ModernSpawner Place(string definition)
+ {
+ var spawner = new ModernSpawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit");
+ spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
+ spawner.AddTriggerDefinition(definition);
+ spawner.TriggerActivated = true;
+ return spawner;
+ }
+
+ private static PlayerMobile PlacePlayer(Point3D at)
+ {
+ var player = new PlayerMobile { Name = "Talker", Player = true };
+ player.MoveToWorld(at, Map.Felucca);
+ return player;
+ }
+
+ [Fact]
+ public void InvalidPattern_RegistersWithoutThrowingAndNeverMatches()
+ {
+ // "(unclosed" is not a valid regex: Regex's constructor throws ArgumentException on it.
+ var spawner = Place(Definition("(unclosed"));
+ var player = PlacePlayer(new Point3D(1503, 1500, 0));
+
+ try
+ {
+ Assert.True(TriggerSystem.Instance.IsRegistered(spawner));
+ Assert.True(spawner.HandlesOnSpeech);
+
+ TriggerSystem.Instance.OnSpeech(player, "(unclosed", player.Location, player.Map, spawner);
+ TriggerSystem.Instance.OnSpeech(player, "anything at all", player.Location, player.Map, spawner);
+
+ Assert.Equal(0, spawner.PendingCycleCount);
+ Assert.Empty(spawner.Spawned);
+ }
+ finally
+ {
+ player.Delete();
+ spawner.Delete();
+ }
+ }
+
+ [Fact]
+ public void ValidPattern_StillMatches()
+ {
+ var spawner = Place(Definition("^hail"));
+ var player = PlacePlayer(new Point3D(1503, 1500, 0));
+
+ try
+ {
+ TriggerSystem.Instance.OnSpeech(player, "nothing here", player.Location, player.Map, spawner);
+ Assert.Empty(spawner.Spawned);
+
+ // The accepted event buys a cycle, and the outermost dispatch runs it on the way out, so
+ // what the match leaves behind is a spawn rather than a queued slot.
+ TriggerSystem.Instance.OnSpeech(player, "hail friend", player.Location, player.Map, spawner);
+ Assert.Single(spawner.Spawned);
+ Assert.Equal(0, spawner.PendingCycleCount);
+ }
+ finally
+ {
+ player.Delete();
+ spawner.Delete();
+ }
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs b/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs
new file mode 100644
index 0000000..146e09a
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs
@@ -0,0 +1,92 @@
+using Server.Engines.ModernSpawner.Triggers;
+using Xunit;
+
+namespace Server.Engines.ModernSpawner.Tests.Triggers;
+
+///
+/// timeofday is retired as a trigger class and survives only as a factory alias: saved worlds,
+/// exports and XmlSpawner imports that still carry the old definition text parse into a
+/// . The legacy end hour was inclusive, so it maps to the window
+/// grammar's exclusive end (timeofday:8:17 covers 08:00-17:59, i.e. [8, 18)).
+///
+public class TimeOfDayAliasTests
+{
+ private static GameTimeWindowTrigger Parse(string definition) =>
+ Assert.IsType(TriggerSystem.Instance.ParseTrigger(definition));
+
+ [Theory]
+ [InlineData("timeofday:8:17", 8, 18)]
+ [InlineData("timeofday:20:6", 20, 7)]
+ [InlineData("timeofday:0:23", 0, 24)]
+ public void LegacyDefinition_ParsesToAGameTimeWindow_WithAnExclusiveEnd(string definition, int start, int end)
+ {
+ var trigger = Parse(definition);
+
+ Assert.Equal(start, trigger.StartHour);
+ Assert.Equal(end, trigger.EndHour);
+ Assert.Equal("game_time_window", trigger.TriggerType);
+ Assert.Equal(TriggerKind.Gate, trigger.Kind);
+ }
+
+ [Theory]
+ // The legacy inclusive test (hours >= start && hours <= end) and the window's half-open test must
+ // agree on every hour of the day.
+ [InlineData(8, 17)]
+ [InlineData(20, 6)]
+ [InlineData(0, 23)]
+ [InlineData(12, 12)]
+ // end == start - 1 (mod 24) wrapped all the way round in the legacy grammar and meant every hour;
+ // mapping it to [start, start) would have produced the empty window, the exact opposite.
+ [InlineData(10, 9)]
+ [InlineData(23, 22)]
+ [InlineData(1, 0)]
+ public void EveryHour_MatchesTheLegacyInclusiveTest(int legacyStart, int legacyEnd)
+ {
+ var trigger = Parse($"timeofday:{legacyStart}:{legacyEnd}");
+
+ for (var hour = 0; hour < 24; hour++)
+ {
+ var legacy = legacyEnd >= legacyStart
+ ? hour >= legacyStart && hour <= legacyEnd
+ : hour >= legacyStart || hour <= legacyEnd;
+
+ Assert.Equal(legacy, GameTimeWindowTrigger.IsHourInWindow(hour, trigger.StartHour, trigger.EndHour));
+ }
+ }
+
+ [Fact]
+ public void LegacyDefaults_CoverTheWholeDay()
+ {
+ var trigger = Parse("timeofday");
+
+ Assert.Equal(0, trigger.StartHour);
+ Assert.Equal(24, trigger.EndHour);
+
+ for (var hour = 0; hour < 24; hour++)
+ {
+ Assert.True(GameTimeWindowTrigger.IsHourInWindow(hour, trigger.StartHour, trigger.EndHour));
+ }
+ }
+
+ [Fact]
+ public void LegacyHours_AreClampedBeforeTheyAreConverted()
+ {
+ var trigger = Parse("timeofday:-5:30");
+
+ Assert.Equal(0, trigger.StartHour);
+ Assert.Equal(24, trigger.EndHour);
+ }
+
+ [Fact]
+ public void LegacyNightAndDayFlags_Survive()
+ {
+ var night = Parse("timeofday:0:23:true:false:60");
+ var day = Parse("timeofday:0:23:false:true:60");
+
+ Assert.True(night.NightOnly);
+ Assert.False(night.DayOnly);
+
+ Assert.False(day.NightOnly);
+ Assert.True(day.DayOnly);
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Triggers/TimeOfDayTriggerTests.cs b/Projects/ModernSpawner.Tests/Triggers/TimeOfDayTriggerTests.cs
deleted file mode 100644
index 4493b2d..0000000
--- a/Projects/ModernSpawner.Tests/Triggers/TimeOfDayTriggerTests.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-using System;
-using Server.Engines.ModernSpawner.Triggers;
-using Xunit;
-
-namespace Server.Engines.ModernSpawner.Tests.Triggers;
-
-public class TimeOfDayTriggerTests
-{
- [Fact]
- public void Parse_DefaultValues_WhenOnlyType()
- {
- var trigger = TimeOfDayTrigger.Parse("timeofday");
-
- Assert.NotNull(trigger);
- Assert.Equal("timeofday", trigger.TriggerType);
- Assert.Equal(0, trigger.StartHour);
- Assert.Equal(23, trigger.EndHour);
- Assert.False(trigger.NightOnly);
- Assert.False(trigger.DayOnly);
- Assert.Equal(TimeSpan.FromMinutes(1), trigger.Cooldown);
- }
-
- [Fact]
- public void Parse_HoursClampedInto_0_23()
- {
- var trigger = TimeOfDayTrigger.Parse("timeofday:-5:30");
-
- Assert.Equal(0, trigger.StartHour);
- Assert.Equal(23, trigger.EndHour);
- }
-
- [Fact]
- public void Parse_NightOnlyAndDayOnlyFlags()
- {
- var nightTrigger = TimeOfDayTrigger.Parse("timeofday:0:23:true:false:60");
- var dayTrigger = TimeOfDayTrigger.Parse("timeofday:0:23:false:true:60");
-
- Assert.True(nightTrigger.NightOnly);
- Assert.False(nightTrigger.DayOnly);
-
- Assert.False(dayTrigger.NightOnly);
- Assert.True(dayTrigger.DayOnly);
- }
-
- [Fact]
- public void Parse_AllParameters_RoundTrip()
- {
- var original = new TimeOfDayTrigger(8, 17)
- {
- NightOnly = false,
- DayOnly = true,
- Cooldown = TimeSpan.FromMinutes(2)
- };
-
- var parsed = TimeOfDayTrigger.Parse(original.Serialize());
-
- Assert.NotNull(parsed);
- Assert.Equal(original.StartHour, parsed.StartHour);
- Assert.Equal(original.EndHour, parsed.EndHour);
- Assert.Equal(original.NightOnly, parsed.NightOnly);
- Assert.Equal(original.DayOnly, parsed.DayOnly);
- Assert.Equal(original.Cooldown, parsed.Cooldown);
- }
-
- [Fact]
- public void Constructor_ClampsHours()
- {
- var trigger = new TimeOfDayTrigger(startHour: 99, endHour: -1);
-
- Assert.Equal(23, trigger.StartHour);
- Assert.Equal(0, trigger.EndHour);
- }
-}
diff --git a/Projects/ModernSpawner.Tests/Triggers/TriggerSetTests.cs b/Projects/ModernSpawner.Tests/Triggers/TriggerSetTests.cs
new file mode 100644
index 0000000..0061428
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Triggers/TriggerSetTests.cs
@@ -0,0 +1,101 @@
+using Server.Engines.ModernSpawner.Triggers;
+using Xunit;
+
+namespace Server.Engines.ModernSpawner.Tests.Triggers;
+
+///
+/// The one per spawner that replaced the six per-type dictionaries: adding a
+/// parsed trigger files it under its class and advances the event/gate counts the tick guards read.
+/// Pure - no world, no registration.
+///
+public class TriggerSetTests
+{
+ private static TriggerSet Populated()
+ {
+ var set = new TriggerSet();
+ set.Add(ProximityTrigger.Parse("proximity:8"));
+ set.Add(SpeechTrigger.Parse("speech:hello"));
+ set.Add(KillTrigger.Parse("kill:1"));
+ set.Add(SkillTrigger.Parse("skill:Mining:10"));
+ set.Add(GameTimeWindowTrigger.Parse("game_time_window:8:17"));
+ set.Add(WallTimeWindowTrigger.Parse("wall_time_window:18:0:23:0"));
+ return set;
+ }
+
+ [Fact]
+ public void Add_FilesEachTriggerUnderItsClass()
+ {
+ var set = Populated();
+
+ Assert.Equal(6, set.All.Count);
+ Assert.Single(set.Proximity);
+ Assert.Single(set.Speech);
+ Assert.Single(set.Kill);
+ Assert.Single(set.Skill);
+ Assert.Equal(2, set.Gates.Count);
+ }
+
+ [Fact]
+ public void Add_CountsEventsAndGatesSeparately()
+ {
+ var set = Populated();
+
+ Assert.Equal(4, set.EventCount);
+ Assert.Equal(2, set.GateCount);
+ }
+
+ [Fact]
+ public void Add_IgnoresNull()
+ {
+ var set = new TriggerSet();
+ set.Add(null);
+
+ Assert.Empty(set.All);
+ Assert.Equal(0, set.EventCount);
+ Assert.Equal(0, set.GateCount);
+ }
+
+ [Fact]
+ public void Clear_EmptiesEveryListAndCount()
+ {
+ var set = Populated();
+ set.Clear();
+
+ Assert.Empty(set.All);
+ Assert.Empty(set.Proximity);
+ Assert.Empty(set.Speech);
+ Assert.Empty(set.Kill);
+ Assert.Empty(set.Skill);
+ Assert.Empty(set.Gates);
+ Assert.Equal(0, set.EventCount);
+ Assert.Equal(0, set.GateCount);
+ }
+
+ [Fact]
+ public void TriggerContext_IsAStructWithValueEquality()
+ {
+ Assert.True(typeof(TriggerContext).IsValueType);
+
+ var a = new TriggerContext(null, null, "open sesame", null, SkillName.Mining, 55.0, true);
+ var b = new TriggerContext(null, null, "open sesame", null, SkillName.Mining, 55.0, true);
+ var c = a with { SkillSuccess = false };
+
+ Assert.Equal(a, b);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ Assert.NotEqual(a, c);
+ }
+
+ [Fact]
+ public void TriggerContext_FactoriesFillOnlyTheirOwnFields()
+ {
+ var speech = TriggerContext.ForSpeech(null, null, "hail");
+ Assert.Equal("hail", speech.Speech);
+ Assert.Null(speech.KilledEntity);
+ Assert.False(speech.SkillSuccess);
+ Assert.Equal(0.0, speech.SkillValue);
+
+ var proximity = TriggerContext.ForProximity(null, null);
+ Assert.Null(proximity.Speech);
+ Assert.Null(proximity.KilledEntity);
+ }
+}
diff --git a/Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs b/Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs
new file mode 100644
index 0000000..786b9bd
--- /dev/null
+++ b/Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs
@@ -0,0 +1,181 @@
+using System;
+using Server.Engines.ModernSpawner.Triggers;
+using Xunit;
+
+namespace Server.Engines.ModernSpawner.Tests.Triggers;
+
+///
+/// The shared per-event-trigger tokens (wake:, mode:, when:) parse out of any event
+/// trigger definition regardless of where they sit among the positional arguments, and survive a
+/// Serialize round trip. Defaults stay off the wire so pre-token definitions round-trip unchanged.
+///
+public class TriggerTokenTests
+{
+ [Fact]
+ public void Proximity_TokensAfterPositionalArguments()
+ {
+ var trigger = ProximityTrigger.Parse("proximity:8:true:false:5:Player:wake:true:mode:tick");
+
+ Assert.NotNull(trigger);
+ Assert.Equal(8, trigger.Range);
+ Assert.True(trigger.PlayersOnly);
+ Assert.False(trigger.RequireLineOfSight);
+ Assert.Equal(TimeSpan.FromSeconds(5), trigger.Cooldown);
+ Assert.True(trigger.Wake);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ Assert.Equal(TriggerKind.Event, trigger.Kind);
+ }
+
+ [Fact]
+ public void Tokens_ParseInAnyOrder()
+ {
+ var wakeFirst = ProximityTrigger.Parse("proximity:8:true:false:5:0:wake:true:mode:tick");
+ var modeFirst = ProximityTrigger.Parse("proximity:8:true:false:5:0:mode:tick:wake:true");
+
+ Assert.Equal(8, wakeFirst.Range);
+ Assert.True(wakeFirst.Wake);
+ Assert.Equal(CycleMode.Tick, wakeFirst.Mode);
+
+ Assert.Equal(8, modeFirst.Range);
+ Assert.True(modeFirst.Wake);
+ Assert.Equal(CycleMode.Tick, modeFirst.Mode);
+ }
+
+ [Theory]
+ // A positional argument that happens to spell a token name must stay an argument: the token scan
+ // only starts past each grammar's positional arity. Eating "Wake" here would have shifted
+ // requirePlayerKiller and the cooldown one segment to the left.
+ [InlineData("kill:3:true:false:Wake:true:30", "Wake")]
+ [InlineData("kill:3:true:false:Mode:true:30", "Mode")]
+ [InlineData("kill:3:true:false:When:true:30", "When")]
+ public void PositionalArgumentSpelledLikeAToken_StaysPositional(string definition, string filter)
+ {
+ var trigger = KillTrigger.Parse(definition);
+
+ Assert.Equal(filter, trigger.FilterType);
+ Assert.True(trigger.RequirePlayerKiller);
+ Assert.Equal(TimeSpan.FromSeconds(30), trigger.Cooldown);
+ Assert.False(trigger.Wake);
+ Assert.Equal(CycleMode.Now, trigger.Mode);
+ Assert.Null(trigger.When);
+ }
+
+ [Fact]
+ public void Tokens_RoundTripThroughSerialize()
+ {
+ var first = ProximityTrigger.Parse("proximity:12:false:true:30:2:wake:true:mode:tick:when:trigMob.Karma > 0");
+ Assert.NotNull(first);
+
+ var second = ProximityTrigger.Parse(first.Serialize());
+
+ Assert.NotNull(second);
+ Assert.Equal(first.Range, second.Range);
+ Assert.Equal(first.PlayersOnly, second.PlayersOnly);
+ Assert.Equal(first.RequireLineOfSight, second.RequireLineOfSight);
+ Assert.Equal(first.Cooldown, second.Cooldown);
+ Assert.Equal(first.MinAccessLevel, second.MinAccessLevel);
+ Assert.True(second.Wake);
+ Assert.Equal(CycleMode.Tick, second.Mode);
+ Assert.Equal("trigMob.Karma > 0", second.WhenSource);
+ }
+
+ [Fact]
+ public void When_CompilesOnceAtParseTime()
+ {
+ var trigger = ProximityTrigger.Parse("proximity:8:true:false:5:0:when:1 + 1");
+
+ Assert.NotNull(trigger.When);
+ Assert.True(trigger.When.IsValid);
+ Assert.Same(trigger.When, trigger.When);
+ }
+
+ [Fact]
+ public void Defaults_StayOffTheWire()
+ {
+ var trigger = ProximityTrigger.Parse("proximity:8");
+
+ Assert.False(trigger.Wake);
+ Assert.Equal(CycleMode.Now, trigger.Mode);
+ Assert.Null(trigger.When);
+ Assert.Null(trigger.WhenSource);
+
+ var serialized = trigger.Serialize();
+ Assert.DoesNotContain("wake", serialized, StringComparison.Ordinal);
+ Assert.DoesNotContain("mode", serialized, StringComparison.Ordinal);
+ Assert.DoesNotContain("when", serialized, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Speech_CarriesTokens()
+ {
+ var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("open sesame"));
+ var trigger = SpeechTrigger.Parse($"speech:{encoded}:false:true:15:false:30:wake:true");
+
+ Assert.Equal("open sesame", trigger.Keyword);
+ Assert.False(trigger.IgnoreCase);
+ Assert.True(trigger.UseRegex);
+ Assert.Equal(15, trigger.Range);
+ Assert.False(trigger.PlayersOnly);
+ Assert.Equal(TimeSpan.FromSeconds(30), trigger.Cooldown);
+ Assert.True(trigger.Wake);
+ Assert.Equal(CycleMode.Now, trigger.Mode);
+ }
+
+ [Fact]
+ public void Kill_CarriesTokens()
+ {
+ var trigger = KillTrigger.Parse("kill:3:true:false:Dragon:true:30:mode:tick");
+
+ Assert.Equal(3, trigger.RequiredKills);
+ Assert.True(trigger.RequireAllDead);
+ Assert.False(trigger.ResetOnTrigger);
+ Assert.Equal("Dragon", trigger.FilterType);
+ Assert.True(trigger.RequirePlayerKiller);
+ Assert.Equal(TimeSpan.FromSeconds(30), trigger.Cooldown);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ Assert.False(trigger.Wake);
+ }
+
+ [Fact]
+ public void Skill_CarriesTokens()
+ {
+ var trigger = SkillTrigger.Parse("skill:Mining+:15:50-90:true:10:wake:true:mode:tick");
+
+ Assert.NotNull(trigger);
+ Assert.Equal(SkillName.Mining, trigger.TargetSkill);
+ Assert.Equal(SkillOutcome.Success, trigger.Outcome);
+ Assert.Equal(15, trigger.Range);
+ Assert.Equal(50.0, trigger.MinSkillValue);
+ Assert.Equal(90.0, trigger.MaxSkillValue);
+ Assert.True(trigger.RequireLOS);
+ Assert.Equal(TimeSpan.FromSeconds(10), trigger.Cooldown);
+ Assert.True(trigger.Wake);
+ Assert.Equal(CycleMode.Tick, trigger.Mode);
+ }
+
+ [Fact]
+ public void UnknownTokenValue_KeepsTheDefault()
+ {
+ var trigger = ProximityTrigger.Parse("proximity:8:true:false:5:0:mode:sideways:wake:maybe");
+
+ Assert.Equal(8, trigger.Range);
+ Assert.Equal(CycleMode.Now, trigger.Mode);
+ Assert.False(trigger.Wake);
+ }
+
+ [Fact]
+ public void Gates_IgnoreTokensAndReportGateKind()
+ {
+ var game = GameTimeWindowTrigger.Parse("game_time_window:8:17");
+ var wall = WallTimeWindowTrigger.Parse("wall_time_window:18:0:23:0");
+
+ Assert.Equal(TriggerKind.Gate, game.Kind);
+ Assert.Equal(TriggerKind.Gate, wall.Kind);
+ Assert.False(game.Wake);
+ Assert.False(wall.Wake);
+ Assert.Equal(CycleMode.Now, game.Mode);
+ Assert.Equal(CycleMode.Now, wall.Mode);
+ Assert.Null(game.When);
+ Assert.Null(wall.When);
+ }
+}
diff --git a/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs b/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs
index e6db313..59fce5d 100644
--- a/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs
+++ b/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Text.Json;
using System.Text.Json.Serialization;
using Server.Engines.ModernSpawner.Scripting;
+using Server.Engines.ModernSpawner.Triggers;
using Server.Engines.Spawners;
using Server.Json;
@@ -22,6 +24,9 @@ public override SpawnerDto ToDto()
MinDelay = MinDelay,
MaxDelay = MaxDelay,
Team = Team,
+ // Base flag, binary-persisted but easy to lose on the way out: without it an exported
+ // group spawner comes back as a plain one.
+ Group = Group,
WalkingRange = DtoWalkingRange,
Entries = _spawnEntries ?? [],
SpawnLocationIsHome = SpawnLocationIsHome,
@@ -38,15 +43,37 @@ public override SpawnerDto ToDto()
MaxZDelta = _maxZDelta,
TriggerActivated = _triggerActivated,
Notes = _notes,
- Triggers = _triggerDefinitions,
+ Triggers = ExportTriggerDefinitions(),
CycleMode = _cycleMode,
CurrentSubgroup = _currentSubgroup,
SequentialResetTime = _sequentialResetTime,
SequentialResetTo = _sequentialResetTo,
- HoldSequence = _holdSequence
+ HoldSequence = _holdSequence,
+ MaxPendingCycles = _maxPendingCycles,
+ RefractoryMin = _refractoryMin,
+ RefractoryMax = _refractoryMax
};
}
+ // Definitions export as { id, text }. Runtime state - queued cycles, cooldowns, kill counters,
+ // per-entry deadlines and the refractory deadline - is world-save only and never exported.
+ private List ExportTriggerDefinitions()
+ {
+ var definitions = _triggerDefs;
+ if (definitions == null || definitions.Count == 0)
+ {
+ return [];
+ }
+
+ var exported = new List(definitions.Count);
+ for (var i = 0; i < definitions.Count; i++)
+ {
+ exported.Add(new TriggerDefinitionDto { Id = definitions[i].Id, Text = definitions[i].Text });
+ }
+
+ return exported;
+ }
+
/// Applies the ModernSpawner-specific DTO fields (import path).
internal void ApplyModernDto(ModernSpawnerDto dto)
{
@@ -75,12 +102,31 @@ internal void ApplyModernDto(ModernSpawnerDto dto)
_maxZDelta = dto.MaxZDelta;
_triggerActivated = dto.TriggerActivated;
_notes = dto.Notes;
- _triggerDefinitions = dto.Triggers != null ? new List(dto.Triggers) : [];
+
+ // Ids come across so per-definition state written by a later save still lines up; a DTO
+ // authored by hand may omit them, and TriggerDefinition mints one in that case.
+ _triggerDefs = [];
+ var triggers = dto.Triggers;
+ if (triggers != null)
+ {
+ for (var i = 0; i < triggers.Count; i++)
+ {
+ var trigger = triggers[i];
+ if (trigger != null)
+ {
+ _triggerDefs.Add(new TriggerDefinition(this, UniqueDefinitionId(trigger.Id), trigger.Text));
+ }
+ }
+ }
+
_cycleMode = dto.CycleMode;
_currentSubgroup = dto.CurrentSubgroup;
_sequentialResetTime = dto.SequentialResetTime;
_sequentialResetTo = dto.SequentialResetTo;
_holdSequence = dto.HoldSequence;
+ MaxPendingCycles = dto.MaxPendingCycles;
+ _refractoryMin = dto.RefractoryMin;
+ _refractoryMax = dto.RefractoryMax;
}
}
@@ -141,7 +187,7 @@ public sealed record ModernSpawnerDto : SpawnerDto
[JsonPropertyName("triggers")]
[JsonPropertyOrder(30)]
- public List Triggers { get; init; }
+ public List Triggers { get; init; }
[JsonPropertyName("cycleMode")]
[JsonPropertyOrder(31)]
@@ -163,6 +209,21 @@ public sealed record ModernSpawnerDto : SpawnerDto
[JsonPropertyOrder(35)]
public bool HoldSequence { get; init; }
+ /// Queue bound for trigger-bought cycles; 0 means run-now-or-drop.
+ [JsonPropertyName("maxPendingCycles")]
+ [JsonPropertyOrder(36)]
+ public int MaxPendingCycles { get; init; } = 1;
+
+ /// Low end of the spawner-wide lockout applied after an accepted event.
+ [JsonPropertyName("refractoryMin")]
+ [JsonPropertyOrder(37)]
+ public TimeSpan RefractoryMin { get; init; }
+
+ /// High end of the spawner-wide lockout applied after an accepted event.
+ [JsonPropertyName("refractoryMax")]
+ [JsonPropertyOrder(38)]
+ public TimeSpan RefractoryMax { get; init; }
+
protected override BaseSpawner CreateEmpty() => new ModernSpawner();
public override BaseSpawner ToSpawner()
@@ -189,3 +250,104 @@ public override BaseSpawner ToSpawner()
}
}
}
+
+///
+/// JSON carrier for one : its stable id and its parse text. Runtime
+/// state that hangs off the id - cooldowns, kill counters, queued cycles - is world-save only.
+/// Reads the pre-id shape (a bare definition string) as well as { id, text }; see
+/// .
+///
+[JsonConverter(typeof(TriggerDefinitionDtoConverter))]
+public sealed record TriggerDefinitionDto
+{
+ /// Stable id of the definition. Omitted or empty asks the importer to mint one.
+ [JsonPropertyName("id")]
+ [JsonPropertyOrder(0)]
+ public Guid Id { get; init; }
+
+ /// The definition text the trigger system parses, e.g. proximity:8:true.
+ [JsonPropertyName("text")]
+ [JsonPropertyOrder(1)]
+ public string Text { get; init; }
+}
+
+///
+/// Reads a trigger definition written either as { "id": …, "text": … } or, for files exported
+/// before definitions had ids, as a bare string. A string yields an empty id, which the import path
+/// replaces with a freshly minted one. Always writes the object form.
+///
+public sealed class TriggerDefinitionDtoConverter : JsonConverter
+{
+ private const string IdPropertyName = "id";
+ private const string TextPropertyName = "text";
+
+ ///
+ public override TriggerDefinitionDto Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return null;
+ }
+
+ // Pre-id shape: "triggers": [ "proximity:8:true", ... ]
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ return new TriggerDefinitionDto { Text = reader.GetString() };
+ }
+
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ throw new JsonException($"Expected a trigger definition string or object, found {reader.TokenType}.");
+ }
+
+ var id = Guid.Empty;
+ string text = null;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ return new TriggerDefinitionDto { Id = id, Text = text };
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ throw new JsonException($"Expected a trigger definition property name, found {reader.TokenType}.");
+ }
+
+ var propertyName = reader.GetString();
+ reader.Read();
+
+ if (IdPropertyName.Equals(propertyName, StringComparison.OrdinalIgnoreCase))
+ {
+ id = reader.TokenType == JsonTokenType.Null ? Guid.Empty : JsonSerializer.Deserialize(ref reader, options);
+ }
+ else if (TextPropertyName.Equals(propertyName, StringComparison.OrdinalIgnoreCase))
+ {
+ text = reader.TokenType == JsonTokenType.Null ? null : reader.GetString();
+ }
+ else
+ {
+ reader.Skip();
+ }
+ }
+
+ throw new JsonException("Unterminated trigger definition object.");
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, TriggerDefinitionDto value, JsonSerializerOptions options)
+ {
+ if (value == null)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ writer.WriteStartObject();
+ writer.WritePropertyName(IdPropertyName);
+ JsonSerializer.Serialize(writer, value.Id, options);
+ writer.WriteString(TextPropertyName, value.Text);
+ writer.WriteEndObject();
+ }
+}
diff --git a/Projects/ModernSpawner/Core/ModernSpawner.Migrations.cs b/Projects/ModernSpawner/Core/ModernSpawner.Migrations.cs
new file mode 100644
index 0000000..6d34c36
--- /dev/null
+++ b/Projects/ModernSpawner/Core/ModernSpawner.Migrations.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using Server.Engines.ModernSpawner.Triggers;
+
+namespace Server.Engines.ModernSpawner;
+
+public partial class ModernSpawner
+{
+ ///
+ /// v0 -> v1. The trigger definition list gains identity: each stored string becomes a
+ /// with a freshly minted id, in the order it was saved. The old
+ /// _triggered flag is dropped - a spawner that was mid-trigger at save time comes back with
+ /// an empty queue, which is what a restart means for a one-shot flag. Everything the queue, the
+ /// refractory and the per-definition state need starts at its default: no pending cycles, a queue
+ /// bound of one, no lockout, and one empty state per definition (bound by
+ /// on load).
+ ///
+ /// The v0 payload.
+ private void MigrateFrom(V0Content content)
+ {
+ _spawnEntries = content.SpawnEntries;
+ _onActivateScriptSerial = content.OnActivateScriptSerial;
+ _onDeactivateScriptSerial = content.OnDeactivateScriptSerial;
+ _onBeforeSpawnScriptSerial = content.OnBeforeSpawnScriptSerial;
+ _onAfterSpawnScriptSerial = content.OnAfterSpawnScriptSerial;
+ _useSmartPositioning = content.UseSmartPositioning;
+ _returnToSpawnOnIdle = content.ReturnToSpawnOnIdle;
+ _maxZDelta = content.MaxZDelta;
+
+ var definitions = content.TriggerDefinitions;
+ _triggerDefs = new List(definitions?.Count ?? 0);
+ if (definitions != null)
+ {
+ for (var i = 0; i < definitions.Count; i++)
+ {
+ _triggerDefs.Add(new TriggerDefinition(this, definitions[i]));
+ }
+ }
+
+ _triggerActivated = content.TriggerActivated;
+ // content.Triggered is deliberately dropped: the queue replaces it.
+ _notes = content.Notes;
+ _cycleMode = content.CycleMode;
+ _currentSubgroup = content.CurrentSubgroup;
+ _sequentialResetTime = content.SequentialResetTime;
+ _sequentialResetTo = content.SequentialResetTo;
+ _holdSequence = content.HoldSequence;
+
+ _pendingSlots = [];
+ _maxPendingCycles = 1;
+ _refractoryMin = TimeSpan.Zero;
+ _refractoryMax = TimeSpan.Zero;
+ _refractoryUntil = default;
+ _triggerStateList = [];
+ }
+}
diff --git a/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs
new file mode 100644
index 0000000..133cf1b
--- /dev/null
+++ b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs
@@ -0,0 +1,958 @@
+using System;
+using System.Collections.Generic;
+using Server.Engines.ModernSpawner.Perf;
+using Server.Engines.ModernSpawner.Scripting;
+using Server.Engines.ModernSpawner.Scripting.Expressions;
+using Server.Engines.ModernSpawner.Triggers;
+using Server.Logging;
+
+namespace Server.Engines.ModernSpawner;
+
+///
+/// The D2 trigger state machine: the gate set, the bounded queue of trigger-bought cycles, the
+/// acceptance order every event passes through, and the tick precedence that spends what the events
+/// bought. Everything here is spawner state, so a tick compares fields and never looks anything up.
+///
+public partial class ModernSpawner
+{
+ private static readonly ILogger TriggerLogger = LogFactory.GetLogger(typeof(ModernSpawner));
+
+ ///
+ /// How many cycles one spawner may drain inside a single outer dispatch, matching the product
+ /// spec's script recursion limit. A cycle whose scripts raise further events queues them into the
+ /// same drain list, so without this a self-feeding spawner would never let the loop finish.
+ ///
+ private const int MaxDrainsPerRound = 10;
+
+ /// How long a failed placement parks an entry, when that is shorter than its min delay.
+ private static readonly TimeSpan FailureBackoff = TimeSpan.FromSeconds(30);
+
+ ///
+ /// The floor the timer is armed at. A deadline already in the past would otherwise arm at zero
+ /// and spin the spawner through the timer wheel; one slice is soon enough for a catch-up tick.
+ ///
+ private static readonly TimeSpan MinimumArmDelay = TimeSpan.FromMilliseconds(100);
+
+ // The set of open gates, by definition index. A bitmask covers the first 64 definitions - far more
+ // than any real spawner carries - and the overflow list catches the rest, so the common case is a
+ // single word compare with no allocation and no definition is silently ignored.
+ private ulong _openGateBits;
+ private List _openGateOverflow;
+
+ // Recomputed at every registration from the parsed set; malformed definitions count for neither.
+ private int _gateCount;
+ private int _eventCount;
+
+ // Non-zero while this spawner has a live registration. Deleting invalidates it first, so a request
+ // already in flight from a gate callback or a script is dropped rather than run against a
+ // registration that no longer exists.
+ private int _registrationGeneration;
+
+ // Set while EnsureTriggersActive is (re)registering: a window that is already open reports its open
+ // edge from inside Activate, and A3 says that edge only hydrates the set, it does not buy a cycle.
+ private bool _hydratingGates;
+
+ // Guards the cycle body against re-entry: an event arriving for a spawner that is already running a
+ // cycle is queued, never nested.
+ private bool _isRunningCycle;
+
+ // Set while Respawn() is running the group bulk operation, so the before/after scripts run once for
+ // the whole respawn instead of once per Spawn() inside it.
+ private bool _inBulkRespawn;
+
+ // MaxPendingCycles == 0 (XmlSpawner semantics) has no queue to put a slot in, so the accepted event
+ // leaves this one-shot request behind instead. It never latches: the very next drain either spends
+ // it or drops it, so it cannot fire on some arbitrary later one. The mobile is held by serial
+ // rather than by reference, so a dropped request cannot root a deleted mobile.
+ private bool _runNowRequested;
+ private Serial _runNowMobile;
+
+ // The mobile the cycle in flight belongs to, threaded into positioning (player_relative) and into
+ // the script contexts the cycle runs. Saved and restored around every cycle.
+ private Mobile _cycleTriggeringMobile;
+
+ ///
+ /// How many cycles this spawner has drained in the outer dispatch in progress. Reset by the trigger
+ /// system when the drain list is exhausted.
+ ///
+ internal int DrainsThisRound { get; set; }
+
+ ///
+ /// Whether this spawner's gate lets a tick through: a spawner with no gates, or a deactivated one,
+ /// is always open; otherwise at least one window must currently be open.
+ ///
+ public bool GateOpen =>
+ !_triggerActivated || _gateCount == 0 || _openGateBits != 0 || _openGateOverflow is { Count: > 0 };
+
+ /// How many of this spawner's registered triggers are gates (time windows).
+ public int GateCount => _gateCount;
+
+ ///
+ /// How many of this spawner's registered triggers are event sources. When this is greater than zero
+ /// the spawner never spawns on its own timer: each cycle has to be bought by an accepted event.
+ ///
+ public int EventCount => _eventCount;
+
+ ///
+ /// Whether a tick would be allowed to run a cycle right now (the design's tick authorization).
+ ///
+ public bool IsAuthorizedForTick =>
+ Running && !Deleted && GateOpen && !IsFull && (_eventCount == 0 || PendingCycleCount > 0);
+
+ ///
+ /// Binds this spawner to a fresh trigger registration. Called by the trigger system once the parsed
+ /// set exists and before any trigger is activated, because an activating gate runs spawner code.
+ ///
+ /// The registration's generation number.
+ /// How many parsed triggers are event sources.
+ /// How many parsed triggers are gates.
+ internal void SetRegistration(int generation, int eventCount, int gateCount)
+ {
+ _registrationGeneration = generation;
+ _eventCount = eventCount;
+ _gateCount = gateCount;
+ }
+
+ ///
+ /// Drops this spawner's registration bookkeeping. The gate set goes with it: gates are recomputed
+ /// from the clock every time the definitions are parsed again.
+ ///
+ internal void ClearRegistration()
+ {
+ _registrationGeneration = 0;
+ _eventCount = 0;
+ _gateCount = 0;
+ ClearGates();
+ }
+
+ ///
+ ///
+ /// The D2 tick precedence, top to bottom, first match wins. A row that parks deliberately leaves
+ /// the timer unarmed: the next state transition - a gate opening, an accepted event, a spawn being
+ /// removed, - is what brings it back.
+ ///
+ public override void OnTick()
+ {
+ // The whole precedence is measured, parked rows included: the D2 condition is about what a
+ // tick costs when it does nothing, and a scope that only wrapped the cycle would miss that.
+ using var _ = SpawnerMetrics.MeasureTick();
+
+ // T0
+ if (Deleted || !Running)
+ {
+ return;
+ }
+
+ var group = Group;
+
+ // T1: base Group is "all dead, then respawn", so a populated pack parks until it is cleared.
+ if (group)
+ {
+ Defrag();
+
+ if (Spawned.Count > 0)
+ {
+ return;
+ }
+ }
+
+ // T2, T3, T4
+ if (!IsAuthorizedForTick)
+ {
+ return;
+ }
+
+ var now = Core.Now;
+
+ // T5. A group respawn is a bulk operation with its own removal semantics, so per-entry
+ // deadlines do not hold it back.
+ if (!group && !HasDueEntry(now))
+ {
+ ArmAtEarliestDeadline(now);
+ return;
+ }
+
+ // T6, and T1's bulk branch: the cycle source is the same, only the body differs, and
+ // RunCycle picks the group body when base Group is set. A queued slot is spent whether or not
+ // this spawner has event definitions: an external Trigger() queues one on a spawner that has
+ // none, and nothing else would ever pop it.
+ var slot = PopOldestSlot();
+ if (!RunCycle(slot, false) && slot != null)
+ {
+ // The cycle could not run after all (a cycle is already in flight on this spawner), so the
+ // slot goes back at the head of the queue rather than being spent on nothing.
+ InsertIntoPendingSlots(0, slot);
+ return;
+ }
+
+ if (group)
+ {
+ // Respawn arms the timer itself.
+ return;
+ }
+
+ ArmAtEarliestDeadline(Core.Now);
+ }
+
+ ///
+ /// Asks this spawner for one spawn cycle on behalf of a trigger that just matched. Dispatch never
+ /// calls itself: it evaluates, calls this, and lets the outermost dispatch
+ /// drain what was bought.
+ ///
+ ///
+ /// The acceptance order is fixed and every check runs before any state changes: registration live,
+ /// the trigger matches, its cooldown has elapsed, the spawner-wide refractory has elapsed, the
+ /// when: condition passes, and the queue has room (or, with
+ /// zero, the cycle can run right now). Only then do the cooldown,
+ /// the refractory and the kill counter move, together.
+ ///
+ /// Proximity, speech and skill triggers arrive here already evaluated: their dispatcher
+ /// has to call anyway to pick which of a spawner's triggers is
+ /// firing, so this does not evaluate them a second time. Kill triggers are the exception - their
+ /// match depends on a counter that lives on this spawner - and they are evaluated below.
+ ///
+ ///
+ /// The trigger that matched.
+ ///
+ /// The the request was raised from. A request stamped with a
+ /// registration this spawner has since replaced names triggers that are no longer bound to its
+ /// state, and is dropped.
+ ///
+ /// The event being dispatched.
+ /// True when the event was accepted and bought a cycle.
+ internal bool RequestCycle(ITrigger trigger, int generation, in TriggerContext context)
+ {
+ if (trigger == null || Deleted)
+ {
+ return false;
+ }
+
+ // The registration stamp replaces a TriggerActivated test: a definition-backed request can
+ // only carry a live generation while this spawner is registered, and an external one carries
+ // whatever generation the spawner has right now - which is how a script or command Trigger()
+ // is honoured on a spawner whose triggers are deactivated, or that has none at all.
+ if (generation != _registrationGeneration)
+ {
+ return false;
+ }
+
+ // A kill trigger's threshold is spawner state, so the kill dispatch hands every kill that
+ // passed the trigger's filters to this method and the evaluation happens here: a kill below
+ // the threshold still counts, it just does not buy anything.
+ var kill = trigger as KillTrigger;
+ if (kill != null && !kill.Evaluate(in context))
+ {
+ kill.AdvanceKillCount(false);
+ return false;
+ }
+
+ var now = Core.Now;
+ var state = trigger.State;
+ var wake = trigger.Wake;
+ var latched = _maxPendingCycles > 0;
+
+ // The acceptance gates, in the design's order and short-circuiting, so nothing past the first
+ // refusal is even evaluated - `when:` in particular only builds a context once the cooldown
+ // and the refractory have let the event through.
+ var refused =
+ state != null && now < state.CooldownUntil ||
+ now < _refractoryUntil ||
+ !WhenPasses(trigger, context.TriggeringMobile);
+
+ if (!refused)
+ {
+ refused = latched
+ // E6: the queue is the only thing between this event and a cycle, and it is full.
+ ? PendingCycleCount >= _maxPendingCycles
+ // MaxPendingCycles == 0 reproduces XmlSpawner: run now or drop, never latch. Nothing
+ // can run now, so the event is refused rather than quietly eaten.
+ : !GateOpen || IsFull || !(Running || (wake && Entries.Count > 0));
+ }
+
+ if (refused)
+ {
+ // A kill that reached the threshold and was then refused still counts: the cooldown, the
+ // refractory and the queue gate the trigger firing, not the kills that build toward it.
+ // Exactly one advance per dispatch, here or on acceptance below.
+ kill?.AdvanceKillCount(false);
+ return false;
+ }
+
+ // ---- accepted: every side effect of acceptance happens here, and only here ----
+ if (state != null && trigger.Cooldown > TimeSpan.Zero)
+ {
+ state.CooldownUntil = now + trigger.Cooldown;
+ }
+
+ ApplyRefractory(now);
+ kill?.AdvanceKillCount(true);
+
+ var mobile = context.TriggeringMobile;
+ var serial = mobile == null ? Serial.Zero : mobile.Serial;
+
+ // E4: a wake trigger may start a stopped spawner.
+ if (!Running && wake)
+ {
+ Start();
+ }
+
+ // E5, or an E4 whose Start() found no entries to run: hold the cycle if the queue allows it.
+ if (!Running)
+ {
+ if (latched)
+ {
+ EnqueuePendingCycle(trigger.Id, serial);
+ }
+
+ return true;
+ }
+
+ // E3: mode:tick only arms the timer, so the cycle runs with the normal tick ordering.
+ // Qualified: the spawner's own CycleMode property would otherwise win the name lookup here.
+ if (trigger.Mode == Triggers.CycleMode.Tick && latched)
+ {
+ EnqueuePendingCycle(trigger.Id, serial);
+ DoTimer(TimeSpan.Zero);
+ return true;
+ }
+
+ if (latched)
+ {
+ EnqueuePendingCycle(trigger.Id, serial);
+ }
+ else
+ {
+ _runNowRequested = true;
+ _runNowMobile = serial;
+ }
+
+ // E1: run it as soon as the dispatch that raised the event returns. E2 (gate closed or full)
+ // leaves the slot waiting for T2 / T3 to clear instead.
+ if (GateOpen && !IsFull)
+ {
+ TriggerSystem.Instance.RequestDrain(this);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Runs one bought cycle, called by the trigger system once the outermost dispatch has returned.
+ /// Re-validates the spawner first (D1): a cycle bought a moment ago must not run into a spawner
+ /// that has since been deleted, stopped, deactivated, filled up or had its window close.
+ ///
+ internal void DrainOne()
+ {
+ if (DrainsThisRound >= MaxDrainsPerRound)
+ {
+ // A run-now request never latches, not even past the budget: it is spent by the very next
+ // drain or it is gone.
+ ClearRunNow();
+
+ if (DrainsThisRound == MaxDrainsPerRound)
+ {
+ DrainsThisRound++;
+ TriggerLogger.Warning(
+ "Spawner {Serial} hit the {Limit} cycle recursion limit in one dispatch; the rest of its queued cycles wait for the next tick.",
+ Serial,
+ MaxDrainsPerRound
+ );
+ }
+
+ // "The next tick" has to actually come: nothing else is going to arm the timer for a
+ // spawner whose queue is what the budget refused.
+ if (PendingCycleCount > 0)
+ {
+ DoTimer(TimeSpan.Zero);
+ }
+
+ return;
+ }
+
+ DrainsThisRound++;
+
+ // D1: a deleted spawner discards what it was holding. Deactivation is the other discarding
+ // case and it already emptied the queue on its way through EnsureTriggersActive (A4), so this
+ // does not test the flag - an external Trigger() is a legitimate source on a spawner that has
+ // no definitions and therefore no activation at all.
+ if (Deleted)
+ {
+ ClearPendingCycles();
+ ClearRunNow();
+ return;
+ }
+
+ if (!Running || !GateOpen || IsFull)
+ {
+ // A run-now request never latches, so it is the one thing that is dropped here.
+ ClearRunNow();
+ return;
+ }
+
+ // Queued, never nested: the queued slots stay where they are and the cycle in flight drains
+ // them when it exits. A run-now request is one-shot and never latches, so it is dropped here
+ // rather than left to fire on some arbitrary later drain.
+ if (_isRunningCycle)
+ {
+ ClearRunNow();
+ return;
+ }
+
+ if (PendingCycleCount > 0)
+ {
+ var slot = PopOldestSlot();
+ if (!RunCycle(slot, true))
+ {
+ // T1 on a base-Group spawner: the pack is not dead yet, so the cycle keeps waiting.
+ InsertIntoPendingSlots(0, slot);
+ return;
+ }
+ }
+ else if (_runNowRequested)
+ {
+ var mobile = _runNowMobile;
+ ClearRunNow();
+ RunCycleCore(ResolveMobile(mobile), true);
+ }
+
+ // A cycle can buy more cycles through its scripts; RunCycleCore has already asked for the
+ // follow-up drain, and it goes round this same bounded loop.
+ }
+
+ ///
+ /// Triggers the spawner from a script or a command. This is an event source of its own - it counts
+ /// even on a spawner with no event definitions at all - and it runs through the same acceptance
+ /// path as any other event, so the queue bound and the refractory apply to it too.
+ ///
+ public void Trigger()
+ {
+ var context = TriggerContext.ForProximity(this, null);
+ RequestCycle(ExternalTrigger.Instance, _registrationGeneration, in context);
+ }
+
+ ///
+ /// Resets the trigger state: every queued cycle is dropped and any queued drain is cancelled (M4).
+ /// Registrations, cooldowns and kill counters survive.
+ ///
+ public void ResetTrigger()
+ {
+ ClearPendingCycles();
+ ClearRunNow();
+ TriggerSystem.Instance.CancelDrain(this);
+ }
+
+ ///
+ /// Called by a gate when its window opens, naming the gate by its position in
+ /// (-1 while unbound). Adding an id to an empty set is the open
+ /// edge that authorizes the spawner again (G1); every other open edge only records the id (G2).
+ ///
+ /// Position of the gate's definition, or -1.
+ public void OnGateOpened(int definitionIndex)
+ {
+ if (Deleted || !_triggerActivated)
+ {
+ return;
+ }
+
+ var wasOpen = _openGateBits != 0 || _openGateOverflow is { Count: > 0 };
+
+ if (!AddGate(definitionIndex) || wasOpen)
+ {
+ // G2: already present, or the set was not empty, so nothing changes for the spawner.
+ return;
+ }
+
+ // A3: registration hydrates the set from the clock without running G1's cycle.
+ if (_hydratingGates || !Running)
+ {
+ return;
+ }
+
+ // G1. The window-open cycle is a timer cycle, not an event drain, so it honours per-entry
+ // deadlines; only a mode:now event drain bypasses them.
+ if (_eventCount == 0)
+ {
+ if (!RunCycleCore(null, false))
+ {
+ // A group spawner whose pack is still alive parks until removal, exactly as T1 does,
+ // rather than arming a timer that would only park again.
+ return;
+ }
+ }
+ else if (PendingCycleCount > 0)
+ {
+ // Through the drain list rather than calling DrainOne directly: the recursion budget is
+ // only reset when the list is exhausted, so a direct call would leak one drain per gate
+ // opening and eventually stop the gate from draining at all.
+ TriggerSystem.Instance.RequestDrain(this);
+ }
+
+ ArmAtEarliestDeadline(Core.Now);
+ }
+
+ ///
+ /// Called by a gate when its window closes, naming the gate by its position in
+ /// (-1 while unbound). Live spawns and queued cycles both stay:
+ /// D10 owns spawn lifetimes, and a cycle that was already bought is not refunded (G3, G4).
+ ///
+ /// Position of the gate's definition, or -1.
+ public void OnGateClosed(int definitionIndex)
+ {
+ if (Deleted || !_triggerActivated)
+ {
+ return;
+ }
+
+ RemoveGate(definitionIndex);
+ }
+
+ ///
+ ///
+ /// M3: the runtime state a restart would not carry either - queued cycles, cooldowns, the
+ /// refractory and the kill counters - is cleared, while the registration stays, because a stopped
+ /// registration is a live one.
+ ///
+ public override void Reset()
+ {
+ base.Reset();
+
+ ClearPendingCycles();
+ ClearRunNow();
+ TriggerSystem.Instance.CancelDrain(this);
+ RefractoryUntil = default;
+
+ var states = _triggerStateList;
+ if (states != null)
+ {
+ for (var i = 0; i < states.Count; i++)
+ {
+ states[i].Reset();
+ }
+ }
+ }
+
+ ///
+ ///
+ /// M2: a bulk operation that bypasses the triggers entirely. The before and after scripts wrap the
+ /// whole respawn rather than every inside it, so a group respawn runs them
+ /// once (§7).
+ ///
+ public override void Respawn()
+ {
+ if (_inBulkRespawn)
+ {
+ base.Respawn();
+ return;
+ }
+
+ _inBulkRespawn = true;
+ try
+ {
+ var beforeScript = OnBeforeSpawnScript;
+ if (beforeScript?.IsValid == true)
+ {
+ var context = new ScriptContext(null, this)
+ {
+ TriggeringMobile = _cycleTriggeringMobile
+ };
+
+ ScriptEngine.Instance.Execute(beforeScript, context);
+
+ if (context.CancelSpawn)
+ {
+ DoTimer();
+ return;
+ }
+ }
+
+ base.Respawn();
+
+ var afterScript = OnAfterSpawnScript;
+ if (afterScript?.IsValid == true)
+ {
+ ScriptEngine.Instance.Execute(
+ afterScript,
+ new ScriptContext(null, this)
+ {
+ TriggeringMobile = _cycleTriggeringMobile
+ }
+ );
+ }
+ }
+ finally
+ {
+ _inBulkRespawn = false;
+ }
+ }
+
+ ///
+ /// Restores this spawner once the world has finished loading (L1/L2). Registration is deferred to
+ /// here because a gate has to hydrate against the clock with every other spawner already read, and
+ /// nothing on this path may spawn.
+ ///
+ internal void OnWorldLoaded()
+ {
+ if (Deleted)
+ {
+ return;
+ }
+
+ if (!_triggerActivated)
+ {
+ // L2: a deactivated spawner holds no cycles.
+ ClearPendingCycles();
+ return;
+ }
+
+ // L1: a save written before MaxPendingCycles was lowered can carry more slots than the bound
+ // now allows.
+ TrimPendingCycles();
+ EnsureTriggersActive();
+ }
+
+ ///
+ /// Runs the queued cycle in , resolving the mobile that bought it so
+ /// positioning and scripts still see the player even though the dispatch is long gone.
+ ///
+ /// The queued cycle, or null for a cycle no event named a mobile for.
+ /// Whether the cycle ignores per-entry deadlines.
+ /// True when the cycle ran; false when the caller must keep the slot.
+ private bool RunCycle(PendingCycle slot, bool bypassDeadlines) =>
+ RunCycleCore(slot == null ? null : ResolveMobile(slot.TriggeringMobile), bypassDeadlines);
+
+ ///
+ /// Runs one cycle body with bound to it, then drains whatever
+ /// that cycle's scripts bought.
+ ///
+ ///
+ /// Two things stop the cycle before it starts, and both mean "the caller keeps what it was going to
+ /// spend": a cycle already in flight on this spawner (queued, never nested), and base
+ /// with the pack still alive, because on a group spawner
+ /// every cycle source - tick, event drain, window opening - is the same bulk respawn and
+ /// T1 applies to all of them.
+ ///
+ /// The mobile the cycle belongs to, or null.
+ /// Whether the cycle ignores per-entry deadlines.
+ /// True when the cycle ran.
+ private bool RunCycleCore(Mobile triggeringMobile, bool bypassDeadlines)
+ {
+ if (_isRunningCycle)
+ {
+ return false;
+ }
+
+ var group = Group;
+ if (group)
+ {
+ Defrag();
+
+ if (Spawned.Count > 0)
+ {
+ return false;
+ }
+ }
+
+ var previous = _cycleTriggeringMobile;
+ _cycleTriggeringMobile = triggeringMobile;
+ _isRunningCycle = true;
+ try
+ {
+ if (group)
+ {
+ // One bulk operation with the before/after scripts once around it (§7).
+ Respawn();
+ }
+ else
+ {
+ SpawnCore(bypassDeadlines);
+ }
+ }
+ finally
+ {
+ _isRunningCycle = false;
+ _cycleTriggeringMobile = previous;
+ }
+
+ // Anything the cycle's scripts bought while it was running was queued rather than nested, so
+ // it is drained now that the cycle has returned - through the same bounded drain list, which
+ // is what keeps a self-feeding spawner from running away.
+ if (PendingCycleCount > 0 || _runNowRequested)
+ {
+ TriggerSystem.Instance.RequestDrain(this);
+ }
+
+ return true;
+ }
+
+ /// The mobile a queued cycle named, or null when it is gone (or none was named).
+ /// The serial the slot carried.
+ /// The live mobile, or null.
+ private static Mobile ResolveMobile(Serial serial) =>
+ serial == Serial.Zero ? null : World.FindMobile(serial);
+
+ /// Removes and returns the oldest queued cycle, or null when there is none.
+ /// The oldest slot, or null.
+ private PendingCycle PopOldestSlot()
+ {
+ if (_pendingSlots is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ var slot = _pendingSlots[0];
+ RemoveFromPendingSlotsAt(0);
+ return slot;
+ }
+
+ /// Drops the one-shot run-now request left by an accepted event with no queue.
+ private void ClearRunNow()
+ {
+ _runNowRequested = false;
+ _runNowMobile = Serial.Zero;
+ }
+
+ /// Rolls and applies the spawner-wide lockout after an accepted event.
+ /// The instant the event was accepted.
+ private void ApplyRefractory(DateTime now)
+ {
+ if (_refractoryMin <= TimeSpan.Zero && _refractoryMax <= TimeSpan.Zero)
+ {
+ return;
+ }
+
+ var lockout = _refractoryMax > _refractoryMin
+ ? Utility.RandomMinMax(_refractoryMin, _refractoryMax)
+ : _refractoryMin;
+
+ if (lockout > TimeSpan.Zero)
+ {
+ RefractoryUntil = now + lockout;
+ }
+ }
+
+ ///
+ /// Evaluates a trigger's when: condition against the mobile that raised the event. The
+ /// expression was compiled at parse time; only the context is built here, and only when a
+ /// condition exists at all, so the common case allocates nothing.
+ ///
+ /// The trigger that matched.
+ /// The mobile that raised the event, or null.
+ /// True when the trigger carries no condition or the condition holds.
+ private bool WhenPasses(ITrigger trigger, Mobile mobile)
+ {
+ var when = trigger.When;
+ if (when == null)
+ {
+ return true;
+ }
+
+ var context = new ScriptContext(null, this)
+ {
+ TriggeringMobile = mobile
+ };
+
+ return ExpressionEngine.Instance.EvaluateBoolean(when, context);
+ }
+
+ #region The gate set
+
+ /// Records a gate as open. Returns false when it was already in the set (G2, G4).
+ /// Position of the gate's definition.
+ /// True when the set actually grew.
+ private bool AddGate(int definitionIndex)
+ {
+ if (definitionIndex < 0)
+ {
+ return false;
+ }
+
+ if (definitionIndex < 64)
+ {
+ var mask = 1UL << definitionIndex;
+ if ((_openGateBits & mask) != 0)
+ {
+ return false;
+ }
+
+ _openGateBits |= mask;
+ return true;
+ }
+
+ _openGateOverflow ??= [];
+ if (_openGateOverflow.Contains(definitionIndex))
+ {
+ return false;
+ }
+
+ _openGateOverflow.Add(definitionIndex);
+ return true;
+ }
+
+ /// Records a gate as closed. Returns false for a stale edge (G4).
+ /// Position of the gate's definition.
+ /// True when the set actually shrank.
+ private bool RemoveGate(int definitionIndex)
+ {
+ if (definitionIndex < 0)
+ {
+ return false;
+ }
+
+ if (definitionIndex < 64)
+ {
+ var mask = 1UL << definitionIndex;
+ if ((_openGateBits & mask) == 0)
+ {
+ return false;
+ }
+
+ _openGateBits &= ~mask;
+ return true;
+ }
+
+ return _openGateOverflow?.Remove(definitionIndex) == true;
+ }
+
+ /// Empties the gate set; the next registration hydrates it from the clock again.
+ private void ClearGates()
+ {
+ _openGateBits = 0;
+ _openGateOverflow?.Clear();
+ }
+
+ #endregion
+
+ #region Per-entry deadlines
+
+ /// The subgroup entry selection is restricted to this cycle, or -1 for any.
+ private int SelectionSubgroup => _cycleMode == SpawnCycleMode.Sequential ? _currentSubgroup : -1;
+
+ /// Whether any entry this cycle could select is past its deadline (T5 / T6).
+ /// The instant the tick is running at.
+ /// True when at least one selectable entry is due.
+ private bool HasDueEntry(DateTime now)
+ {
+ var entries = _spawnEntries;
+ if (entries == null)
+ {
+ return false;
+ }
+
+ var subgroup = SelectionSubgroup;
+ for (var i = 0; i < entries.Count; i++)
+ {
+ var entry = entries[i];
+ if (IsEligible(entry, subgroup) && entry.IsDue(now))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Arms the timer for the next moment an entry could be selected: the earliest of the selectable
+ /// entries' own deadlines, with an entry that carries none contributing the spawner's random
+ /// delay rather than being ignored. A deadline already in the past arms at the floor, so a tick
+ /// that could not spend a due entry comes back promptly instead of after a full delay.
+ ///
+ ///
+ /// Deliberately not clamped to : a per-entry delay is allowed
+ /// to be longer than the spawner's, and clamping would wake the spawner repeatedly for an entry
+ /// that is not due for hours.
+ ///
+ /// The instant the tick is running at.
+ private void ArmAtEarliestDeadline(DateTime now)
+ {
+ var entries = _spawnEntries;
+ var subgroup = SelectionSubgroup;
+ var found = false;
+ var earliest = TimeSpan.Zero;
+ var haveRandom = false;
+ var randomDelay = TimeSpan.Zero;
+
+ if (entries != null)
+ {
+ for (var i = 0; i < entries.Count; i++)
+ {
+ var entry = entries[i];
+ if (!IsEligible(entry, subgroup))
+ {
+ continue;
+ }
+
+ TimeSpan delay;
+ if (entry.NextEligible == default)
+ {
+ // No deadline of its own, so it is due whenever the spawner's own delay says so.
+ // Rolled once for the whole scan: a tick must not pay a roll per entry.
+ if (!haveRandom)
+ {
+ randomDelay = RandomSpawnerDelay();
+ haveRandom = true;
+ }
+
+ delay = randomDelay;
+ }
+ else
+ {
+ delay = entry.NextEligible - now;
+ }
+
+ if (delay < MinimumArmDelay)
+ {
+ delay = MinimumArmDelay;
+ }
+
+ if (!found || delay < earliest)
+ {
+ earliest = delay;
+ found = true;
+ }
+ }
+ }
+
+ if (!found)
+ {
+ // Nothing selectable at all - every entry full or disabled. The base delay keeps the
+ // spawner alive so it notices when that changes.
+ DoTimer();
+ return;
+ }
+
+ DoTimer(earliest);
+ }
+
+ /// One roll of the spawner's own delay, the same one uses.
+ /// A delay between and .
+ private TimeSpan RandomSpawnerDelay() =>
+ TimeSpan.FromMilliseconds(
+ Utility.RandomMinMax((long)MinDelay.TotalMilliseconds, (long)MaxDelay.TotalMilliseconds)
+ );
+
+ #endregion
+
+ ///
+ /// The synthetic trigger behind : an event source that is not
+ /// one of the spawner's definitions, so it carries no id, no state, no cooldown and no tokens. It
+ /// holds nothing per call, so one instance serves every spawner.
+ ///
+ private sealed class ExternalTrigger : TriggerBase
+ {
+ /// The shared instance; external events carry no per-call state.
+ public static ExternalTrigger Instance { get; } = new();
+
+ ///
+ public override string TriggerType => "external";
+
+ ///
+ public override TriggerKind Kind => TriggerKind.Event;
+
+ ///
+ public override bool Evaluate(in TriggerContext context) => true;
+
+ ///
+ public override string Serialize() => "external";
+ }
+}
diff --git a/Projects/ModernSpawner/Core/ModernSpawner.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs
index 1e4bde2..5627fef 100644
--- a/Projects/ModernSpawner/Core/ModernSpawner.cs
+++ b/Projects/ModernSpawner/Core/ModernSpawner.cs
@@ -16,7 +16,7 @@ namespace Server.Engines.ModernSpawner;
/// Owns a list of through ModernUO's entry-ownership contract, so
/// every base spawn path (Spawn, Defrag, Remove, RemoveAllEntries) runs over the modern entries.
///
-[SerializationGenerator(0)]
+[SerializationGenerator(1)]
public partial class ModernSpawner : Spawner
{
// Owned here so the base contract runs over ModernSpawnerEntry; null until the first entry.
@@ -72,13 +72,11 @@ public partial class ModernSpawner : Spawner
[SerializedCommandProperty(AccessLevel.Developer)]
private int _maxZDelta = 20;
- ///
- /// List of trigger conditions that can activate this spawner.
- /// Stored as serialized trigger definitions.
- ///
+ // The generated accessors are private so ids can only be minted through AddTriggerDefinition:
+ // the raw list helpers would append a definition with a default (empty) id.
[SerializedIgnoreDupe]
- [SerializableField(8)]
- private List _triggerDefinitions = [];
+ [SerializableField(8, getter: "private", setter: "private")]
+ private List _triggerDefs = [];
///
/// Whether this spawner is trigger-activated (vs. timer-based). Master switch for this spawner's
@@ -104,27 +102,28 @@ public bool TriggerActivated
_triggerActivated = value;
this.MarkDirty();
EnsureTriggersActive();
+
+ if (!value)
+ {
+ // A4: it may have been parked behind a closed gate or an empty queue, and nothing
+ // else is going to arm it now that those are gone. Only deactivation re-arms; a
+ // definition edit on a non-activated spawner must not re-roll its countdown.
+ DoTimer();
+ }
}
}
- ///
- /// External trigger state - set by trigger system.
- ///
- [SerializableField(10)]
- [SerializedCommandProperty(AccessLevel.Developer)]
- private bool _triggered;
-
///
/// Notes field for admin documentation.
///
- [SerializableField(11)]
+ [SerializableField(10)]
[SerializedCommandProperty(AccessLevel.Developer)]
private string _notes;
///
/// Selection strategy used each spawn cycle. See .
///
- [SerializableField(12)]
+ [SerializableField(11)]
[SerializedCommandProperty(AccessLevel.Developer)]
private SpawnCycleMode _cycleMode = SpawnCycleMode.Random;
@@ -132,7 +131,7 @@ public bool TriggerActivated
/// In mode, only entries with
/// Subgroup == CurrentSubgroup are eligible this cycle.
///
- [SerializableField(13)]
+ [SerializableField(12)]
[SerializedCommandProperty(AccessLevel.Developer)]
private int _currentSubgroup;
@@ -142,14 +141,14 @@ public bool TriggerActivated
/// after this much real time has elapsed without an advance.
/// disables auto-reset.
///
- [SerializableField(14)]
+ [SerializableField(13)]
[SerializedCommandProperty(AccessLevel.Developer)]
private TimeSpan _sequentialResetTime;
///
/// Subgroup that rewinds to. Defaults to 0.
///
- [SerializableField(15)]
+ [SerializableField(14)]
[SerializedCommandProperty(AccessLevel.Developer)]
private int _sequentialResetTo;
@@ -157,10 +156,56 @@ public bool TriggerActivated
/// When true, is a no-op. Lets scripts / triggers pin
/// the spawner on a specific subgroup until explicitly released.
///
- [SerializableField(16)]
+ [SerializableField(15)]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _holdSequence;
+ // Runtime queue. Private accessors: slots are only created through the bounded enqueue path,
+ // which enforces MaxPendingCycles, and only dropped through the drain / clear paths.
+ [SerializedIgnoreDupe]
+ [SerializableField(16, getter: "private", setter: "private")]
+ private List _pendingSlots = [];
+
+ ///
+ /// How many trigger-bought cycles this spawner may hold at once. 0 reproduces XmlSpawner:
+ /// an event runs now or is dropped, never latched. Lowering it trims the oldest queued slots.
+ ///
+ [SerializableField(17, fieldChanged: nameof(OnMaxPendingCyclesChanged))]
+ [SerializedCommandProperty(AccessLevel.Developer)]
+ private int _maxPendingCycles = 1;
+
+ ///
+ /// Low end of the spawner-wide lockout applied after any accepted event. Zero disables it.
+ ///
+ [SerializableField(18)]
+ [SerializedCommandProperty(AccessLevel.Developer)]
+ private TimeSpan _refractoryMin;
+
+ ///
+ /// High end of the spawner-wide lockout applied after any accepted event. Zero disables it.
+ ///
+ [SerializableField(19)]
+ [SerializedCommandProperty(AccessLevel.Developer)]
+ private TimeSpan _refractoryMax;
+
+ ///
+ /// Absolute instant before which no event is accepted, rolled from the refractory range.
+ /// Default means "no lockout pending", which is the common case, so it is written conditionally.
+ ///
+ [SerializedIgnoreDupe]
+ [SerializableField(20)]
+ [SerializedCommandProperty(AccessLevel.Developer)]
+ [SaveFlag(nameof(ShouldSerializeRefractoryUntil))]
+ private DateTime _refractoryUntil;
+
+ // Per-definition runtime state, keyed by TriggerDefinition.Id. Private accessors: entries are
+ // created and dropped by SyncTriggerStates, which keeps them in step with the definition list.
+ [SerializedIgnoreDupe]
+ [SerializableField(21, getter: "private", setter: "private")]
+ private List _triggerStateList = [];
+
+ private bool ShouldSerializeRefractoryUntil() => _refractoryUntil != default;
+
// When the last spawn happened, used to enforce SequentialResetTime.
private DateTime _lastSequenceAdvance = DateTime.MinValue;
@@ -168,9 +213,30 @@ public bool TriggerActivated
private bool _hasSpeechTriggers;
private bool _hasProximityTriggers;
- // Extended area movement subscription tracking
- private bool _hasExtendedProximityTriggers;
- private Rectangle2D _extendedTriggerBounds;
+ ///
+ /// Whether this spawner is already on the trigger system's drain list for the dispatch in progress.
+ /// Runtime only: a drain list never outlives the dispatch that filled it.
+ ///
+ internal bool DrainRequested { get; set; }
+
+ ///
+ /// This spawner's trigger definitions, in gump order. Read-only: use
+ /// , and
+ /// so ids are minted and runtime state stays in step.
+ ///
+ public IReadOnlyList TriggerDefinitions =>
+ _triggerDefs ?? (IReadOnlyList)Array.Empty();
+
+ /// Cycles bought by accepted trigger events and not yet drained, oldest first.
+ public IReadOnlyList PendingCycles =>
+ _pendingSlots ?? (IReadOnlyList)Array.Empty();
+
+ /// Number of queued cycles; never greater than .
+ public int PendingCycleCount => _pendingSlots?.Count ?? 0;
+
+ /// Per-definition runtime state, one entry per definition, keyed by id.
+ public IReadOnlyList TriggerStates =>
+ _triggerStateList ?? (IReadOnlyList)Array.Empty();
/// Typed view of the entries; the base is the same list.
public IReadOnlyList ModernEntries =>
@@ -400,20 +466,46 @@ public ModernSpawnerEntry AddModernEntry(
return entry;
}
- public override void Spawn()
+ ///
+ /// Runs one spawn cycle by hand (M1). Manual spawning is trigger-bypassing: the gate, the queue
+ /// and the per-entry deadlines are all ignored, and nothing the triggers have bought is spent.
+ /// The timer path reaches the same body through , which honours them.
+ ///
+ public override void Spawn() => SpawnCore(true);
+
+ ///
+ /// The cycle body: before-spawn script, defrag, entry selection per cycle mode, one attempt (or
+ /// one per entry in ), after-spawn script.
+ ///
+ ///
+ /// Whether entry selection ignores . Event cycles and
+ /// manual spawns bypass it; timer cycles honour it.
+ ///
+ private void SpawnCore(bool bypassDeadlines)
{
using var _ = SpawnerMetrics.MeasureSpawn();
- var beforeScript = OnBeforeSpawnScript;
- if (beforeScript?.IsValid == true)
- {
- var context = new ScriptContext(null, this);
- ScriptEngine.Instance.Execute(beforeScript, context);
+ // A group respawn is one bulk operation, so its scripts wrap the whole loop rather than each
+ // Spawn() inside it (§7).
+ var runScripts = !_inBulkRespawn;
- // Check if the script cancelled the spawn
- if (context.CancelSpawn)
+ if (runScripts)
+ {
+ var beforeScript = OnBeforeSpawnScript;
+ if (beforeScript?.IsValid == true)
{
- return;
+ var context = new ScriptContext(null, this)
+ {
+ TriggeringMobile = _cycleTriggeringMobile
+ };
+
+ ScriptEngine.Instance.Execute(beforeScript, context);
+
+ // Check if the script cancelled the spawn
+ if (context.CancelSpawn)
+ {
+ return;
+ }
}
}
@@ -429,49 +521,65 @@ public override void Spawn()
MaybeAutoResetSequence();
+ var now = Core.Now;
+
using (SpawnerMetrics.MeasureEntrySelection())
{
switch (_cycleMode)
{
case SpawnCycleMode.Sequential:
- SpawnWeightedOne(_currentSubgroup);
+ SpawnWeightedOne(_currentSubgroup, bypassDeadlines, now);
break;
- case SpawnCycleMode.Group:
- SpawnGroupMode();
+ case SpawnCycleMode.AllEntries:
+ SpawnAllEntries(bypassDeadlines, now);
break;
default:
- SpawnWeightedOne(-1);
+ SpawnWeightedOne(-1, bypassDeadlines, now);
break;
}
}
+ if (!runScripts)
+ {
+ return;
+ }
+
var afterScript = OnAfterSpawnScript;
if (afterScript?.IsValid == true)
{
- var context = new ScriptContext(null, this);
+ var context = new ScriptContext(null, this)
+ {
+ TriggeringMobile = _cycleTriggeringMobile
+ };
+
ScriptEngine.Instance.Execute(afterScript, context);
}
}
///
- /// Spawns one entity from every eligible entry this cycle. When all entries are at
+ /// Spawns one entity from every selectable entry this cycle. When all entries are at
/// their max count, no further spawns happen until the pack is cleared.
///
- private void SpawnGroupMode()
+ /// Whether per-entry deadlines are ignored.
+ /// The instant this cycle is running at.
+ private void SpawnAllEntries(bool bypassDeadlines, DateTime now)
{
var entries = _spawnEntries;
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
- if (!entry.IsFull && !entry.Disabled)
+ if (IsSelectable(entry, -1, bypassDeadlines, now))
{
- SpawnEntry(entry);
+ SpawnEntry(entry, now);
}
}
}
- /// Weighted pick over eligible entries; -1 means any subgroup.
- private void SpawnWeightedOne(int subgroup)
+ /// Weighted pick over selectable entries; -1 means any subgroup.
+ /// The subgroup to restrict selection to, or -1.
+ /// Whether per-entry deadlines are ignored.
+ /// The instant this cycle is running at.
+ private void SpawnWeightedOne(int subgroup, bool bypassDeadlines, DateTime now)
{
var entries = _spawnEntries;
var probsum = 0;
@@ -479,7 +587,7 @@ private void SpawnWeightedOne(int subgroup)
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
- if (IsEligible(entry, subgroup))
+ if (IsSelectable(entry, subgroup, bypassDeadlines, now))
{
probsum += entry.SpawnedProbability;
}
@@ -495,14 +603,14 @@ private void SpawnWeightedOne(int subgroup)
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
- if (!IsEligible(entry, subgroup))
+ if (!IsSelectable(entry, subgroup, bypassDeadlines, now))
{
continue;
}
if (rand <= entry.SpawnedProbability)
{
- SpawnEntry(entry);
+ SpawnEntry(entry, now);
return;
}
@@ -513,13 +621,31 @@ private void SpawnWeightedOne(int subgroup)
private static bool IsEligible(ModernSpawnerEntry entry, int subgroup) =>
!entry.IsFull && !entry.Disabled && (subgroup < 0 || entry.Subgroup == subgroup);
- /// Spawns one entity from and records the attempt's flags.
- private void SpawnEntry(ModernSpawnerEntry entry)
+ private static bool IsSelectable(ModernSpawnerEntry entry, int subgroup, bool bypassDeadlines, DateTime now) =>
+ IsEligible(entry, subgroup) && (bypassDeadlines || entry.IsDue(now));
+
+ ///
+ /// Spawns one entity from , records the attempt's flags and moves the
+ /// entry's own deadline: its full delay after a placement, a short backoff after a failure so a
+ /// broken entry cannot burn every cycle.
+ ///
+ /// The entry to spawn from.
+ /// The instant this cycle is running at.
+ private void SpawnEntry(ModernSpawnerEntry entry, DateTime now)
{
using var _ = SpawnerMetrics.MeasureSpawnFromEntry();
- Spawn(entry, out var flags);
+ var placed = Spawn(entry, out var flags);
entry.Valid = flags;
+
+ if (placed)
+ {
+ entry.NextEligible = now + Utility.RandomMinMax(entry.EffectiveMinDelay, entry.EffectiveMaxDelay);
+ return;
+ }
+
+ var minDelay = entry.EffectiveMinDelay;
+ entry.NextEligible = now + (minDelay < FailureBackoff ? minDelay : FailureBackoff);
}
///
@@ -598,29 +724,303 @@ private void MaybeAutoResetSequence()
///
/// Brings this spawner's trigger registrations in line with its current state, and is the only
/// caller of outside the trigger system itself.
- /// replaces the spawner's batch in the registry but
- /// appends to the per-type dispatch lists, so a second call would duplicate dispatch; this
- /// deactivates first and is therefore safe to call any number of times. Every construction path
- /// that can leave a spawner running with triggers already set - start, deserialization, dupe,
- /// import, migration - ends here, because only reaches
- /// when actually flips and a
- /// constructed spawner is already running.
+ /// Each call parses the definitions into a fresh
+ /// TriggerSet; deactivating first is what retires the previous one, so the triggers it holds
+ /// stop monitoring (window timers cancelled, skill candidacy dropped) instead of being left live
+ /// alongside their replacements. That also makes this safe to call any number of times.
+ ///
+ /// Registration does not follow (A1/A2), so starting and stopping
+ /// a spawner do not come through here. What does: the setter, the
+ /// three definition-list wrappers, a map change on an already registered spawner, the deferred load
+ /// hook, and every construction path that hands back a spawner with definitions already set -
+ /// dupe, DTO import, both JSON importers, the XmlSpawner importer and the migrator.
+ ///
///
internal void EnsureTriggersActive()
{
TriggerSystem.Instance.DeactivateTriggers(this);
- if (Running && _triggerActivated && _triggerDefinitions is { Count: > 0 })
+ // A3: definitions may have been added, removed or reordered since the last registration, so
+ // re-bind state by id before anything parses the list again.
+ SyncTriggerStates();
+
+ // A4: a deactivated spawner is a plain timer spawner - it holds no cycles and no open gates.
+ if (!_triggerActivated)
+ {
+ ClearPendingCycles();
+ ClearRunNow();
+ TriggerSystem.Instance.CancelDrain(this);
+ }
+
+ // Gates are never persisted; every registration recomputes them from the clock.
+ ClearGates();
+
+ // A1/A2: registration is independent of Running, so a stopped spawner can still be woken by
+ // one of its own triggers.
+ if (Deleted || !_triggerActivated || _triggerDefs is not { Count: > 0 })
+ {
+ return;
+ }
+
+ // A3: a window that is already open reports its open edge from inside Activate; while
+ // hydrating that edge only records the gate, it does not buy a cycle.
+ _hydratingGates = true;
+ try
{
TriggerSystem.Instance.ActivateTriggers(this);
}
+ finally
+ {
+ _hydratingGates = false;
+ }
}
- ///
- protected override void OnStarted()
+ ///
+ /// Adds a trigger definition with a freshly minted id, then re-registers. This is the only way to
+ /// grow the definition list: the generated collection helpers are private because they cannot
+ /// assign an id.
+ ///
+ /// The definition text the trigger system parses, e.g. proximity:8:true.
+ public void AddTriggerDefinition(string text) => AddTriggerDefinition(Guid.Empty, text);
+
+ ///
+ /// Adds a trigger definition keeping an existing id. Import paths use this so ids survive an
+ /// export and re-import; asks for a fresh id.
+ ///
+ /// The id to keep, or to generate one.
+ /// The definition text the trigger system parses.
+ public void AddTriggerDefinition(Guid id, string text)
{
+ TriggerDefs ??= [];
+ AddToTriggerDefs(new TriggerDefinition(this, UniqueDefinitionId(id), text));
EnsureTriggersActive();
+ }
+
+ ///
+ /// An id for a definition about to be added: the one supplied, unless it is empty or already in
+ /// use on this spawner. Ids arriving from outside can collide - a hand-edited export, a block
+ /// copied between spawners - and two definitions sharing an id would alias onto one
+ /// and onto each other's pending slots, so the later one is
+ /// given a fresh id instead.
+ ///
+ /// The requested id, or .
+ /// An id not currently used by any definition on this spawner.
+ private Guid UniqueDefinitionId(Guid id) =>
+ id == Guid.Empty || HasDefinition(_triggerDefs, id) ? Guid.CreateVersion7() : id;
+
+ ///
+ /// Removes the definition at and, with it, its runtime state and any
+ /// queued cycle that named it, then re-registers. Out-of-range indexes are ignored.
+ ///
+ /// Position in .
+ public void RemoveTriggerDefinitionAt(int index)
+ {
+ if (_triggerDefs == null || index < 0 || index >= _triggerDefs.Count)
+ {
+ return;
+ }
+
+ RemoveFromTriggerDefsAt(index);
+ EnsureTriggersActive();
+ }
+
+ ///
+ /// Drops every definition along with all runtime state and queued cycles, then unregisters.
+ ///
+ public void ClearTriggerDefinitions()
+ {
+ if (_triggerDefs is { Count: > 0 })
+ {
+ ClearTriggerDefs();
+ }
+
+ EnsureTriggersActive();
+ }
+
+ /// Runtime state for a definition id, or null when the id is not (or no longer) defined.
+ /// A .
+ /// The bound state, or null.
+ public TriggerRuntimeState GetTriggerState(Guid id)
+ {
+ var states = _triggerStateList;
+ if (states == null)
+ {
+ return null;
+ }
+
+ for (var i = 0; i < states.Count; i++)
+ {
+ if (states[i].Id == id)
+ {
+ return states[i];
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Runtime state for a definition id, created when this spawner does not have one yet. The trigger
+ /// system calls this once per definition at registration and binds the result onto the parsed
+ /// trigger, so no dispatch ever looks state up.
+ ///
+ /// A .
+ /// The bound state, or null for .
+ internal TriggerRuntimeState GetOrCreateTriggerState(Guid id)
+ {
+ if (id == Guid.Empty)
+ {
+ return null;
+ }
+
+ var existing = GetTriggerState(id);
+ if (existing != null)
+ {
+ return existing;
+ }
+
+ var state = new TriggerRuntimeState(this, id);
+ TriggerStateList ??= [];
+ AddToTriggerStateList(state);
+ return state;
+ }
+
+ ///
+ /// Brings runtime state and queued cycles in line with the definition list (A3): every definition
+ /// gets exactly one state, states for removed definitions are dropped, and queued cycles naming a
+ /// definition that no longer exists are discarded. Slots from external calls
+ /// carry and are kept. Registration-time only, never on a tick.
+ ///
+ private void SyncTriggerStates()
+ {
+ var definitions = _triggerDefs;
+
+ var states = _triggerStateList;
+ if (states != null)
+ {
+ for (var i = states.Count - 1; i >= 0; i--)
+ {
+ if (!HasDefinition(definitions, states[i].Id))
+ {
+ RemoveFromTriggerStateListAt(i);
+ }
+ }
+ }
+ if (definitions != null)
+ {
+ for (var i = 0; i < definitions.Count; i++)
+ {
+ var id = definitions[i].Id;
+ if (GetTriggerState(id) == null)
+ {
+ TriggerStateList ??= [];
+ AddToTriggerStateList(new TriggerRuntimeState(this, id));
+ }
+ }
+ }
+
+ var slots = _pendingSlots;
+ if (slots == null)
+ {
+ return;
+ }
+
+ for (var i = slots.Count - 1; i >= 0; i--)
+ {
+ var triggerId = slots[i].TriggerId;
+ if (triggerId != Guid.Empty && !HasDefinition(definitions, triggerId))
+ {
+ RemoveFromPendingSlotsAt(i);
+ }
+ }
+ }
+
+ private static bool HasDefinition(List definitions, Guid id)
+ {
+ if (definitions == null)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < definitions.Count; i++)
+ {
+ if (definitions[i].Id == id)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Queues one cycle for , carrying the mobile that raised the event so
+ /// a deferred drain can still position relative to it. Bounded by :
+ /// returns false when the queue is full (E6) or the bound is zero.
+ ///
+ /// The definition that bought the cycle, or .
+ /// The mobile that raised the event, or .
+ /// True when a slot was queued.
+ internal bool EnqueuePendingCycle(Guid triggerId, Serial triggeringMobile)
+ {
+ if (_maxPendingCycles <= 0 || PendingCycleCount >= _maxPendingCycles)
+ {
+ return false;
+ }
+
+ PendingSlots ??= [];
+ AddToPendingSlots(new PendingCycle(this, triggerId, triggeringMobile));
+ return true;
+ }
+
+ ///
+ /// Test seam that plants a queued cycle directly, so a test can set up a spawner that is already
+ /// holding one without first driving a whole event through . Uses the
+ /// same bounded enqueue the acceptance path uses.
+ ///
+ /// The definition that bought the cycle, or .
+ /// The mobile that raised the event, or .
+ /// True when a slot was queued.
+ internal bool EnqueuePendingForTest(Guid triggerId, Serial mobile) =>
+ EnqueuePendingCycle(triggerId, mobile);
+
+ /// Drops every queued cycle. Cooldowns, kill counters and registrations are untouched.
+ internal void ClearPendingCycles()
+ {
+ if (_pendingSlots is { Count: > 0 })
+ {
+ ClearPendingSlots();
+ }
+ }
+
+ // A5: the bound is never negative, and lowering it drops the oldest slots first.
+ private void OnMaxPendingCyclesChanged(int oldValue, int newValue)
+ {
+ if (_maxPendingCycles < 0)
+ {
+ _maxPendingCycles = 0;
+ }
+
+ TrimPendingCycles();
+ }
+
+ private void TrimPendingCycles()
+ {
+ while (PendingCycleCount > _maxPendingCycles)
+ {
+ RemoveFromPendingSlotsAt(0);
+ }
+ }
+
+ ///
+ /// Runs the activate script. A1: starting a spawner arms its timer and nothing else - registration
+ /// follows and the definition list, not ,
+ /// so there is nothing to (re)register here and re-parsing a live set on every start would drop
+ /// the gate schedulers and the trigger objects the runtime state is bound to.
+ ///
+ protected override void OnStarted()
+ {
var activateScript = OnActivateScript;
if (activateScript?.IsValid == true)
{
@@ -629,11 +1029,10 @@ protected override void OnStarted()
}
///
- /// Runs the deactivate script and unregisters this spawner's triggers. Deleting a running
- /// spawner reaches here as well, through calling
- /// , so the deactivate script runs on deletion too:
- /// sets only after
- /// has returned, so there is no state here that distinguishes a stop from a deletion.
+ /// Runs the deactivate script. A2: stopping a spawner stops its timer and nothing else - the
+ /// registrations, cooldowns, counters and gate schedulers all stay live, so a stopped spawner
+ /// still hears its own triggers and a wake: one can start it again.
+ /// is what tears a registration down.
///
protected override void OnStopped()
{
@@ -642,10 +1041,6 @@ protected override void OnStopped()
{
ScriptEngine.Instance.Execute(deactivateScript, new ScriptContext(null, this));
}
-
- // DeactivateTriggers is a no-op when nothing is registered, so no flag check: the flag can be
- // cleared after registration and must not leave a stale entry behind.
- TriggerSystem.Instance.DeactivateTriggers(this);
}
///
@@ -662,7 +1057,10 @@ protected override Point3D GetSpawnPosition(SpawnerEntry entry, ISpawnable spawn
{
var posContext = new PositioningContext(this, spawned, map, modern)
{
- MaxZDelta = _maxZDelta
+ MaxZDelta = _maxZDelta,
+ // The cycle in flight owns the triggering mobile, resolved from the queued slot
+ // when the drain happened long after the event; player_relative reads it here.
+ TriggeringMobile = _cycleTriggeringMobile
};
var position = PositioningRules.GetPosition(modern.PositioningRule, posContext);
@@ -702,7 +1100,12 @@ protected override void OnSpawned(SpawnerEntry entry, ISpawnable spawned)
var compiledScript = ScriptEngine.Instance.Compile(modern.OnSpawnScript);
if (compiledScript?.IsValid == true)
{
- ScriptEngine.Instance.Execute(compiledScript, new ScriptContext(spawned, this));
+ var context = new ScriptContext(spawned, this)
+ {
+ TriggeringMobile = _cycleTriggeringMobile
+ };
+
+ ScriptEngine.Instance.Execute(compiledScript, context);
}
}
}
@@ -930,7 +1333,12 @@ public override void GetSpawnerProperties(IPropertyList list)
if (_triggerActivated)
{
- list.Add(1050039, $"{"trigger:"}\t{(_triggered ? "active" : "waiting")}");
+ list.Add(1050039, $"{"pending:"}\t{PendingCycleCount}");
+
+ if (GateCount > 0)
+ {
+ list.Add(1050039, $"{"gate:"}\t{(GateOpen ? "open" : "closed")}");
+ }
}
if (_useSmartPositioning)
@@ -939,99 +1347,32 @@ public override void GetSpawnerProperties(IPropertyList list)
}
}
- ///
- /// Triggers the spawner from an external source (trigger system).
- ///
- public void Trigger()
- {
- if (!_triggerActivated)
- {
- return;
- }
-
- _triggered = true;
- this.MarkDirty();
-
- if (!Running)
- {
- Start();
- }
- else
- {
- // Force an immediate spawn
- Spawn();
- }
- }
-
- ///
- /// Resets the trigger state.
- ///
- public void ResetTrigger()
- {
- _triggered = false;
- this.MarkDirty();
- }
-
- ///
- /// Called by a time-window trigger when its window opens.
- /// This enables spawning during the trigger's active window.
- ///
- /// The trigger that activated.
- public void OnTriggerActivated(ITrigger trigger)
- {
- if (!_triggerActivated || !Running)
- {
- return;
- }
-
- _triggered = true;
- this.MarkDirty();
-
- // Force an immediate spawn check when trigger activates
- Spawn();
- }
-
- ///
- /// Called by a time-window trigger when its window closes.
- /// This disables spawning until the trigger reactivates.
- ///
- /// The trigger that deactivated.
- public void OnTriggerDeactivated(ITrigger trigger)
- {
- if (!_triggerActivated)
- {
- return;
- }
-
- _triggered = false;
- this.MarkDirty();
- }
-
[AfterDeserialization]
private void AfterDeserializationModernSpawner()
{
// Spawner's rebuild ran before _spawnEntries was read; rebuild over the modern list.
RebuildSpawned();
- // Activate triggers if spawner is running
- EnsureTriggersActive();
+ // L1: binding runtime state to the definitions it belongs to is safe while the world is still
+ // reading, and it keeps a migrated save's state list in step with its definitions. Registering
+ // is not: a gate has to hydrate against a world that has finished loading, so it waits for
+ // the deferred pass below.
+ SyncTriggerStates();
}
///
- /// Called after world load completes.
- /// Subscribes to extended area movement if needed.
+ /// Called once the world has finished loading (L1/L2): the deferred half of the restore, where
+ /// registration and gate hydration happen. Nothing on this path spawns.
///
[AfterDeserialization(false)]
- private void AfterWorldLoad()
- {
- // Extended area movement subscription is not yet supported in ModernUO
- // TODO: Implement extended proximity trigger support when Map APIs are available
- }
+ private void AfterWorldLoad() => OnWorldLoaded();
///
/// Copies the modern fields the dupe contract cannot reach. The base override copies the entries;
- /// is [SerializedIgnoreDupe] because the copy must own its
- /// own list rather than share this one, so it is copied here and then registered.
+ /// the definition list is [SerializedIgnoreDupe] because the copy must own its own list of
+ /// its own definition objects rather than share this one, so it is rebuilt here and then
+ /// registered. Ids are carried across so the copy's runtime state keys line up with its
+ /// definitions. Queued cycles and cooldowns are runtime state and are not copied.
///
/// The freshly duped item.
public override void OnAfterDuped(Item newItem)
@@ -1043,8 +1384,15 @@ public override void OnAfterDuped(Item newItem)
return;
}
- // Through the generated setter so the copy is marked dirty.
- copy.TriggerDefinitions = new List(_triggerDefinitions);
+ var definitions = _triggerDefs;
+ if (definitions != null)
+ {
+ for (var i = 0; i < definitions.Count; i++)
+ {
+ copy.AddTriggerDefinition(definitions[i].Id, definitions[i].Text);
+ }
+ }
+
copy.EnsureTriggersActive();
}
@@ -1053,14 +1401,25 @@ public override void OnAfterDuped(Item newItem)
///
public override void OnDelete()
{
- // Unsubscribe from extended area movement before deletion
- UnsubscribeFromExtendedAreaMovement();
-
- // Deactivate triggers before deletion. DeactivateTriggers is a no-op when nothing is registered,
- // so no flag check: the flag can be cleared after registration and must not leave a stale entry behind.
- TriggerSystem.Instance.DeactivateTriggers(this);
-
- base.OnDelete();
+ // X1: invalidate the registration first, so a request already in flight - a gate edge, a
+ // lifecycle script - is dropped rather than run against a spawner that is going away, and
+ // cancel anything already queued for a drain.
+ ClearRegistration();
+ TriggerSystem.Instance.CancelDrain(this);
+ ClearPendingCycles();
+ ClearRunNow();
+
+ try
+ {
+ // Runs the base lifecycle, including Stop() and with it the deactivate script.
+ base.OnDelete();
+ }
+ finally
+ {
+ // DeactivateTriggers is a no-op when nothing is registered, so no flag check: the flag can
+ // be cleared after registration and must not leave a stale entry behind.
+ TriggerSystem.Instance.DeactivateTriggers(this);
+ }
}
///
@@ -1069,16 +1428,21 @@ public override void OnDelete()
public override void OnMapChange()
{
base.OnMapChange();
- // Extended area movement subscription is not yet supported in ModernUO
- }
- ///
- /// Called when the spawner's location changes.
- ///
- public override void OnLocationChange(Point3D oldLocation)
- {
- base.OnLocationChange(oldLocation);
- // Extended area movement subscription is not yet supported in ModernUO
+ // A3 lists a map change alongside a definition change: a game-time gate reads the clock of the
+ // map it stands on, so a spawner that moves has to recompute its window rather than keep the
+ // old map's. Re-registering does that and re-files skill candidacy at the same time.
+ // Guarded on an existing registration so a world load, which sets the map before the deferred
+ // restore runs, does not register early.
+ if (_registrationGeneration != 0)
+ {
+ EnsureTriggersActive();
+ return;
+ }
+
+ // Skill dispatch keeps a candidate list per map, so a registered spawner has to move between
+ // those lists rather than be found by a registry scan.
+ TriggerSystem.Instance.OnSpawnerMapChanged(this);
}
public override void OnDoubleClick(Mobile from)
@@ -1090,14 +1454,14 @@ public override void OnDoubleClick(Mobile from)
}
///
- /// Returns true when this spawner has active speech triggers.
- /// This enables ModernUO's built-in speech dispatch to call OnSpeech.
+ /// Returns true when this spawner has registered speech triggers, whether or not it is running:
+ /// A2 keeps a stopped spawner listening, because a wake: trigger has to be able to start it.
///
public override bool HandlesOnSpeech => _hasSpeechTriggers;
///
- /// Returns true when this spawner has active proximity triggers.
- /// This enables ModernUO's built-in movement dispatch to call OnMovement.
+ /// Returns true when this spawner has registered proximity triggers, whether or not it is running:
+ /// A2 keeps a stopped spawner listening, because a wake: trigger has to be able to start it.
///
public override bool HandlesOnMovement => _hasProximityTriggers;
@@ -1117,24 +1481,24 @@ public override void OnSpeech(SpeechEventArgs e)
}
///
- /// Called by ModernUO when a mobile moves near this spawner.
- /// For normal proximity (within 24 tiles): called via Item.HandlesOnMovement.
- /// For extended proximity (beyond 24 tiles): called via area movement subscription.
- /// Routes to the trigger system for evaluation.
+ /// Called by ModernUO when a mobile moves near this spawner, via Item.HandlesOnMovement.
+ /// Routes to the trigger system for evaluation. Proximity ranges are clamped to
+ /// at parse time, because nothing outside that radius is
+ /// dispatched here at all.
///
+ /// The mobile that moved.
+ /// Where it came from.
public override void OnMovement(Mobile m, Point3D oldLocation)
{
- if (m?.Map == null || m.Map == Map.Internal || !Running || Deleted)
+ // A2: dispatch does not depend on Running - a stopped spawner still evaluates its triggers so
+ // a wake trigger can start it and a non-wake one can queue a cycle for its first tick.
+ if (m?.Map == null || m.Map == Map.Internal || Deleted)
{
return;
}
- // For extended proximity triggers, check if mobile is within trigger bounds
- if (!_hasExtendedProximityTriggers || _extendedTriggerBounds.Contains(new Point2D(m.Location.X, m.Location.Y)))
- {
- using var _ = SpawnerMetrics.MeasureProximityDispatch();
- TriggerSystem.Instance.OnMobileProximity(m, m.Location, m.Map, this);
- }
+ using var _ = SpawnerMetrics.MeasureProximityDispatch();
+ TriggerSystem.Instance.OnMobileProximity(m, m.Location, m.Map, this);
}
///
@@ -1154,27 +1518,4 @@ internal void SetHasProximityTriggers(bool value)
{
_hasProximityTriggers = value;
}
-
- ///
- /// Sets up extended area movement trigger bounds.
- /// Called by TriggerSystem when proximity triggers with extended range are registered.
- /// Note: Extended area movement is not yet supported in ModernUO.
- ///
- internal void SetExtendedTriggerBounds(Rectangle2D bounds)
- {
- _extendedTriggerBounds = bounds;
- _hasExtendedProximityTriggers = true;
- // Extended area movement subscription is not yet supported in ModernUO
- }
-
- ///
- /// Unsubscribes from extended area movement notifications.
- /// Called by TriggerSystem when all extended proximity triggers are unregistered.
- ///
- internal void UnsubscribeFromExtendedAreaMovement()
- {
- _hasExtendedProximityTriggers = false;
- _extendedTriggerBounds = default;
- // Extended area movement subscription is not yet supported in ModernUO
- }
}
diff --git a/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs b/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs
index d59df77..3f1543e 100644
--- a/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs
+++ b/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs
@@ -11,7 +11,7 @@ namespace Server.Engines.ModernSpawner;
/// The six stock fields (name, probability, max count, properties, parameters, spawned) and the
/// Disabled flag come from .
///
-[SerializationGenerator(0)]
+[SerializationGenerator(1)]
public partial class ModernSpawnerEntry : SpawnerEntry
{
// The generator resolves dirty tracking on the declared type only (SerializationGenerator #58).
@@ -76,6 +76,38 @@ public partial class ModernSpawnerEntry : SpawnerEntry
[SerializedJsonPropertyName("subgroup")]
private int _subgroup;
+ ///
+ /// Absolute instant before which this entry is not selectable by a timer cycle. Trigger cycles in
+ /// mode:now bypass it. World-save only: it is runtime state, so it never reaches the DTO.
+ ///
+ [SerializableField(11)]
+ [SerializedJsonIgnore]
+ [SaveFlag(nameof(ShouldSerializeNextEligible))]
+ private DateTime _nextEligible;
+
+ private bool ShouldSerializeNextEligible() => _nextEligible != default;
+
+ ///
+ /// v0 -> v1. Every v0 field is carried over unchanged; is new and starts
+ /// at its default, so a migrated entry is immediately selectable.
+ ///
+ /// The v0 payload.
+ private void MigrateFrom(V0Content content)
+ {
+ _onSpawnScript = content.OnSpawnScript;
+ _onDespawnScript = content.OnDespawnScript;
+ _minDelay = content.MinDelay;
+ _maxDelay = content.MaxDelay;
+ _positioningRule = content.PositioningRule;
+ _spawnGroup = content.SpawnGroup;
+ _requireLOS = content.RequireLOS;
+ _spawnAreaOffset = content.SpawnAreaOffset;
+ _spawnRange = content.SpawnRange;
+ _lootTemplate = content.LootTemplate;
+ _subgroup = content.Subgroup;
+ _nextEligible = default;
+ }
+
public ModernSpawnerEntry(BaseSpawner parent) : base(parent)
{
}
@@ -115,4 +147,12 @@ public ModernSpawnerEntry(
/// Effective spawn range: the entry's override, else the spawner's home range.
[JsonIgnore]
public int EffectiveSpawnRange => _spawnRange >= 0 ? _spawnRange : Parent != null ? Parent.HomeRange : 4;
+
+ ///
+ /// Whether a timer cycle may select this entry at . An entry that has never
+ /// spawned carries no deadline (the default) and is always due.
+ ///
+ /// The instant the cycle is running at.
+ /// True when the entry's own deadline has passed.
+ public bool IsDue(DateTime now) => _nextEligible == default || _nextEligible <= now;
}
diff --git a/Projects/ModernSpawner/Core/SpawnCycleMode.cs b/Projects/ModernSpawner/Core/SpawnCycleMode.cs
index 3ec7e61..d42d7f9 100644
--- a/Projects/ModernSpawner/Core/SpawnCycleMode.cs
+++ b/Projects/ModernSpawner/Core/SpawnCycleMode.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace Server.Engines.ModernSpawner;
///
@@ -28,5 +30,13 @@ public enum SpawnCycleMode
/// point the whole group respawns. Suitable for monster camps and pack spawns that
/// should appear and respawn as a unit.
///
- Group = 2
+ AllEntries = 2,
+
+ ///
+ /// Former name of , kept for one release so exported JSON written
+ /// before the rename still parses. It shares ' numeric value, so
+ /// binary saves are unaffected. Do not use in new code.
+ ///
+ [Obsolete("Use AllEntries. Kept so JSON written as \"Group\" still parses.")]
+ Group = AllEntries
}
diff --git a/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs b/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs
index a093f79..8c3d6d9 100644
--- a/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs
+++ b/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using Server.Engines.ModernSpawner.Triggers;
using Server.Gumps;
using Server.Network;
using Server.Text;
@@ -102,7 +103,7 @@ protected override void BuildLayout(ref DynamicGumpBuilder builder)
// Trigger info
var sb = ValueStringBuilder.Create();
- FormatTriggerDisplay(trigger, ref sb);
+ FormatTriggerDisplay(trigger.Text, ref sb);
builder.AddHtml(55, y + 3, Width - 100, 20, sb.AsSpan(), "#F4F4F4");
y += 25;
sb.Dispose();
@@ -173,7 +174,7 @@ protected override void BuildLayout(ref DynamicGumpBuilder builder)
builder.AddLabel(Width - 45, Height - 32, 0x384, "Cancel");
}
- private List GetTriggerList() => _spawner.TriggerDefinitions ?? [];
+ private IReadOnlyList GetTriggerList() => _spawner.TriggerDefinitions;
/// Appends a minute field, zero-padding single digits so 18:0 renders as 18:00.
private static void AppendMinutes(scoped ref ValueStringBuilder sb, ReadOnlySpan minutes)
@@ -316,8 +317,7 @@ public override void OnResponse(NetState state, in RelayInfo info)
{
range = Math.Max(1, parsedRange);
}
- _spawner.AddToTriggerDefinitions($"proximity:{range}:true");
- _spawner.EnsureTriggersActive();
+ _spawner.AddTriggerDefinition($"proximity:{range}:true");
from.SendMessage($"Added proximity trigger with {range} tile range.");
break;
}
@@ -338,8 +338,7 @@ public override void OnResponse(NetState state, in RelayInfo info)
}
// WallTimeWindowTrigger.Parse reads wall_time_window:startHour:startMin:endHour:endMin;
// the gump only offers whole hours, so the minute fields are zero.
- _spawner.AddToTriggerDefinitions($"wall_time_window:{startHour}:0:{endHour}:0");
- _spawner.EnsureTriggersActive();
+ _spawner.AddTriggerDefinition($"wall_time_window:{startHour}:0:{endHour}:0");
from.SendMessage($"Added time window trigger: {startHour}:00 - {endHour}:00.");
break;
}
@@ -347,8 +346,7 @@ public override void OnResponse(NetState state, in RelayInfo info)
case ButtonId_AddGameTime:
// GameTimeWindowTrigger.Parse reads game_time_window:startHour:endHour:nightOnly;
// NightOnly is the parser's night preset and overrides the hours it is given.
- _spawner.AddToTriggerDefinitions("game_time_window:21:5:true");
- _spawner.EnsureTriggersActive();
+ _spawner.AddTriggerDefinition("game_time_window:21:5:true");
from.SendMessage("Added game time trigger for night hours.");
break;
@@ -359,10 +357,10 @@ public override void OnResponse(NetState state, in RelayInfo info)
var deleteIndex = info.ButtonID - ButtonId_DeleteBase;
if (deleteIndex >= 0 && deleteIndex < triggers.Count)
{
- // triggers is the live list: remove through the generated index helper so the
- // spawner is marked dirty and duplicate definitions still delete by position.
- _spawner.RemoveFromTriggerDefinitionsAt(deleteIndex);
- _spawner.EnsureTriggersActive();
+ // triggers is the live list: remove by position so duplicate definitions
+ // still delete the one that was clicked. The wrapper marks the spawner dirty,
+ // drops the definition's runtime state and re-registers.
+ _spawner.RemoveTriggerDefinitionAt(deleteIndex);
from.SendMessage("Trigger removed.");
}
}
diff --git a/Projects/ModernSpawner/Migration/MigrationReport.cs b/Projects/ModernSpawner/Migration/MigrationReport.cs
new file mode 100644
index 0000000..7b60737
--- /dev/null
+++ b/Projects/ModernSpawner/Migration/MigrationReport.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+
+namespace Server.Engines.ModernSpawner.Migration;
+
+///
+/// Aggregates the outcome of an run: how many spawners
+/// were created or failed, plus one advisory line per spawner for anything the migration approximated or
+/// dropped (a time-of-day window that no longer despawns on close, a Duration with no despawn timer
+/// to carry it, an untranslatable PlayerPropertyName, ...).
+///
+public sealed class MigrationReport
+{
+ private readonly List<(string SpawnerName, string Note)> _notes = [];
+
+ /// Spawners successfully created.
+ public int Success { get; private set; }
+
+ /// Nodes that failed to parse or produce a spawner.
+ public int Failed { get; private set; }
+
+ /// One advisory line per spawner; the migration otherwise succeeded for that spawner.
+ public IReadOnlyList<(string SpawnerName, string Note)> Notes => _notes;
+
+ /// Records one successfully created spawner.
+ internal void RecordSuccess() => Success++;
+
+ /// Records one node that failed to parse or produce a spawner.
+ internal void RecordFailure() => Failed++;
+
+ /// Adds one advisory line for .
+ internal void AddNote(string spawnerName, string note) => _notes.Add((spawnerName, note));
+}
diff --git a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs
index 683cb69..27c30f3 100644
--- a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs
+++ b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Xml;
+using Server.Engines.Events;
using Server.Logging;
namespace Server.Engines.ModernSpawner.Migration;
@@ -14,6 +15,12 @@ public static class XmlSpawnerMigrator
{
private static readonly ILogger Logger = LogFactory.GetLogger(typeof(XmlSpawnerMigrator));
+ ///
+ /// Range used for a speech or property trigger when the node carries no ProximityRange of its
+ /// own - the same fallback already used for SkillTrigger.
+ ///
+ private const int DefaultTriggerRange = 10;
+
///
/// Registers the migration commands.
///
@@ -46,19 +53,37 @@ private static void ImportXmlSpawners_OnCommand(CommandEventArgs e)
e.Mobile.SendMessage($"Importing XmlSpawners from {path}...");
- var (success, failed) = ImportFromFile(path);
+ var report = ImportFromFile(path);
+
+ e.Mobile.SendMessage($"Import complete. Created: {report.Success}, Failed: {report.Failed}");
+
+ foreach (var (spawnerName, note) in report.Notes)
+ {
+ var line = $"{spawnerName}: {note}";
+ if (e.Mobile != null)
+ {
+ e.Mobile.SendMessage(line);
+ }
+ else
+ {
+ Logger.Information("{Note}", line);
+ }
+ }
- e.Mobile.SendMessage($"Import complete. Created: {success}, Failed: {failed}");
- Logger.Information("Imported {Success} XmlSpawners, {Failed} failures from {Path}", success, failed, path);
+ Logger.Information(
+ "Imported {Success} XmlSpawners, {Failed} failures from {Path}",
+ report.Success,
+ report.Failed,
+ path
+ );
}
///
/// Imports XmlSpawner data from an XML file.
///
- public static (int success, int failed) ImportFromFile(string path)
+ public static MigrationReport ImportFromFile(string path)
{
- var success = 0;
- var failed = 0;
+ var report = new MigrationReport();
try
{
@@ -73,20 +98,25 @@ public static (int success, int failed) ImportFromFile(string path)
{
try
{
- var spawner = ParseXmlSpawnerNode(node);
+ var notes = new List();
+ var spawner = ParseXmlSpawnerNode(node, notes);
if (spawner != null)
{
- success++;
+ report.RecordSuccess();
+ foreach (var note in notes)
+ {
+ report.AddNote(spawner.Name, note);
+ }
}
else
{
- failed++;
+ report.RecordFailure();
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to parse XmlSpawner node");
- failed++;
+ report.RecordFailure();
}
}
}
@@ -102,17 +132,17 @@ public static (int success, int failed) ImportFromFile(string path)
var spawner = ParseSpawnPointNode(node);
if (spawner != null)
{
- success++;
+ report.RecordSuccess();
}
else
{
- failed++;
+ report.RecordFailure();
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to parse SpawnPoint node");
- failed++;
+ report.RecordFailure();
}
}
}
@@ -122,13 +152,22 @@ public static (int success, int failed) ImportFromFile(string path)
Logger.Error(ex, "Failed to load XML file: {Path}", path);
}
- return (success, failed);
+ return report;
}
///
/// Parses an XmlSpawner node from the save format.
///
- internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node)
+ internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node) => ParseXmlSpawnerNode(node, null);
+
+ ///
+ /// Parses an XmlSpawner node from the save format, collecting one advisory line per approximated or
+ /// dropped attribute into (e.g. a TOD window that D2 no longer despawns on
+ /// close, or a PlayerPropertyName that could not be translated into a when: expression).
+ ///
+ /// The XmlSpawner node.
+ /// Receives report lines for this spawner; pass null to discard them.
+ internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node, List notes)
{
// Parse location
var x = GetIntAttribute(node, "X", 0);
@@ -177,33 +216,99 @@ internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node)
);
}
- // Parse trigger settings
- var proximityRange = GetIntAttribute(node, "ProximityRange", -1);
- if (proximityRange >= 0)
+ // XmlSpawner "group" is respawn-all-when-all-dead; that is base Group, not the AllEntries cycle
+ // mode (design §7).
+ spawner.Group = GetBoolAttribute(node, "IsGroup", false);
+
+ // Refractory lockout: MinRefractory/MaxRefractory are minutes (dev-docs §2/§3).
+ var minRefractory = GetDoubleAttribute(node, "MinRefractory", 0);
+ var maxRefractory = GetDoubleAttribute(node, "MaxRefractory", 0);
+ if (minRefractory > 0 || maxRefractory > 0)
{
- spawner.TriggerActivated = true;
- spawner.AddToTriggerDefinitions($"proximity:{proximityRange}:true:false:5:0");
+ spawner.RefractoryMin = TimeSpan.FromMinutes(minRefractory);
+ spawner.RefractoryMax = TimeSpan.FromMinutes(Math.Max(maxRefractory, minRefractory));
+
+ if (maxRefractory < minRefractory)
+ {
+ // An inverted range is a configuration mistake in the source file, not a shape this
+ // model has an answer for, so the lockout becomes the fixed minimum and the operator
+ // is told which spawner had it.
+ notes?.Add(
+ $"Refractory max ({maxRefractory}m) was below min ({minRefractory}m); the lockout was clamped to a fixed {minRefractory}m."
+ );
+ }
}
+ // SpawnOnTrigger=False defers the accepted event to the next tick (mode:tick) behind a one-slot
+ // queue; SpawnOnTrigger=True or absent reproduces XmlSpawner's own run-now-or-drop semantics
+ // (ruling §13.2).
+ var spawnOnTrigger = GetBoolAttribute(node, "SpawnOnTrigger", true);
+ spawner.MaxPendingCycles = spawnOnTrigger ? 0 : 1;
+
+ // Parse trigger settings. XmlSpawner tests proximity, speech and the player property
+ // conjunctively (dev-docs §8), so a SpeechTrigger folds the proximity range and the property
+ // test into one speech trigger rather than three independent (effectively OR'd) triggers.
+ var proximityRange = GetIntAttribute(node, "ProximityRange", -1);
var speechTrigger = GetAttribute(node, "SpeechTrigger", null);
+ var playerPropertyName = GetAttribute(node, "PlayerPropertyName", null);
+
if (!string.IsNullOrEmpty(speechTrigger))
{
- spawner.TriggerActivated = true;
+ var range = proximityRange >= 0 ? proximityRange : DefaultTriggerRange;
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(speechTrigger));
- spawner.AddToTriggerDefinitions($"speech:{encoded}:true:false:10:true:5");
+ var positional = $"speech:{encoded}:true:false:{range}:true:5";
+
+ spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes));
+ spawner.TriggerActivated = true;
+ }
+ else if (proximityRange >= 0)
+ {
+ var positional = $"proximity:{proximityRange}:true:false:5:0";
+
+ spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes));
+ spawner.TriggerActivated = true;
+ }
+ else if (!string.IsNullOrEmpty(playerPropertyName))
+ {
+ // PlayerPropertyName alone still needs a trigger to hang the when: expression on; XmlSpawner
+ // had no minimum proximity for this case either, so it gets the same default range.
+ var positional = $"proximity:{DefaultTriggerRange}:true:false:5:0";
+
+ spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes));
+ spawner.TriggerActivated = true;
}
var skillTrigger = GetAttribute(node, "SkillTrigger", null);
if (!string.IsNullOrWhiteSpace(skillTrigger))
{
- var definition = MapSkillTrigger(skillTrigger, proximityRange < 0 ? 10 : proximityRange);
- if (definition != null)
+ var positional = MapSkillTrigger(skillTrigger, proximityRange < 0 ? DefaultTriggerRange : proximityRange);
+ if (positional != null)
{
- spawner.AddToTriggerDefinitions(definition);
+ spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, null, notes));
spawner.TriggerActivated = true;
}
}
+ // Time-of-day gate. TODStart/TODEnd are TotalMinutes; TODMode 0 = Realtime (wall clock), 1 =
+ // Gametime (dev-docs §2/§3). XmlSpawner despawned live spawns when the window closed; D2 keeps
+ // them running, since per-spawn lifetimes are tracked separately under D10.
+ var todStart = GetDoubleAttribute(node, "TODStart", -1);
+ var todEnd = GetDoubleAttribute(node, "TODEnd", -1);
+ var todMode = GetIntAttribute(node, "TODMode", 0);
+ if (todStart >= 0 || todEnd >= 0)
+ {
+ AddTimeOfDayGate(spawner, todMode, Math.Max(todStart, 0), Math.Max(todEnd, 0));
+ notes?.Add("XmlSpawner despawned live spawns when the window closed; D2 keeps them (lifetimes are D10).");
+ }
+
+ // Duration is a per-spawn lifetime XmlSpawner enforced; ModernSpawner has no entry despawn timer
+ // yet (D10), so the value is reported and dropped rather than approximated.
+ var duration = GetDoubleAttribute(node, "Duration", -1);
+ if (duration > 0)
+ {
+ notes?.Add($"Duration ({duration} min) is a per-spawn lifetime; ModernSpawner has no entry despawn timer yet (D10) and the value was dropped.");
+ }
+
// Parse spawn objects
var objectsNode = node.SelectSingleNode("SpawnObjects") ?? node.SelectSingleNode("Objects");
if (objectsNode != null)
@@ -426,4 +531,69 @@ private static bool GetBoolAttribute(XmlNode node, string name, bool defaultValu
var value = GetAttribute(node, name, null);
return bool.TryParse(value, out var result) ? result : defaultValue;
}
+
+ private static double GetDoubleAttribute(XmlNode node, string name, double defaultValue)
+ {
+ var value = GetAttribute(node, name, null);
+ return double.TryParse(value, out var result) ? result : defaultValue;
+ }
+
+ ///
+ /// Builds the final trigger definition text from : the shared
+ /// mode:tick token first, then when: last - documents
+ /// when: as consuming everything after it, so a token appended past it would be swallowed into
+ /// the expression source and never parsed, leaving the trigger permanently inert. The single writer
+ /// for every event trigger definition this migrator emits, so the ordering cannot drift between call
+ /// sites.
+ ///
+ /// The trigger's positional argument list, with no tokens yet.
+ /// XmlSpawner's SpawnOnTrigger; false appends mode:tick.
+ /// The raw PlayerPropertyName test, or null/empty for none.
+ /// Receives a report line when the property test cannot be translated.
+ private static string BuildEventDefinition(string positional, bool spawnOnTrigger, string playerPropertyName, List notes)
+ {
+ var definition = spawnOnTrigger ? positional : positional + ":mode:tick";
+
+ if (string.IsNullOrEmpty(playerPropertyName))
+ {
+ return definition;
+ }
+
+ if (XmlSpawnerPropertyExpression.TryTranslate(playerPropertyName, out var expression, out var reason))
+ {
+ return $"{definition}:when:{expression}";
+ }
+
+ notes?.Add(
+ $"PlayerPropertyName '{playerPropertyName}' could not be translated ({reason}); the trigger was migrated without its when: condition."
+ );
+ return definition;
+ }
+
+ ///
+ /// Adds the time-of-day gate matching : 0 (Realtime) is a wall-clock
+ /// window, 1 (Gametime) is an in-game-hour window (dev-docs §2/§3).
+ /// and are TotalMinutes, as XmlSpawner wrote them.
+ ///
+ private static void AddTimeOfDayGate(ModernSpawner spawner, int todMode, double todStartMinutes, double todEndMinutes)
+ {
+ var startHour = (int)(todStartMinutes / 60) % 24;
+ var startMinute = (int)(todStartMinutes % 60);
+ var endHour = (int)(todEndMinutes / 60) % 24;
+ var endMinute = (int)(todEndMinutes % 60);
+
+ if (todMode == 1)
+ {
+ // GameTimeWindowTrigger only carries whole hours; the minute component is dropped.
+ spawner.AddTriggerDefinition($"game_time_window:{startHour}:{endHour}:false:false");
+ }
+ else
+ {
+ spawner.AddTriggerDefinition(
+ $"wall_time_window:{startHour}:{startMinute}:{endHour}:{endMinute}:{(int)AllowedDays.All}:{(int)AllowedMonths.All}:{TimeZoneInfo.Utc.Id}"
+ );
+ }
+
+ spawner.TriggerActivated = true;
+ }
}
diff --git a/Projects/ModernSpawner/Migration/XmlSpawnerPropertyExpression.cs b/Projects/ModernSpawner/Migration/XmlSpawnerPropertyExpression.cs
new file mode 100644
index 0000000..8ccdc10
--- /dev/null
+++ b/Projects/ModernSpawner/Migration/XmlSpawnerPropertyExpression.cs
@@ -0,0 +1,239 @@
+using System;
+using System.Globalization;
+
+namespace Server.Engines.ModernSpawner.Migration;
+
+///
+/// Translates the XmlSpawner property-test grammar (dev-docs/xmlspawner-migration.md §5:
+/// prop op value, ~ for negation, &/| for conjunction/disjunction) into an
+/// expression the ModernSpawner accepts, for use as a
+/// trigger's when: condition. Shared by and
+/// so PlayerPropertyName maps identically on both
+/// ingestion paths.
+///
+///
+/// Only the subset needed to carry a simple property test survives: the property side of prop op
+/// value accepts a bare property name or TRIGMOB.Prop, both resolving to the triggering mobile
+/// (trigMob.Prop in the target grammar, since PlayerPropertyName is always tested against the
+/// triggering player); the value side additionally accepts number, hex, boolean and unquoted-string
+/// literals (XmlSpawner property tests are never quoted). Constructs the target grammar has no equivalent
+/// for - GETONTHIS, GETONMOB, PLAYERSINRANGE, RND, anything else with a comma -
+/// are reported as unsupported rather than guessed at, per the migration requirements ("if a given property
+/// test cannot be represented, emit the trigger without when: and add a report line").
+///
+public static class XmlSpawnerPropertyExpression
+{
+ ///
+ /// Attempts to translate into a when: expression.
+ ///
+ /// The raw XmlSpawner property-test string (e.g. PlayerPropertyName).
+ /// Receives the translated expression on success.
+ /// Receives a human-readable reason on failure.
+ /// True when the whole test string was translated.
+ public static bool TryTranslate(string propertyTest, out string expression, out string reason)
+ {
+ if (string.IsNullOrWhiteSpace(propertyTest))
+ {
+ expression = null;
+ reason = "empty property test";
+ return false;
+ }
+
+ return TryTranslateConjunction(propertyTest.Trim(), out expression, out reason);
+ }
+
+ // A & B | C nests right-recursively - A & (B | C) - matching BaseXmlSpawner.CheckPropertyString's own
+ // parse, which combines the first operator it finds with the recursively-translated remainder.
+ private static bool TryTranslateConjunction(string test, out string expression, out string reason)
+ {
+ var splitIndex = -1;
+ var isAnd = false;
+
+ for (var i = 0; i < test.Length; i++)
+ {
+ if (test[i] is '&' or '|')
+ {
+ splitIndex = i;
+ isAnd = test[i] == '&';
+ break;
+ }
+ }
+
+ if (splitIndex < 0)
+ {
+ return TryTranslateSingle(test, out expression, out reason);
+ }
+
+ if (!TryTranslateSingle(test[..splitIndex], out var left, out reason))
+ {
+ expression = null;
+ return false;
+ }
+
+ if (!TryTranslateConjunction(test[(splitIndex + 1)..], out var right, out reason))
+ {
+ expression = null;
+ return false;
+ }
+
+ expression = $"({left} {(isAnd ? "and" : "or")} {right})";
+ return true;
+ }
+
+ private static bool TryTranslateSingle(string test, out string expression, out string reason)
+ {
+ test = test.Trim();
+
+ var negate = false;
+ if (test.StartsWith('~'))
+ {
+ negate = true;
+ test = test[1..].Trim();
+ }
+
+ if (!TryFindOperator(test, out var opIndex, out var opLength, out var opText))
+ {
+ expression = null;
+ reason = $"unrecognized property test '{test}'";
+ return false;
+ }
+
+ var left = test[..opIndex].Trim();
+ var right = test[(opIndex + opLength)..].Trim();
+
+ // The left side of "prop op value" is always the property under test; the right side is the
+ // value being compared against, literal or another property when TRIGMOB.-qualified.
+ if (!TryTranslateOperand(left, isProperty: true, out var leftExpr, out reason))
+ {
+ expression = null;
+ return false;
+ }
+
+ if (!TryTranslateOperand(right, isProperty: false, out var rightExpr, out reason))
+ {
+ expression = null;
+ return false;
+ }
+
+ var comparison = $"{leftExpr} {opText} {rightExpr}";
+ expression = negate ? $"not ({comparison})" : comparison;
+ reason = null;
+ return true;
+ }
+
+ private static bool TryFindOperator(string test, out int index, out int length, out string opText)
+ {
+ for (var i = 0; i < test.Length; i++)
+ {
+ var c = test[i];
+ if (c is not ('=' or '!' or '<' or '>'))
+ {
+ continue;
+ }
+
+ var hasEquals = i + 1 < test.Length && test[i + 1] == '=';
+ index = i;
+ length = hasEquals ? 2 : 1;
+ opText = c switch
+ {
+ '=' => "==",
+ '!' => "!=",
+ '<' => hasEquals ? "<=" : "<",
+ '>' => hasEquals ? ">=" : ">",
+ _ => null
+ };
+ return opText != null;
+ }
+
+ index = -1;
+ length = 0;
+ opText = null;
+ return false;
+ }
+
+ ///
+ /// Translates one side of a comparison. The property side of prop op value only accepts a
+ /// property reference (bare, or TRIGMOB.-qualified - both resolve to the triggering mobile,
+ /// the only object PlayerPropertyName is ever tested against); the value side also accepts
+ /// literals, including an unquoted string (XmlSpawner property tests are never quoted).
+ ///
+ private static bool TryTranslateOperand(string operand, bool isProperty, out string expression, out string reason)
+ {
+ reason = null;
+
+ if (string.IsNullOrEmpty(operand))
+ {
+ expression = null;
+ reason = "empty operand";
+ return false;
+ }
+
+ if (operand.StartsWith("TRIGMOB.", StringComparison.OrdinalIgnoreCase))
+ {
+ expression = $"trigMob.{operand[8..]}";
+ return true;
+ }
+
+ if (bool.TryParse(operand, out var boolValue))
+ {
+ expression = boolValue ? "true" : "false";
+ return true;
+ }
+
+ if (operand.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && operand.Length > 2)
+ {
+ expression = operand;
+ return true;
+ }
+
+ if (double.TryParse(operand, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
+ {
+ expression = operand;
+ return true;
+ }
+
+ if (isProperty)
+ {
+ if (IsSimplePropertyPath(operand))
+ {
+ expression = $"trigMob.{operand}";
+ return true;
+ }
+
+ expression = null;
+ reason = $"unsupported property reference '{operand}'";
+ return false;
+ }
+
+ // Not a recognised literal and not the property side: a bare word compares as a string, since
+ // XmlSpawner property tests are never quoted - unless it looks like one of the keyword
+ // constructs (GETONTHIS, GETONMOB, PLAYERSINRANGE, RND) this translator does not evaluate.
+ if (operand.Contains(',', StringComparison.Ordinal))
+ {
+ expression = null;
+ reason = $"unsupported construct '{operand}'";
+ return false;
+ }
+
+ expression = $"\"{operand.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
+ return true;
+ }
+
+ private static bool IsSimplePropertyPath(string value)
+ {
+ if (value.Length == 0 || !(char.IsLetter(value[0]) || value[0] == '_'))
+ {
+ return false;
+ }
+
+ foreach (var c in value)
+ {
+ if (!(char.IsLetterOrDigit(c) || c is '_' or '.'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawner.v0.json b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawner.v0.json
new file mode 100644
index 0000000..b3dfa39
--- /dev/null
+++ b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawner.v0.json
@@ -0,0 +1,140 @@
+{
+ "version": 0,
+ "type": "Server.Engines.ModernSpawner.ModernSpawner",
+ "properties": [
+ {
+ "name": "SpawnEntries",
+ "type": "System.Collections.Generic.List\u003CServer.Engines.ModernSpawner.ModernSpawnerEntry\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "Server.Engines.ModernSpawner.ModernSpawnerEntry",
+ "RawSerializableMigrationRule",
+ "DeserializationRequiresParent"
+ ]
+ },
+ {
+ "name": "OnActivateScriptSerial",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ },
+ {
+ "name": "OnDeactivateScriptSerial",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ },
+ {
+ "name": "OnBeforeSpawnScriptSerial",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ },
+ {
+ "name": "OnAfterSpawnScriptSerial",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ },
+ {
+ "name": "UseSmartPositioning",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "ReturnToSpawnOnIdle",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "MaxZDelta",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "TriggerDefinitions",
+ "type": "System.Collections.Generic.List\u003Cstring\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "string",
+ "PrimitiveTypeMigrationRule",
+ ""
+ ]
+ },
+ {
+ "name": "TriggerActivated",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Triggered",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Notes",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "CycleMode",
+ "type": "Server.Engines.ModernSpawner.SpawnCycleMode",
+ "rule": "EnumMigrationRule"
+ },
+ {
+ "name": "CurrentSubgroup",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "SequentialResetTime",
+ "type": "System.TimeSpan",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "SequentialResetTo",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "HoldSequence",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawner.v1.json b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawner.v1.json
new file mode 100644
index 0000000..dcdc867
--- /dev/null
+++ b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawner.v1.json
@@ -0,0 +1,179 @@
+{
+ "version": 1,
+ "type": "Server.Engines.ModernSpawner.ModernSpawner",
+ "properties": [
+ {
+ "name": "SpawnEntries",
+ "type": "System.Collections.Generic.List\u003CServer.Engines.ModernSpawner.ModernSpawnerEntry\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "Server.Engines.ModernSpawner.ModernSpawnerEntry",
+ "RawSerializableMigrationRule",
+ "DeserializationRequiresParent"
+ ]
+ },
+ {
+ "name": "OnActivateScriptSerial",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ },
+ {
+ "name": "OnDeactivateScriptSerial",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ },
+ {
+ "name": "OnBeforeSpawnScriptSerial",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ },
+ {
+ "name": "OnAfterSpawnScriptSerial",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ },
+ {
+ "name": "UseSmartPositioning",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "ReturnToSpawnOnIdle",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "MaxZDelta",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "TriggerDefs",
+ "type": "System.Collections.Generic.List\u003CServer.Engines.ModernSpawner.Triggers.TriggerDefinition\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "Server.Engines.ModernSpawner.Triggers.TriggerDefinition",
+ "RawSerializableMigrationRule",
+ "DeserializationRequiresParent"
+ ]
+ },
+ {
+ "name": "TriggerActivated",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Notes",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "CycleMode",
+ "type": "Server.Engines.ModernSpawner.SpawnCycleMode",
+ "rule": "EnumMigrationRule"
+ },
+ {
+ "name": "CurrentSubgroup",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "SequentialResetTime",
+ "type": "System.TimeSpan",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "SequentialResetTo",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "HoldSequence",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "PendingSlots",
+ "type": "System.Collections.Generic.List\u003CServer.Engines.ModernSpawner.Triggers.PendingCycle\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "Server.Engines.ModernSpawner.Triggers.PendingCycle",
+ "RawSerializableMigrationRule",
+ "DeserializationRequiresParent"
+ ]
+ },
+ {
+ "name": "MaxPendingCycles",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "RefractoryMin",
+ "type": "System.TimeSpan",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "RefractoryMax",
+ "type": "System.TimeSpan",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "RefractoryUntil",
+ "type": "System.DateTime",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "TriggerStateList",
+ "type": "System.Collections.Generic.List\u003CServer.Engines.ModernSpawner.Triggers.TriggerRuntimeState\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "Server.Engines.ModernSpawner.Triggers.TriggerRuntimeState",
+ "RawSerializableMigrationRule",
+ "DeserializationRequiresParent"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawnerEntry.v0.json b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawnerEntry.v0.json
new file mode 100644
index 0000000..f5bb2d5
--- /dev/null
+++ b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawnerEntry.v0.json
@@ -0,0 +1,88 @@
+{
+ "version": 0,
+ "type": "Server.Engines.ModernSpawner.ModernSpawnerEntry",
+ "properties": [
+ {
+ "name": "OnSpawnScript",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "OnDespawnScript",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "MinDelay",
+ "type": "System.TimeSpan",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "MaxDelay",
+ "type": "System.TimeSpan",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "PositioningRule",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "SpawnGroup",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "RequireLOS",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "SpawnAreaOffset",
+ "type": "Server.Point3D",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Point3D"
+ ]
+ },
+ {
+ "name": "SpawnRange",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "LootTemplate",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Subgroup",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawnerEntry.v1.json b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawnerEntry.v1.json
new file mode 100644
index 0000000..d194368
--- /dev/null
+++ b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawnerEntry.v1.json
@@ -0,0 +1,97 @@
+{
+ "version": 1,
+ "type": "Server.Engines.ModernSpawner.ModernSpawnerEntry",
+ "properties": [
+ {
+ "name": "OnSpawnScript",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "OnDespawnScript",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "MinDelay",
+ "type": "System.TimeSpan",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "MaxDelay",
+ "type": "System.TimeSpan",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "PositioningRule",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "SpawnGroup",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "RequireLOS",
+ "type": "bool",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "SpawnAreaOffset",
+ "type": "Server.Point3D",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Point3D"
+ ]
+ },
+ {
+ "name": "SpawnRange",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "LootTemplate",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "Subgroup",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "NextEligible",
+ "type": "System.DateTime",
+ "usesSaveFlag": true,
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.PendingCycle.v0.json b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.PendingCycle.v0.json
new file mode 100644
index 0000000..344fdbe
--- /dev/null
+++ b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.PendingCycle.v0.json
@@ -0,0 +1,19 @@
+{
+ "version": 0,
+ "type": "Server.Engines.ModernSpawner.Triggers.PendingCycle",
+ "properties": [
+ {
+ "name": "TriggerId",
+ "type": "System.Guid",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "TriggeringMobile",
+ "type": "Server.Serial",
+ "rule": "PrimitiveUOTypeMigrationRule",
+ "ruleArguments": [
+ "Serial"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.TriggerDefinition.v0.json b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.TriggerDefinition.v0.json
new file mode 100644
index 0000000..072c93a
--- /dev/null
+++ b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.TriggerDefinition.v0.json
@@ -0,0 +1,19 @@
+{
+ "version": 0,
+ "type": "Server.Engines.ModernSpawner.Triggers.TriggerDefinition",
+ "properties": [
+ {
+ "name": "Id",
+ "type": "System.Guid",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "Text",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.TriggerRuntimeState.v0.json b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.TriggerRuntimeState.v0.json
new file mode 100644
index 0000000..d8a3692
--- /dev/null
+++ b/Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.TriggerRuntimeState.v0.json
@@ -0,0 +1,27 @@
+{
+ "version": 0,
+ "type": "Server.Engines.ModernSpawner.Triggers.TriggerRuntimeState",
+ "properties": [
+ {
+ "name": "Id",
+ "type": "System.Guid",
+ "rule": "PrimitiveTypeMigrationRule"
+ },
+ {
+ "name": "CooldownUntil",
+ "type": "System.DateTime",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "KillCount",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/ModernSpawner/Perf/SpawnerMetrics.cs b/Projects/ModernSpawner/Perf/SpawnerMetrics.cs
index 2ecb66b..fe30ced 100644
--- a/Projects/ModernSpawner/Perf/SpawnerMetrics.cs
+++ b/Projects/ModernSpawner/Perf/SpawnerMetrics.cs
@@ -51,6 +51,9 @@ public static class SpawnerMetrics
private static long _proximityDispatchCalls;
private static long _proximityDispatchTicks;
+ private static long _tickCalls;
+ private static long _tickTicks;
+
private static long _entitiesSpawned;
///
@@ -83,6 +86,13 @@ public static SpawnerMetricsScope MeasureEntrySelection() =>
public static SpawnerMetricsScope MeasureProximityDispatch() =>
new(Enabled, ref _proximityDispatchTicks, ref _proximityDispatchCalls);
+ ///
+ /// Opens a measurement scope for , covering the whole D2 tick
+ /// precedence including the rows that park without running a cycle.
+ ///
+ public static SpawnerMetricsScope MeasureTick() =>
+ new(Enabled, ref _tickTicks, ref _tickCalls);
+
///
/// Records a successful entity spawn. Not timed — cheap counter only.
///
@@ -123,6 +133,10 @@ public readonly struct Snapshot
public double ProximityDispatchAvgUs =>
ProximityDispatchCalls == 0 ? 0 : ProximityDispatchTotalUs / ProximityDispatchCalls;
+ public long TickCalls { get; init; }
+ public double TickTotalUs { get; init; }
+ public double TickAvgUs => TickCalls == 0 ? 0 : TickTotalUs / TickCalls;
+
public long EntitiesSpawned { get; init; }
}
@@ -139,6 +153,8 @@ public static Snapshot Capture() =>
SelectTotalUs = Volatile.Read(ref _selectTicks) / TicksPerMicrosecond,
ProximityDispatchCalls = Volatile.Read(ref _proximityDispatchCalls),
ProximityDispatchTotalUs = Volatile.Read(ref _proximityDispatchTicks) / TicksPerMicrosecond,
+ TickCalls = Volatile.Read(ref _tickCalls),
+ TickTotalUs = Volatile.Read(ref _tickTicks) / TicksPerMicrosecond,
EntitiesSpawned = Volatile.Read(ref _entitiesSpawned)
};
@@ -154,6 +170,8 @@ public static void Reset()
Interlocked.Exchange(ref _selectTicks, 0);
Interlocked.Exchange(ref _proximityDispatchCalls, 0);
Interlocked.Exchange(ref _proximityDispatchTicks, 0);
+ Interlocked.Exchange(ref _tickCalls, 0);
+ Interlocked.Exchange(ref _tickTicks, 0);
Interlocked.Exchange(ref _entitiesSpawned, 0);
}
}
diff --git a/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs b/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs
index 236bc66..6fcb6b1 100644
--- a/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs
+++ b/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs
@@ -18,7 +18,7 @@ namespace Server.Engines.ModernSpawner.Perf;
///
///
-/// Admin commands for driving ModernSpawner perf scenarios.
+/// Admin commands for driving ModernSpawner perf scenarios on a live shard.
///
/// Typical run:
/// [ModernSpawnerPerfSeed 10000 - create 10k spawners in a grid around me
@@ -28,6 +28,19 @@ namespace Server.Engines.ModernSpawner.Perf;
/// [ModernSpawnerPerfStop - disable counters
/// [ModernSpawnerPerfClear - delete the seed spawners
///
+/// The D2 merge gate does not run here. Walking a player around a live shard is not reproducible and
+/// needs a shard to walk on, so the 12k movement-dispatch and tick measurement lives in the test
+/// project instead, as ModernSpawner.Tests/Perf/TriggerPerfHarness.cs: it seeds the same grid
+/// through , walks a placed player along a fixed 2000-step lap dispatching
+/// OnMovement to every spawner inside of each step,
+/// ticks all 12k with the gate closed and again with it open, and writes perf-results.json
+/// next to the test binaries. Run it with:
+///
+/// MODERNSPAWNER_PERF=1 dotnet test Projects/ModernSpawner.Tests --filter "FullyQualifiedName~TriggerPerfHarness"
+///
+/// These commands remain the way to measure what a harness cannot reach: the real spawn burst,
+/// sustained churn, and defrag under a live world.
+///
/// See Docs/Perf-Runbook.md for the canonical scenario steps.
///
public static class SpawnerPerfCommands
@@ -89,13 +102,68 @@ private static void PerfSeed_OnCommand(CommandEventArgs e)
return;
}
- var gridSide = (int)Math.Ceiling(Math.Sqrt(count));
var origin = e.Mobile.Location;
- var created = 0;
+ var gridSide = GridSide(count);
+
+ // A proximity trigger so player sweeps exercise the dispatch path.
+ var created = SeedGrid(map, origin, count, spacing, _seeded, "proximity:8:true");
+
+ e.Mobile.SendMessage($"Seeded {created} ModernSpawner instances in a {gridSide}x{gridSide} grid at spacing {spacing}.");
+ Logger.Information("Perf seed: created {Count} spawners at {Location} on {Map}", created, origin, map);
+ }
+
+ ///
+ /// The side of the square grid lays spawners out in.
+ ///
+ /// How many spawners the grid has to hold.
+ /// The number of rows, which is also the number of columns.
+ internal static int GridSide(int count) => count <= 0 ? 0 : (int)Math.Ceiling(Math.Sqrt(count));
+
+ ///
+ /// Creates disposable spawners in a square grid, each with one Rabbit
+ /// entry capped at a single spawn and the supplied trigger definitions activated. Shared by
+ /// [ModernSpawnerPerfSeed and the test project 12k trigger harness so both measure the same
+ /// population.
+ ///
+ ///
+ /// The layout is a contract rather than an implementation detail: the spawner appended at offset
+ /// k of sits at
+ /// (origin.X + k / GridSide(count) * spacing, origin.Y + k % GridSide(count) * spacing).
+ /// A caller that has to know which spawners are near a point - the question the engine sector
+ /// dispatch answers on a live shard - can index straight into the list instead of scanning the
+ /// whole population.
+ ///
+ /// The definitions are added before TriggerActivated is set, because that setter registers
+ /// whatever is in the list at that moment.
+ ///
+ ///
+ /// The map to place them on. The internal map is refused.
+ /// The north-west corner of the grid.
+ /// How many spawners to create.
+ /// Tiles between neighbouring spawners on both axes.
+ /// Receives every spawner created, in grid order.
+ /// Definition texts added to each spawner, in order.
+ /// How many spawners were created.
+ internal static int SeedGrid(
+ Map map,
+ Point3D origin,
+ int count,
+ int spacing,
+ List created,
+ params ReadOnlySpan triggerDefinitions
+ )
+ {
+ if (map == null || map == Map.Internal || count <= 0 || spacing <= 0 || created == null)
+ {
+ return 0;
+ }
+
+ var gridSide = GridSide(count);
+ var seeded = 0;
- for (var i = 0; i < gridSide && created < count; i++)
+ for (var i = 0; i < gridSide && seeded < count; i++)
{
- for (var j = 0; j < gridSide && created < count; j++)
+ for (var j = 0; j < gridSide && seeded < count; j++)
{
var location = new Point3D(
origin.X + i * spacing,
@@ -104,7 +172,7 @@ private static void PerfSeed_OnCommand(CommandEventArgs e)
var spawner = new ModernSpawner
{
- Name = $"perfseed-{created}",
+ Name = $"perfseed-{seeded}",
MinDelay = TimeSpan.FromMinutes(5),
MaxDelay = TimeSpan.FromMinutes(10),
HomeRange = 4,
@@ -118,18 +186,22 @@ private static void PerfSeed_OnCommand(CommandEventArgs e)
maxCount: 1,
dotimer: false);
- // Add a proximity trigger so player sweeps exercise the dispatch path. The flag is set
- // after the definition exists: its setter registers whatever is in the list at that moment.
- spawner.AddToTriggerDefinitions("proximity:8:true");
- spawner.TriggerActivated = true;
+ for (var d = 0; d < triggerDefinitions.Length; d++)
+ {
+ spawner.AddTriggerDefinition(triggerDefinitions[d]);
+ }
+
+ if (triggerDefinitions.Length > 0)
+ {
+ spawner.TriggerActivated = true;
+ }
- _seeded.Add(spawner);
- created++;
+ created.Add(spawner);
+ seeded++;
}
}
- e.Mobile.SendMessage($"Seeded {created} ModernSpawner instances in a {gridSide}x{gridSide} grid at spacing {spacing}.");
- Logger.Information("Perf seed: created {Count} spawners at {Location} on {Map}", created, origin, map);
+ return seeded;
}
[Usage("ModernSpawnerPerfClear")]
@@ -304,18 +376,20 @@ private static void PerfDump_OnCommand(CommandEventArgs e)
e.Mobile.SendMessage($"Defrag(): {snapshot.DefragCalls,8} calls, {snapshot.DefragTotalUs,10:F1} us total, {snapshot.DefragAvgUs,8:F2} us/call");
e.Mobile.SendMessage($"Entry selection: {snapshot.SelectCalls,8} calls, {snapshot.SelectTotalUs,10:F1} us total, {snapshot.SelectAvgUs,8:F2} us/call");
e.Mobile.SendMessage($"Proximity disp: {snapshot.ProximityDispatchCalls,8} calls, {snapshot.ProximityDispatchTotalUs,10:F1} us total, {snapshot.ProximityDispatchAvgUs,8:F2} us/call");
+ e.Mobile.SendMessage($"OnTick(): {snapshot.TickCalls,8} calls, {snapshot.TickTotalUs,10:F1} us total, {snapshot.TickAvgUs,8:F2} us/call");
e.Mobile.SendMessage($"Entities spawned: {snapshot.EntitiesSpawned}");
Logger.Information(
"SpawnerMetrics snapshot (enabled={Enabled}): Spawn {SpawnCalls}/{SpawnAvgUs:F2}us, FromEntry {FromEntryCalls}/{FromEntryAvgUs:F2}us, " +
"Defrag {DefragCalls}/{DefragAvgUs:F2}us, Select {SelectCalls}/{SelectAvgUs:F2}us, " +
- "Proximity {ProxCalls}/{ProxAvgUs:F2}us, Entities {EntitiesSpawned}",
+ "Proximity {ProxCalls}/{ProxAvgUs:F2}us, Tick {TickCalls}/{TickAvgUs:F2}us, Entities {EntitiesSpawned}",
SpawnerMetrics.Enabled,
snapshot.SpawnCalls, snapshot.SpawnAvgUs,
snapshot.SpawnFromEntryCalls, snapshot.SpawnFromEntryAvgUs,
snapshot.DefragCalls, snapshot.DefragAvgUs,
snapshot.SelectCalls, snapshot.SelectAvgUs,
snapshot.ProximityDispatchCalls, snapshot.ProximityDispatchAvgUs,
+ snapshot.TickCalls, snapshot.TickAvgUs,
snapshot.EntitiesSpawned);
// Also emit a self-diagnosis hint if the user called PerfDump without PerfStart —
diff --git a/Projects/ModernSpawner/Scripting/Expressions/ExpressionEngine.cs b/Projects/ModernSpawner/Scripting/Expressions/ExpressionEngine.cs
index 19e4d6a..0cdea73 100644
--- a/Projects/ModernSpawner/Scripting/Expressions/ExpressionEngine.cs
+++ b/Projects/ModernSpawner/Scripting/Expressions/ExpressionEngine.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using Server.Logging;
namespace Server.Engines.ModernSpawner.Scripting.Expressions;
@@ -8,6 +9,8 @@ namespace Server.Engines.ModernSpawner.Scripting.Expressions;
///
public class ExpressionEngine
{
+ private static readonly ILogger Logger = LogFactory.GetLogger(typeof(ExpressionEngine));
+
///
/// Singleton instance for convenience.
///
@@ -64,7 +67,7 @@ public object Evaluate(CompiledExpression expression, ScriptContext context)
}
catch (Exception ex)
{
- Console.WriteLine($"Expression evaluation error: {ex.Message}");
+ ReportEvaluationError(expression, ex);
return null;
}
}
@@ -94,7 +97,7 @@ public bool EvaluateBoolean(CompiledExpression expression, ScriptContext context
}
catch (Exception ex)
{
- Console.WriteLine($"Expression evaluation error: {ex.Message}");
+ ReportEvaluationError(expression, ex);
return false;
}
}
@@ -124,11 +127,33 @@ public double EvaluateNumber(CompiledExpression expression, ScriptContext contex
}
catch (Exception ex)
{
- Console.WriteLine($"Expression evaluation error: {ex.Message}");
+ ReportEvaluationError(expression, ex);
return 0;
}
}
+ ///
+ /// Reports an expression that threw while being evaluated, once per compiled expression.
+ ///
+ ///
+ /// A when: condition is evaluated on the movement and speech paths, so an expression that
+ /// throws - a property that does not exist on the mobile that happened to walk past, say - would
+ /// otherwise log on every event. The compiled expression remembers that it has been reported, so
+ /// the shard gets one line per broken expression rather than one per step.
+ ///
+ /// The expression that threw.
+ /// What it threw.
+ private static void ReportEvaluationError(CompiledExpression expression, Exception ex)
+ {
+ if (expression == null || expression.HasReportedEvaluationError)
+ {
+ return;
+ }
+
+ expression.HasReportedEvaluationError = true;
+ Logger.Warning(ex, "Expression {Expression} threw while being evaluated.", expression.Source);
+ }
+
///
/// Clears the expression cache.
///
@@ -190,6 +215,12 @@ public class CompiledExpression
///
public bool IsValid => Root != null && Errors.Length == 0;
+ ///
+ /// Whether a failed evaluation of this expression has already been logged. Compiled expressions
+ /// are cached and shared, so this turns a per-event log into a per-expression one.
+ ///
+ internal bool HasReportedEvaluationError { get; set; }
+
///
/// An empty expression that evaluates to null.
///
diff --git a/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs b/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs
index 214d370..de03f65 100644
--- a/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs
+++ b/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs
@@ -244,7 +244,7 @@ private static List ExportTriggers(ModernSpawner spawner)
var triggers = new List();
foreach (var definition in triggerDefs)
{
- var triggerData = ParseTriggerDefinition(definition);
+ var triggerData = ParseTriggerDefinition(definition.Text);
if (triggerData != null)
{
triggers.Add(triggerData);
diff --git a/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs b/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs
index 22ea7f5..09552fa 100644
--- a/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs
+++ b/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
+using Server.Engines.ModernSpawner.Triggers;
using Server.Json;
namespace Server.Engines.ModernSpawner.Serialization;
@@ -202,12 +203,9 @@ public static void ConfigureSpawner(ModernSpawner spawner, SpawnerExportData dat
// Import triggers (clears existing)
if (data.Triggers != null)
{
- // Through the generated helper so the spawner is marked dirty; it dereferences the list,
- // so the null check stays.
- if (spawner.TriggerDefinitions != null)
- {
- spawner.ClearTriggerDefinitions();
- }
+ // Through the wrapper so the spawner is marked dirty and the runtime state of the
+ // definitions being replaced goes with them.
+ spawner.ClearTriggerDefinitions();
ImportTriggers(spawner, data.Triggers);
}
@@ -303,7 +301,7 @@ private static void ImportTriggers(ModernSpawner spawner, List trig
var definition = BuildTriggerDefinition(trigger);
if (!string.IsNullOrEmpty(definition))
{
- spawner.AddToTriggerDefinitions(definition);
+ spawner.AddTriggerDefinition(definition);
}
}
@@ -337,7 +335,16 @@ private static string BuildTriggerDefinition(TriggerData trigger)
}
if (typeSpan.InsensitiveEquals("timeofday"))
{
- return $"timeofday:{trigger.StartHour}:{trigger.EndHour}";
+ // "timeofday" is retired: it maps onto the game-time window through the one helper that
+ // knows the legacy inclusive-end and wrap-means-whole-day rules.
+ GameTimeWindowTrigger.MapLegacyTimeOfDayHours(
+ trigger.StartHour,
+ trigger.EndHour,
+ out var startHour,
+ out var endHour
+ );
+
+ return $"game_time_window:{startHour}:{endHour}:{trigger.NightOnly}:{trigger.DayOnly}";
}
if (typeSpan.InsensitiveEquals("game_time_window"))
{
diff --git a/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs
index 1d4bd8c..5749b6b 100644
--- a/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs
+++ b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs
@@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.IO;
using System.Xml;
+using Server.Engines.Events;
+using Server.Engines.ModernSpawner.Migration;
using Server.Logging;
namespace Server.Engines.ModernSpawner.Serialization;
@@ -14,9 +16,18 @@ public static class XmlSpawnerImporter
private static readonly ILogger Logger = LogFactory.GetLogger(typeof(XmlSpawnerImporter));
///
- /// Import results from an XML file.
+ /// Range used for a speech or property trigger when the point carries no ProximityRange of
+ /// its own.
///
- public record ImportResult(int Imported, int Failed, List Errors);
+ private const int DefaultTriggerRange = 10;
+
+ ///
+ /// Import results from an XML file. carries one advisory line per spawner
+ /// for anything the import approximated or dropped (a TOD window that no longer despawns on close, a
+ /// Duration with no despawn timer to carry it, an untranslatable PlayerPropertyName,
+ /// ...), the same mechanism already uses rather than a second report type.
+ ///
+ public record ImportResult(int Imported, int Failed, List Errors, List Notes);
///
/// Imports XmlSpawners from an XML file and creates ModernSpawner instances.
@@ -26,7 +37,7 @@ public static ImportResult ImportFromFile(string filePath, bool respawn = true)
{
if (!File.Exists(filePath))
{
- return new ImportResult(0, 0, [$"File not found: {filePath}"]);
+ return new ImportResult(0, 0, [$"File not found: {filePath}"], []);
}
var doc = new XmlDocument();
@@ -36,10 +47,11 @@ public static ImportResult ImportFromFile(string filePath, bool respawn = true)
}
catch (Exception ex)
{
- return new ImportResult(0, 0, [$"Failed to load XML: {ex.Message}"]);
+ return new ImportResult(0, 0, [$"Failed to load XML: {ex.Message}"], []);
}
var errors = new List();
+ var notes = new List();
var imported = 0;
var failed = 0;
@@ -52,7 +64,8 @@ public static ImportResult ImportFromFile(string filePath, bool respawn = true)
{
try
{
- var spawner = ImportXmlSpawnerPoint(point);
+ var pointNotes = new List();
+ var spawner = ImportXmlSpawnerPoint(point, pointNotes);
if (spawner != null)
{
if (respawn)
@@ -60,6 +73,10 @@ public static ImportResult ImportFromFile(string filePath, bool respawn = true)
spawner.Respawn();
}
imported++;
+ foreach (var note in pointNotes)
+ {
+ notes.Add($"{spawner.Name}: {note}");
+ }
}
else
{
@@ -74,7 +91,7 @@ public static ImportResult ImportFromFile(string filePath, bool respawn = true)
}
}
- return new ImportResult(imported, failed, errors);
+ return new ImportResult(imported, failed, errors, notes);
}
// Try Sno's export format (spawners/spawner)
@@ -108,16 +125,18 @@ public static ImportResult ImportFromFile(string filePath, bool respawn = true)
}
}
- return new ImportResult(imported, failed, errors);
+ return new ImportResult(imported, failed, errors, notes);
}
- return new ImportResult(0, 0, ["Unrecognized XML format. Expected or root element."]);
+ return new ImportResult(0, 0, ["Unrecognized XML format. Expected or root element."], []);
}
///
/// Imports a ServUO XmlSpawner Point element.
///
- private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point)
+ /// The Point element.
+ /// Receives one advisory line per approximated or dropped attribute.
+ private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point, List notes)
{
// Parse location
var centreX = int.Parse(GetText(point["CentreX"], "0"));
@@ -168,16 +187,31 @@ private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point)
var sequentialSpawn = int.Parse(GetText(point["SequentialSpawn"], "-1"));
var holdSequence = bool.Parse(GetText(point["HoldSequence"], "False"));
- // Parse proximity trigger
+ // Parse proximity/speech/property triggers
var proximityRange = int.Parse(GetText(point["ProximityRange"], "-1"));
-
- // Parse time of day
- var todStart = double.Parse(GetText(point["TODStart"], "0"));
- var todEnd = double.Parse(GetText(point["TODEnd"], "0"));
+ var speechTrigger = GetText(point["SpeechTrigger"], null);
+ var playerPropertyName = GetText(point["PlayerPropertyName"], null);
+
+ // Refractory lockout: MinRefractory/MaxRefractory are minutes (dev-docs §2/§3).
+ var minRefractory = double.Parse(GetText(point["MinRefractory"], "0"));
+ var maxRefractory = double.Parse(GetText(point["MaxRefractory"], "0"));
+
+ // SpawnOnTrigger=False defers the accepted event to the next tick (mode:tick) behind a one-slot
+ // queue; SpawnOnTrigger=True or absent reproduces XmlSpawner's own run-now-or-drop semantics
+ // (ruling §13.2).
+ var spawnOnTrigger = bool.Parse(GetText(point["SpawnOnTrigger"], "True"));
+
+ // Parse time of day. TODStart/TODEnd are TotalMinutes; TODMode 0 = Realtime (wall clock), 1 =
+ // Gametime (dev-docs §2/§3).
+ var todStart = double.Parse(GetText(point["TODStart"], "-1"));
+ var todEnd = double.Parse(GetText(point["TODEnd"], "-1"));
var todMode = int.Parse(GetText(point["TODMode"], "0"));
- // Map legacy flag combinations onto SpawnCycleMode.
- var cycleMode = MapLegacyCycleMode(isGroup, sequentialSpawn);
+ var duration = double.Parse(GetText(point["Duration"], "-1"));
+
+ // IsGroup maps to base Group only, never to the AllEntries cycle mode (design §7); only
+ // SequentialSpawn drives the cycle mode now.
+ var cycleMode = MapLegacyCycleMode(sequentialSpawn);
// Create spawner
var spawner = new ModernSpawner
@@ -191,9 +225,16 @@ private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point)
UseSmartPositioning = smartSpawning,
CycleMode = cycleMode,
CurrentSubgroup = sequentialSpawn > 0 ? sequentialSpawn : 0,
- HoldSequence = holdSequence
+ HoldSequence = holdSequence,
+ MaxPendingCycles = spawnOnTrigger ? 0 : 1
};
+ if (minRefractory > 0 || maxRefractory > 0)
+ {
+ spawner.RefractoryMin = TimeSpan.FromMinutes(minRefractory);
+ spawner.RefractoryMax = TimeSpan.FromMinutes(Math.Max(maxRefractory, minRefractory));
+ }
+
spawner.MoveToWorld(location, map);
// Set spawn bounds
@@ -213,22 +254,48 @@ private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point)
ParseObjects2(spawner, objects2, maxCount);
}
- // Add proximity trigger if specified
- if (proximityRange > 0)
+ // XmlSpawner tests proximity, speech and the player property conjunctively (dev-docs §8), so a
+ // SpeechTrigger folds the proximity range and the property test into one speech trigger rather
+ // than independent (effectively OR'd) triggers.
+ if (!string.IsNullOrEmpty(speechTrigger))
{
- spawner.AddToTriggerDefinitions($"proximity:{proximityRange}:true");
+ var trigRange = proximityRange >= 0 ? proximityRange : DefaultTriggerRange;
+ var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(speechTrigger));
+ var positional = $"speech:{encoded}:true:false:{trigRange}:true:5";
+
+ spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes));
spawner.TriggerActivated = true;
}
+ else if (proximityRange >= 0)
+ {
+ var positional = $"proximity:{proximityRange}:true:false:5:0";
- // Add time of day trigger if specified
- if (todMode > 0 && (todStart > 0 || todEnd > 0))
+ spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes));
+ spawner.TriggerActivated = true;
+ }
+ else if (!string.IsNullOrEmpty(playerPropertyName))
{
- var startHour = (int)todStart;
- var endHour = (int)todEnd;
- spawner.AddToTriggerDefinitions($"game_time_window:{startHour}:{endHour}:false:false");
+ var positional = $"proximity:{DefaultTriggerRange}:true:false:5:0";
+
+ spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes));
spawner.TriggerActivated = true;
}
+ // Time-of-day gate. XmlSpawner despawned live spawns when the window closed; D2 keeps them
+ // running, since per-spawn lifetimes are tracked separately under D10.
+ if (todStart >= 0 || todEnd >= 0)
+ {
+ AddTimeOfDayGate(spawner, todMode, Math.Max(todStart, 0), Math.Max(todEnd, 0));
+ notes?.Add("XmlSpawner despawned live spawns when the window closed; D2 keeps them (lifetimes are D10).");
+ }
+
+ // Duration is a per-spawn lifetime XmlSpawner enforced; ModernSpawner has no entry despawn timer
+ // yet (D10), so the value is reported and dropped rather than approximated.
+ if (duration > 0)
+ {
+ notes?.Add($"Duration ({duration} min) is a per-spawn lifetime; ModernSpawner has no entry despawn timer yet (D10) and the value was dropped.");
+ }
+
// The spawner was constructed running, so OnStarted never ran for these definitions.
spawner.EnsureTriggersActive();
@@ -240,24 +307,17 @@ private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point)
/// Format: TypeName:MX=maxcount:SB=subgroup:SP=probability:DN=mindelay:DX=maxdelay:OBJ=NextType...
///
///
- /// Maps the legacy IsGroup / SequentialSpawn flag pair onto a
- /// . Public so it can be used by other importers
- /// and exercised directly by unit tests.
+ /// Maps the legacy SequentialSpawn flag onto a . Public so it can
+ /// be used by other importers and exercised directly by unit tests.
///
- public static SpawnCycleMode MapLegacyCycleMode(bool isGroup, int sequentialSpawn)
- {
- if (isGroup)
- {
- return SpawnCycleMode.Group;
- }
-
- if (sequentialSpawn >= 0)
- {
- return SpawnCycleMode.Sequential;
- }
-
- return SpawnCycleMode.Random;
- }
+ ///
+ /// IsGroup used to force here, but XmlSpawner's
+ /// "group" is respawn-all-when-all-dead - base - not the
+ /// per-cycle-per-entry AllEntries mode (design §7); it is mapped there instead, and no longer
+ /// affects the cycle mode at all.
+ ///
+ public static SpawnCycleMode MapLegacyCycleMode(int sequentialSpawn) =>
+ sequentialSpawn >= 0 ? SpawnCycleMode.Sequential : SpawnCycleMode.Random;
private static void ParseObjects2(ModernSpawner spawner, string objects2, int defaultMaxCount)
{
@@ -478,4 +538,63 @@ private static string GetText(XmlElement node, string defaultValue)
{
return node?.InnerText ?? defaultValue;
}
+
+ ///
+ /// Builds the final trigger definition text from : the shared
+ /// mode:tick token first, then when: last -
+ /// documents when: as consuming everything after it, so a token appended past it would be
+ /// swallowed into the expression source and never parsed, leaving the trigger permanently inert. The
+ /// single writer for every event trigger definition this importer emits, so the ordering cannot drift
+ /// between call sites.
+ ///
+ /// The trigger's positional argument list, with no tokens yet.
+ /// XmlSpawner's SpawnOnTrigger; false appends mode:tick.
+ /// The raw PlayerPropertyName test, or null/empty for none.
+ /// Receives a report line when the property test cannot be translated.
+ private static string BuildEventDefinition(string positional, bool spawnOnTrigger, string playerPropertyName, List notes)
+ {
+ var definition = spawnOnTrigger ? positional : positional + ":mode:tick";
+
+ if (string.IsNullOrEmpty(playerPropertyName))
+ {
+ return definition;
+ }
+
+ if (XmlSpawnerPropertyExpression.TryTranslate(playerPropertyName, out var expression, out var reason))
+ {
+ return $"{definition}:when:{expression}";
+ }
+
+ notes?.Add(
+ $"PlayerPropertyName '{playerPropertyName}' could not be translated ({reason}); the trigger was migrated without its when: condition."
+ );
+ return definition;
+ }
+
+ ///
+ /// Adds the time-of-day gate matching : 0 (Realtime) is a wall-clock
+ /// window, 1 (Gametime) is an in-game-hour window (dev-docs §2/§3).
+ /// and are TotalMinutes, as XmlSpawner wrote them.
+ ///
+ private static void AddTimeOfDayGate(ModernSpawner spawner, int todMode, double todStartMinutes, double todEndMinutes)
+ {
+ var startHour = (int)(todStartMinutes / 60) % 24;
+ var startMinute = (int)(todStartMinutes % 60);
+ var endHour = (int)(todEndMinutes / 60) % 24;
+ var endMinute = (int)(todEndMinutes % 60);
+
+ if (todMode == 1)
+ {
+ // GameTimeWindowTrigger only carries whole hours; the minute component is dropped.
+ spawner.AddTriggerDefinition($"game_time_window:{startHour}:{endHour}:false:false");
+ }
+ else
+ {
+ spawner.AddTriggerDefinition(
+ $"wall_time_window:{startHour}:{startMinute}:{endHour}:{endMinute}:{(int)AllowedDays.All}:{(int)AllowedMonths.All}:{TimeZoneInfo.Utc.Id}"
+ );
+ }
+
+ spawner.TriggerActivated = true;
+ }
}
diff --git a/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs b/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs
index 67a4e6c..00d8f0e 100644
--- a/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs
+++ b/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs
@@ -4,20 +4,30 @@
namespace Server.Engines.ModernSpawner.Triggers;
///
-/// Trigger that activates based on in-game time windows.
+/// Gate that opens and closes on in-game time windows.
/// Uses transition-based timers for efficiency instead of polling.
///
///
/// This trigger uses the UO in-game clock which runs faster than real time.
/// For real-world time scheduling, use .
///
-public class GameTimeWindowTrigger : ITrigger
+public class GameTimeWindowTrigger : TriggerBase
{
- // UO game time runs at approximately 12 real minutes per game hour
- // (24 game hours = ~288 real minutes = ~4.8 real hours)
- private static readonly TimeSpan RealTimePerGameHour = TimeSpan.FromMinutes(12);
+ // One game hour in real time, taken from the engine's own clock rather than restated here:
+ // Clock.SecondsPerUOMinute is 5, so a game hour is 60 * 5 = 300 real seconds and a game day is
+ // two real hours. The value used to be hard-coded at 12 real minutes, which put every window edge
+ // 2.4x late.
+ private static readonly TimeSpan RealTimePerGameHour =
+ TimeSpan.FromSeconds(Clock.SecondsPerUOMinute * 60);
- public string TriggerType => "game_time_window";
+ /// The largest value may take: the exclusive end of a whole day.
+ public const int EndOfDay = 24;
+
+ ///
+ public override string TriggerType => "game_time_window";
+
+ ///
+ public override TriggerKind Kind => TriggerKind.Gate;
///
/// The start hour (0-23) when the spawn window opens.
@@ -25,7 +35,8 @@ public class GameTimeWindowTrigger : ITrigger
public int StartHour { get; set; }
///
- /// The end hour (0-23) when the spawn window closes.
+ /// The exclusive end hour (0-24) when the spawn window closes: a window ending at 18 covers up to
+ /// 17:59, and means "to midnight".
/// If EndHour < StartHour, the period spans midnight.
///
public int EndHour { get; set; } = 23;
@@ -47,7 +58,6 @@ public class GameTimeWindowTrigger : ITrigger
///
public bool IsWindowOpen { get; private set; }
- private ModernSpawner _spawner;
private TimerExecutionToken _transitionTimer;
// Night is roughly 9pm (21) to 5am (5)
@@ -58,6 +68,7 @@ public class GameTimeWindowTrigger : ITrigger
private const int DayStartHour = 5;
private const int DayEndHour = 21;
+ /// Creates a trigger with the documented defaults.
public GameTimeWindowTrigger()
{
}
@@ -65,30 +76,33 @@ public GameTimeWindowTrigger()
///
/// Creates a trigger for a specific game-time window.
///
+ /// Inclusive start hour, clamped to 0-23.
+ /// Exclusive end hour, clamped to 0-24.
public GameTimeWindowTrigger(int startHour, int endHour)
{
StartHour = Math.Clamp(startHour, 0, 23);
- EndHour = Math.Clamp(endHour, 0, 23);
+ EndHour = Math.Clamp(endHour, 0, EndOfDay);
}
///
/// Creates a night-only trigger.
///
+ /// A trigger open only during game night.
public static GameTimeWindowTrigger NightOnlyTrigger() => new() { NightOnly = true };
///
/// Creates a day-only trigger.
///
+ /// A trigger open only during game day.
public static GameTimeWindowTrigger DayOnlyTrigger() => new() { DayOnly = true };
- public bool Evaluate(TriggerContext context)
- {
- return IsWindowOpen;
- }
+ ///
+ public override bool Evaluate(in TriggerContext context) => IsWindowOpen;
- public void Activate(ModernSpawner spawner)
+ ///
+ public override void Activate(ModernSpawner spawner)
{
- _spawner = spawner;
+ base.Activate(spawner);
// Get effective hours based on mode
var (effectiveStart, effectiveEnd) = GetEffectiveHours();
@@ -98,10 +112,11 @@ public void Activate(ModernSpawner spawner)
ScheduleNextTransition(effectiveStart, effectiveEnd);
}
- public void Deactivate()
+ ///
+ public override void Deactivate()
{
_transitionTimer.Cancel();
- _spawner = null;
+ base.Deactivate();
IsWindowOpen = false;
}
@@ -122,36 +137,39 @@ public void Deactivate()
private void UpdateWindowState(int effectiveStart, int effectiveEnd)
{
- if (_spawner?.Map == null)
+ var spawner = Spawner;
+ if (spawner?.Map == null)
{
IsWindowOpen = false;
return;
}
- Clock.GetTime(_spawner.Map, _spawner.X, _spawner.Y, out int currentHour, out int _);
+ Clock.GetTime(spawner.Map, spawner.X, spawner.Y, out int currentHour, out int _);
var wasOpen = IsWindowOpen;
IsWindowOpen = IsHourInWindow(currentHour, effectiveStart, effectiveEnd);
- // Notify spawner of state change
+ // Gates report their edges by definition index, so the spawner can keep a set of open gates
+ // without holding trigger references.
if (IsWindowOpen && !wasOpen)
{
- _spawner.OnTriggerActivated(this);
+ spawner.OnGateOpened(DefinitionIndex);
}
else if (!IsWindowOpen && wasOpen)
{
- _spawner.OnTriggerDeactivated(this);
+ spawner.OnGateClosed(DefinitionIndex);
}
}
private void ScheduleNextTransition(int effectiveStart, int effectiveEnd)
{
- if (_spawner?.Map == null)
+ var spawner = Spawner;
+ if (spawner?.Map == null)
{
return;
}
- Clock.GetTime(_spawner.Map, _spawner.X, _spawner.Y, out int currentHour, out int currentMinute);
+ Clock.GetTime(spawner.Map, spawner.X, spawner.Y, out int currentHour, out int currentMinute);
// Calculate hours until next transition
int hoursUntilTransition;
@@ -186,7 +204,7 @@ private void ScheduleNextTransition(int effectiveStart, int effectiveEnd)
private void OnTransition()
{
- if (_spawner == null)
+ if (Spawner == null)
{
return;
}
@@ -205,6 +223,10 @@ private void OnTransition()
/// [start, end) window, correctly handling ranges that cross midnight. Public so
/// it can be exercised by unit tests without a live .
///
+ /// The hour to test.
+ /// Inclusive start hour.
+ /// Exclusive end hour.
+ /// True when the hour is inside the window.
public static bool IsHourInWindow(int currentHour, int startHour, int endHour)
{
if (endHour >= startHour)
@@ -222,6 +244,9 @@ public static bool IsHourInWindow(int currentHour, int startHour, int endHour)
/// , wrapping across midnight if needed. Public so
/// it can be exercised by unit tests.
///
+ /// The current hour.
+ /// The hour being scheduled for.
+ /// Whole hours until the target.
public static int CalculateHoursUntil(int currentHour, int targetHour)
{
if (targetHour > currentHour)
@@ -233,12 +258,14 @@ public static int CalculateHoursUntil(int currentHour, int targetHour)
return 24 - currentHour + targetHour;
}
- public string Serialize()
- {
+ ///
+ public override string Serialize() =>
// Format: game_time_window:startHour:endHour:nightOnly:dayOnly
- return $"game_time_window:{StartHour}:{EndHour}:{NightOnly}:{DayOnly}";
- }
+ $"game_time_window:{StartHour}:{EndHour}:{NightOnly}:{DayOnly}";
+ /// Parses a game-time window definition.
+ /// The definition text.
+ /// The parsed gate.
public static GameTimeWindowTrigger Parse(string definition)
{
var parts = definition.Split(':');
@@ -251,9 +278,89 @@ public static GameTimeWindowTrigger Parse(string definition)
if (parts.Length > 2 && int.TryParse(parts[2], out var endHour))
{
- trigger.EndHour = Math.Clamp(endHour, 0, 23);
+ trigger.EndHour = Math.Clamp(endHour, 0, EndOfDay);
+ }
+
+ if (parts.Length > 3 && bool.TryParse(parts[3], out var nightOnly))
+ {
+ trigger.NightOnly = nightOnly;
+ }
+
+ if (parts.Length > 4 && bool.TryParse(parts[4], out var dayOnly))
+ {
+ trigger.DayOnly = dayOnly;
+ }
+
+ return trigger;
+ }
+
+ ///
+ /// Maps a retired timeofday hour range onto this trigger's half-open [start, end)
+ /// window. The single place that knows the legacy grammar: both the timeofday factory alias
+ /// and the JSON importer go through it.
+ ///
+ ///
+ /// The legacy end hour was inclusive (timeofday:8:17 covered 08:00-17:59), so it becomes an
+ /// exclusive end one hour later. The legacy grammar also read end < start as a wrap past
+ /// midnight, which makes end == start - 1 (mod 24) - 0:23, 10:9, 23:22,
+ /// 1:0 - name every hour of the day. Half-open [start, start) is the empty
+ /// window, the exact opposite, so that case is mapped to the whole day instead.
+ ///
+ /// The legacy inclusive start hour.
+ /// The legacy inclusive end hour.
+ /// Receives the window's inclusive start hour.
+ /// Receives the window's exclusive end hour.
+ public static void MapLegacyTimeOfDayHours(int legacyStart, int legacyEnd, out int startHour, out int endHour)
+ {
+ var start = Math.Clamp(legacyStart, 0, 23);
+ var end = Math.Clamp(legacyEnd, 0, 23);
+
+ if ((end + 1) % 24 == start)
+ {
+ startHour = 0;
+ endHour = EndOfDay;
+ return;
+ }
+
+ startHour = start;
+ endHour = end + 1;
+ }
+
+ ///
+ /// Parses a retired timeofday:<start>:<end>[:nightOnly:dayOnly:cooldown] definition
+ /// into this trigger. Registered as the timeofday factory so saved worlds, exports and
+ /// XmlSpawner imports that still carry the old text keep working. Hours go through
+ /// ; the legacy polling cooldown has no counterpart on a gate
+ /// and is dropped.
+ ///
+ /// The legacy definition text.
+ /// An equivalent game-time window.
+ public static GameTimeWindowTrigger ParseLegacyTimeOfDay(string definition)
+ {
+ var parts = definition.Split(':');
+
+ // The legacy defaults were 0..23 inclusive, i.e. the whole day.
+ var legacyStart = 0;
+ var legacyEnd = 23;
+
+ if (parts.Length > 1 && int.TryParse(parts[1], out var parsedStart))
+ {
+ legacyStart = parsedStart;
}
+ if (parts.Length > 2 && int.TryParse(parts[2], out var parsedEnd))
+ {
+ legacyEnd = parsedEnd;
+ }
+
+ MapLegacyTimeOfDayHours(legacyStart, legacyEnd, out var startHour, out var endHour);
+
+ var trigger = new GameTimeWindowTrigger
+ {
+ StartHour = startHour,
+ EndHour = endHour
+ };
+
if (parts.Length > 3 && bool.TryParse(parts[3], out var nightOnly))
{
trigger.NightOnly = nightOnly;
diff --git a/Projects/ModernSpawner/Triggers/ITrigger.cs b/Projects/ModernSpawner/Triggers/ITrigger.cs
index 2912789..cc814c0 100644
--- a/Projects/ModernSpawner/Triggers/ITrigger.cs
+++ b/Projects/ModernSpawner/Triggers/ITrigger.cs
@@ -1,5 +1,41 @@
+using System;
+using Server.Engines.ModernSpawner.Scripting.Expressions;
+
namespace Server.Engines.ModernSpawner.Triggers;
+///
+/// What a trigger does for the spawner it is registered on.
+///
+public enum TriggerKind
+{
+ ///
+ /// An event source: each accepted match buys one spawn cycle (proximity, speech, kill, skill).
+ ///
+ Event,
+
+ ///
+ /// A window: it does not buy cycles, it opens and closes the spawner's gate (the time windows).
+ ///
+ Gate
+}
+
+///
+/// When the cycle bought by an accepted event runs (the mode: token).
+///
+public enum CycleMode
+{
+ ///
+ /// Run the cycle as soon as the dispatch that raised the event returns. The default.
+ ///
+ Now,
+
+ ///
+ /// Arm the spawner's timer for an immediate tick and let the cycle run there, with the normal tick
+ /// ordering and per-entry deadlines. This is XmlSpawner's SpawnOnTrigger = false.
+ ///
+ Tick
+}
+
///
/// Base interface for all trigger conditions that can activate a spawner.
///
@@ -11,71 +47,75 @@ public interface ITrigger
string TriggerType { get; }
///
- /// Evaluates whether this trigger condition is currently met.
+ /// Whether this trigger buys cycles () or opens and closes the
+ /// spawner's gate ().
///
- /// The trigger evaluation context.
- /// True if the trigger condition is met.
- bool Evaluate(TriggerContext context);
+ TriggerKind Kind { get; }
///
- /// Called when this trigger needs to start monitoring for its condition.
+ /// The this trigger was parsed from. Bound at registration by
+ /// ; while unbound.
///
- void Activate(ModernSpawner spawner);
+ Guid Id { get; set; }
///
- /// Called when this trigger should stop monitoring.
+ /// The position of this trigger's definition in , or
+ /// -1 while unbound. Gates report their open and close edges by this index.
///
- void Deactivate();
+ int DefinitionIndex { get; set; }
///
- /// Serializes this trigger to a string for storage.
+ /// The spawner-side runtime state (cooldown, kill count) for this trigger's definition, bound at
+ /// registration. Null while unbound, which is how a trigger parsed outside a spawner behaves.
///
- string Serialize();
-}
+ TriggerRuntimeState State { get; set; }
-///
-/// Context provided during trigger evaluation.
-///
-public class TriggerContext
-{
///
- /// The spawner being evaluated.
+ /// Whether an accepted event may start a stopped spawner (the wake: token). Gates ignore it.
///
- public ModernSpawner Spawner { get; }
+ bool Wake { get; }
///
- /// The mobile that potentially triggered this spawner (if any).
+ /// When the cycle an accepted event buys runs (the mode: token). Gates ignore it.
///
- public Mobile TriggeringMobile { get; set; }
+ CycleMode Mode { get; }
///
- /// Speech text that triggered this spawner (if any).
+ /// The per-trigger condition from the when: token, compiled once at parse time, or null when
+ /// the definition carried no condition. Gates ignore it.
///
- public string Speech { get; set; }
+ CompiledExpression When { get; }
///
- /// The entity that was killed (for kill triggers).
+ /// Minimum time between two accepted events for this trigger. The spawner writes
+ /// from it when it accepts an event;
+ /// only compares against it. Gates return .
///
- public IEntity KilledEntity { get; set; }
+ TimeSpan Cooldown { get; }
///
- /// The skill that was used (for skill triggers).
+ /// Evaluates whether this trigger condition is currently met. Pure: it reads
+ /// but never writes it, so an evaluation that the spawner goes on to reject
+ /// leaves no trace. Cooldown, refractory and kill-counter advances belong to the spawner's
+ /// acceptance path.
///
- public SkillName UsedSkill { get; set; }
-
- /// Outcome of the skill attempt that raised a skill trigger.
- public bool SkillSuccess { get; set; }
+ /// The trigger evaluation context.
+ /// True if the trigger condition is met.
+ bool Evaluate(in TriggerContext context);
- /// Skill value of the user at the time of the attempt.
- public double SkillValue { get; set; }
+ ///
+ /// Called when this trigger needs to start monitoring for its condition.
+ ///
+ /// The spawner this trigger belongs to.
+ void Activate(ModernSpawner spawner);
///
- /// Custom data that can be passed by trigger sources.
+ /// Called when this trigger should stop monitoring.
///
- public object CustomData { get; set; }
+ void Deactivate();
- public TriggerContext(ModernSpawner spawner)
- {
- Spawner = spawner;
- }
+ ///
+ /// Serializes this trigger to a string for storage.
+ ///
+ string Serialize();
}
diff --git a/Projects/ModernSpawner/Triggers/ITriggerSystem.cs b/Projects/ModernSpawner/Triggers/ITriggerSystem.cs
index 5880e3b..65ac1c7 100644
--- a/Projects/ModernSpawner/Triggers/ITriggerSystem.cs
+++ b/Projects/ModernSpawner/Triggers/ITriggerSystem.cs
@@ -11,33 +11,63 @@ public interface ITriggerSystem
///
/// Registers a trigger factory for a specific trigger type.
///
+ /// The definition prefix the factory answers to.
+ /// Builds a trigger from a definition, or returns null when it is malformed.
void RegisterTriggerType(string triggerType, Func factory);
///
/// Parses a trigger definition string into an ITrigger instance.
///
+ /// The definition text.
+ /// The parsed trigger, or null when the type is unknown or the text malformed.
ITrigger ParseTrigger(string definition);
+ ///
+ /// The parsed triggers registered for , or null when it has none. One
+ /// dictionary lookup: dispatch takes the set once and then walks a typed list by index.
+ ///
+ /// The spawner to look up.
+ /// Its registered set, or null.
+ TriggerSet GetSet(ModernSpawner spawner);
+
+ ///
+ /// Queues for a drain once the outermost dispatch returns, so a spawn
+ /// cycle never runs inside an enumeration that is still in progress.
+ ///
+ /// The spawner that wants to run a queued cycle.
+ void RequestDrain(ModernSpawner spawner);
+
///
/// Activates all triggers for a spawner.
///
+ /// The spawner to register.
void ActivateTriggers(ModernSpawner spawner);
///
/// Deactivates all triggers for a spawner.
///
+ /// The spawner to unregister.
void DeactivateTriggers(ModernSpawner spawner);
///
/// Called when a mobile enters proximity of a specific spawner.
/// Used by Item.OnMovement for optimized sector-based dispatch.
///
+ /// The mobile that moved.
+ /// Where it moved to.
+ /// The map it moved on.
+ /// The spawner the movement was dispatched to.
void OnMobileProximity(Mobile mobile, Point3D location, Map map, ModernSpawner spawner);
///
/// Called when speech is detected near a specific spawner.
/// Used by Item.OnSpeech for optimized sector-based dispatch.
///
+ /// The mobile that spoke.
+ /// What was said.
+ /// Where it was said.
+ /// The map it was said on.
+ /// The spawner the speech was dispatched to.
void OnSpeech(Mobile speaker, string text, Point3D location, Map map, ModernSpawner spawner);
///
@@ -52,5 +82,8 @@ public interface ITriggerSystem
///
/// Called when a spawned entity is killed.
///
+ /// The spawner that owned the spawn.
+ /// The entity that died.
+ /// The mobile credited with the kill, or null.
void OnEntityKilled(ModernSpawner spawner, IEntity killed, Mobile killer);
}
diff --git a/Projects/ModernSpawner/Triggers/KillTrigger.cs b/Projects/ModernSpawner/Triggers/KillTrigger.cs
index e8cce7a..9d722d5 100644
--- a/Projects/ModernSpawner/Triggers/KillTrigger.cs
+++ b/Projects/ModernSpawner/Triggers/KillTrigger.cs
@@ -1,14 +1,25 @@
using System;
+using Server.Text;
namespace Server.Engines.ModernSpawner.Triggers;
///
/// Trigger that activates when a spawned entity from this spawner is killed.
/// Useful for respawn-on-kill mechanics or boss encounter progression.
+/// Definition: kill:<requiredKills>:<requireAllDead>:<resetOnTrigger>:<filterType>:<requirePlayerKiller>:<cooldownSeconds>
+/// plus the shared .
///
-public class KillTrigger : ITrigger
+public class KillTrigger : TriggerBase
{
- public string TriggerType => "kill";
+ // kill:requiredKills:requireAllDead:resetOnTrigger:filterType:requirePlayerKiller:cooldownSeconds -
+ // tokens start after these, so a filter type named "Wake" stays a filter type.
+ private const int PositionalArity = 7;
+
+ ///
+ public override string TriggerType => "kill";
+
+ ///
+ public override TriggerKind Kind => TriggerKind.Event;
///
/// The number of kills required before triggering.
@@ -37,34 +48,33 @@ public class KillTrigger : ITrigger
///
public bool RequirePlayerKiller { get; set; }
- ///
- /// Cooldown between trigger activations.
- ///
- public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(5);
-
- private ModernSpawner _spawner;
- private int _currentKillCount;
- private DateTime _lastTriggered = DateTime.MinValue;
+ /// Creates a trigger with the documented defaults.
+ public KillTrigger() => Cooldown = TimeSpan.FromSeconds(5);
- public KillTrigger()
- {
- }
-
- public KillTrigger(int requiredKills, bool requireAllDead = false)
+ /// Creates a kill trigger.
+ /// Kills needed before the trigger fires; at least one.
+ /// Whether the spawner must hold no live spawns when it fires.
+ public KillTrigger(int requiredKills, bool requireAllDead = false) : this()
{
RequiredKills = Math.Max(1, requiredKills);
RequireAllDead = requireAllDead;
}
- public bool Evaluate(TriggerContext context)
+ ///
+ /// Whether this kill passes the trigger's filters at all, and therefore counts toward
+ /// whether or not the resulting event is accepted.
+ ///
+ ///
+ /// The cooldown is deliberately not one of the filters. It gates this trigger firing, not
+ /// the kills that build up to it: a kill that arrives while the cooldown is running still counts
+ /// toward the next threshold, so a kill:5 trigger does not silently lose progress every
+ /// time it fires. The spawner's acceptance path owns the cooldown comparison.
+ ///
+ /// The kill being dispatched.
+ /// True when the kill counts.
+ public bool CountsKill(in TriggerContext context)
{
- if (_spawner == null || context.KilledEntity == null)
- {
- return false;
- }
-
- // Check cooldown
- if (Core.Now - _lastTriggered < Cooldown)
+ if (context.KilledEntity == null)
{
return false;
}
@@ -73,8 +83,13 @@ public bool Evaluate(TriggerContext context)
if (!string.IsNullOrEmpty(FilterType))
{
var entityType = context.KilledEntity.GetType();
+ var fullName = entityType.FullName;
+
+ // Explicit rather than a lifted bool?: `!x?.Equals(y) == true` reads as "the full name
+ // does not match" but is false whenever FullName is null, so a type without one used to
+ // pass the filter by accident.
if (!entityType.Name.Equals(FilterType, StringComparison.OrdinalIgnoreCase) &&
- !entityType.FullName?.Equals(FilterType, StringComparison.OrdinalIgnoreCase) == true)
+ (fullName == null || !fullName.Equals(FilterType, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
@@ -89,50 +104,81 @@ public bool Evaluate(TriggerContext context)
}
}
- // Increment kill count
- _currentKillCount++;
+ return true;
+ }
+
+ ///
+ ///
+ /// Pure: the counter advance and the reset belong to the spawner's
+ /// acceptance path, so this reports whether this kill reaches the threshold by reading
+ /// and adding the kill in hand. It does not compare
+ /// the cooldown either - that is the spawner's gate, applied after this has said the threshold is
+ /// reached, so a kill refused for cooldown leaves the threshold reached for the next one.
+ ///
+ public override bool Evaluate(in TriggerContext context)
+ {
+ if (!CountsKill(in context))
+ {
+ return false;
+ }
- // Check if all dead is required
+ // Check if all dead is required. This is the only part of a kill trigger that needs the
+ // spawner, so an unbound trigger fails it rather than failing every kill.
if (RequireAllDead)
{
- // Check if spawner has any remaining spawned entities
- if (_spawner.Spawned.Count > 0)
+ var spawner = Spawner;
+ if (spawner == null)
{
return false;
}
- }
-
- // Check if we've reached required kills
- if (_currentKillCount >= RequiredKills)
- {
- _lastTriggered = Core.Now;
- if (ResetOnTrigger)
+ // BaseCreature.OnDeath notifies the spawner before the base death path removes the dying
+ // spawn from the registry, so the creature whose death this is still counts itself. Left
+ // in, "all dead" could never be true on the kill that actually clears the pack.
+ var live = spawner.Spawned.Count;
+ if (context.KilledEntity is ISpawnable killed && spawner.Spawned.ContainsKey(killed))
{
- _currentKillCount = 0;
+ live--;
}
- return true;
+ if (live > 0)
+ {
+ return false;
+ }
}
- return false;
+ var state = State;
+ var reached = state == null ? 1 : state.KillCount + 1;
+ return reached >= RequiredKills;
}
- public void Activate(ModernSpawner spawner)
+ ///
+ /// Counts one kill that passed , and clears the counter when the kill was
+ /// accepted and is set.
+ ///
+ ///
+ /// The counter lives on the spawner, so the advance belongs to the spawner's acceptance path and
+ /// ModernSpawner.RequestCycle is the only caller. A kill that passed
+ /// but did not reach the threshold still counts (
+ /// false); the kill that reaches it counts and then clears the counter. A kill the spawner refuses
+ /// for some other reason - its refractory, a full queue - never gets here at all, so a refused
+ /// threshold stays reached for the next kill.
+ ///
+ /// Whether matched for this kill.
+ public void AdvanceKillCount(bool accepted)
{
- _spawner = spawner;
- _currentKillCount = 0;
- TriggerSystem.Instance?.RegisterKillTrigger(spawner, this);
- }
+ var state = State;
+ if (state == null)
+ {
+ return;
+ }
- public void Deactivate()
- {
- if (_spawner != null)
+ state.KillCount++;
+
+ if (accepted && ResetOnTrigger)
{
- TriggerSystem.Instance?.UnregisterKillTrigger(_spawner, this);
+ state.KillCount = 0;
}
- _spawner = null;
- _currentKillCount = 0;
}
///
@@ -140,19 +186,43 @@ public void Deactivate()
///
public void ResetKillCount()
{
- _currentKillCount = 0;
+ var state = State;
+ if (state != null)
+ {
+ state.KillCount = 0;
+ }
}
- public string Serialize()
+ ///
+ public override string Serialize()
{
// Format: kill:requiredKills:requireAllDead:resetOnTrigger:filterType:requirePlayerKiller:cooldownSeconds
var filter = string.IsNullOrEmpty(FilterType) ? "any" : FilterType;
- return $"kill:{RequiredKills}:{RequireAllDead}:{ResetOnTrigger}:{filter}:{RequirePlayerKiller}:{(int)Cooldown.TotalSeconds}";
+
+ var sb = ValueStringBuilder.CreateMT();
+ try
+ {
+ sb.Append($"kill:{RequiredKills}:{RequireAllDead}:{ResetOnTrigger}:{filter}:{RequirePlayerKiller}:{(int)Cooldown.TotalSeconds}");
+ AppendTokens(ref sb);
+ return sb.ToString();
+ }
+ finally
+ {
+ sb.Dispose();
+ }
}
+ /// Parses a kill trigger definition.
+ /// The definition text.
+ /// The parsed trigger.
public static KillTrigger Parse(string definition)
{
- var parts = definition.Split(':');
+ var wake = false;
+ var mode = CycleMode.Now;
+ string when = null;
+ var positional = TriggerTokens.Strip(definition, PositionalArity, ref wake, ref mode, ref when);
+
+ var parts = positional.Split(':');
var trigger = new KillTrigger();
if (parts.Length > 1 && int.TryParse(parts[1], out var requiredKills))
@@ -189,6 +259,7 @@ public static KillTrigger Parse(string definition)
trigger.Cooldown = TimeSpan.FromSeconds(cooldown);
}
+ trigger.ApplyTokens(wake, mode, when);
return trigger;
}
}
diff --git a/Projects/ModernSpawner/Triggers/PendingCycle.cs b/Projects/ModernSpawner/Triggers/PendingCycle.cs
new file mode 100644
index 0000000..9b8e86f
--- /dev/null
+++ b/Projects/ModernSpawner/Triggers/PendingCycle.cs
@@ -0,0 +1,52 @@
+using System;
+using ModernUO.Serialization;
+
+namespace Server.Engines.ModernSpawner.Triggers;
+
+///
+/// One queued spawn cycle bought by an accepted trigger event. The queue on the spawner holds at most
+/// of these; each carries the mobile that caused it so a
+/// deferred drain can still position relative to that player.
+///
+[SerializationGenerator(0, false)]
+public partial class PendingCycle
+{
+ [DirtyTrackingEntity]
+ private ModernSpawner _spawner;
+
+ ///
+ /// The that bought this cycle, or for
+ /// an external source (a script or command calling ).
+ ///
+ [SerializableField(0, setter: "private")]
+ private Guid _triggerId;
+
+ ///
+ /// Serial of the mobile that raised the event, or when there was none.
+ /// Resolved at drain time; the mobile may be gone by then.
+ ///
+ [SerializableField(1, setter: "private")]
+ private Serial _triggeringMobile;
+
+ ///
+ /// Constructor used by the serialization generator when reading a spawner's pending list.
+ /// The fields are overwritten by Deserialize immediately afterwards.
+ ///
+ /// The spawner that owns this slot.
+ public PendingCycle(ModernSpawner spawner) => _spawner = spawner;
+
+ /// Creates a queued cycle for a trigger and the mobile that raised it.
+ /// The spawner that owns this slot.
+ /// The definition that bought the cycle, or .
+ /// The mobile that raised the event, or .
+ public PendingCycle(ModernSpawner spawner, Guid triggerId, Serial triggeringMobile)
+ {
+ _spawner = spawner;
+ _triggerId = triggerId;
+ _triggeringMobile = triggeringMobile;
+ }
+
+ /// Re-parents this slot, e.g. after a dupe copies the list.
+ /// The spawner that now owns this slot.
+ public void SetParent(ModernSpawner spawner) => _spawner = spawner;
+}
diff --git a/Projects/ModernSpawner/Triggers/ProximityTrigger.cs b/Projects/ModernSpawner/Triggers/ProximityTrigger.cs
index db56906..e502f96 100644
--- a/Projects/ModernSpawner/Triggers/ProximityTrigger.cs
+++ b/Projects/ModernSpawner/Triggers/ProximityTrigger.cs
@@ -1,18 +1,40 @@
using System;
+using Server.Logging;
+using Server.Text;
namespace Server.Engines.ModernSpawner.Triggers;
///
/// Trigger that activates when a mobile comes within range of the spawner.
+/// Definition: proximity:<range>:<playersOnly>:<requireLos>:<cooldownSeconds>:<minAccess>
+/// plus the shared .
///
-public class ProximityTrigger : ITrigger
+public class ProximityTrigger : TriggerBase
{
- public string TriggerType => "proximity";
+ private static readonly ILogger Logger = LogFactory.GetLogger(typeof(ProximityTrigger));
+
+ // proximity:range:playersOnly:requireLos:cooldownSeconds:minAccess - tokens start after these.
+ private const int PositionalArity = 6;
+
+ ///
+ public override string TriggerType => "proximity";
+
+ ///
+ public override TriggerKind Kind => TriggerKind.Event;
+
+ private int _range = 8;
///
- /// The range within which the mobile must be to trigger.
+ /// The range within which the mobile must be to trigger. Clamped to
+ /// : movement is dispatched to an item through the sectors
+ /// around it, and nothing outside that radius ever reaches ,
+ /// so a larger value would read as a trigger that silently never fires.
///
- public int Range { get; set; } = 8;
+ public int Range
+ {
+ get => _range;
+ set => _range = ClampRange(value);
+ }
///
/// Whether the trigger requires line of sight.
@@ -29,28 +51,44 @@ public class ProximityTrigger : ITrigger
///
public AccessLevel MinAccessLevel { get; set; } = AccessLevel.Player;
- ///
- /// Cooldown between trigger activations.
- ///
- public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(5);
-
- private ModernSpawner _spawner;
- private DateTime _lastTriggered = DateTime.MinValue;
+ /// Creates a trigger with the documented defaults.
+ public ProximityTrigger() => Cooldown = TimeSpan.FromSeconds(5);
- public ProximityTrigger()
- {
- }
-
- public ProximityTrigger(int range, bool playersOnly = true, bool requireLos = false)
+ /// Creates a proximity trigger.
+ /// Range in tiles, clamped to .
+ /// Whether only players may trigger it.
+ /// Whether the mobile must have line of sight to the spawner.
+ public ProximityTrigger(int range, bool playersOnly = true, bool requireLos = false) : this()
{
Range = range;
PlayersOnly = playersOnly;
RequireLineOfSight = requireLos;
}
- public bool Evaluate(TriggerContext context)
+ private static int ClampRange(int range)
+ {
+ if (range <= Core.GlobalMaxUpdateRange)
+ {
+ return range;
+ }
+
+ // Extended (beyond the global update range) proximity needs an area-movement subscription
+ // ModernUO does not expose yet, so the definition is clamped rather than quietly ignored.
+ Logger.Warning(
+ "Proximity trigger range {Range} exceeds the global update range {Max} and was clamped; movement is only dispatched within {Max} tiles.",
+ range,
+ Core.GlobalMaxUpdateRange,
+ Core.GlobalMaxUpdateRange
+ );
+
+ return Core.GlobalMaxUpdateRange;
+ }
+
+ ///
+ public override bool Evaluate(in TriggerContext context)
{
- if (context.TriggeringMobile == null || _spawner == null)
+ var spawner = Spawner;
+ if (context.TriggeringMobile == null || spawner == null)
{
return false;
}
@@ -70,60 +108,57 @@ public bool Evaluate(TriggerContext context)
}
// Check range
- if (!mobile.InRange(_spawner.Location, Range))
+ if (!mobile.InRange(spawner.Location, Range))
{
return false;
}
// Check map
- if (mobile.Map != _spawner.Map)
+ if (mobile.Map != spawner.Map)
{
return false;
}
// Line of sight, not visibility: Mobile.CanSee(Item) ends in item.Visible, and a spawner is
// Visible = false, so CanSee could never pass here for a player.
- if (RequireLineOfSight && !mobile.InLOS(_spawner))
+ if (RequireLineOfSight && !mobile.InLOS(spawner))
{
return false;
}
- // Check cooldown
- if (Core.Now - _lastTriggered < Cooldown)
- {
- return false;
- }
-
- _lastTriggered = Core.Now;
- return true;
- }
-
- public void Activate(ModernSpawner spawner)
- {
- _spawner = spawner;
- // Register with the trigger system for proximity events
- TriggerSystem.Instance?.RegisterProximityTrigger(spawner, this);
+ // Cooldown is a read: the spawner advances it when it accepts the event.
+ return CooldownElapsed();
}
- public void Deactivate()
+ ///
+ public override string Serialize()
{
- if (_spawner != null)
+ // Format: proximity:range:playersOnly:requireLos:cooldownSeconds:minAccess
+ var sb = ValueStringBuilder.CreateMT();
+ try
{
- TriggerSystem.Instance?.UnregisterProximityTrigger(_spawner, this);
+ sb.Append($"proximity:{Range}:{PlayersOnly}:{RequireLineOfSight}:{(int)Cooldown.TotalSeconds}:{(int)MinAccessLevel}");
+ AppendTokens(ref sb);
+ return sb.ToString();
+ }
+ finally
+ {
+ sb.Dispose();
}
- _spawner = null;
- }
-
- public string Serialize()
- {
- // Format: proximity:range:playersOnly:requireLos:cooldownSeconds:minAccess
- return $"proximity:{Range}:{PlayersOnly}:{RequireLineOfSight}:{(int)Cooldown.TotalSeconds}:{(int)MinAccessLevel}";
}
+ /// Parses a proximity trigger definition.
+ /// The definition text.
+ /// The parsed trigger.
public static ProximityTrigger Parse(string definition)
{
+ var wake = false;
+ var mode = CycleMode.Now;
+ string when = null;
+ var positional = TriggerTokens.Strip(definition, PositionalArity, ref wake, ref mode, ref when);
+
// Skip the "proximity:" prefix
- var parts = definition.Split(':');
+ var parts = positional.Split(':');
var trigger = new ProximityTrigger();
if (parts.Length > 1 && int.TryParse(parts[1], out var range))
@@ -151,6 +186,7 @@ public static ProximityTrigger Parse(string definition)
trigger.MinAccessLevel = (AccessLevel)accessLevel;
}
+ trigger.ApplyTokens(wake, mode, when);
return trigger;
}
}
diff --git a/Projects/ModernSpawner/Triggers/SkillTrigger.cs b/Projects/ModernSpawner/Triggers/SkillTrigger.cs
index 8c0fd0b..b139652 100644
--- a/Projects/ModernSpawner/Triggers/SkillTrigger.cs
+++ b/Projects/ModernSpawner/Triggers/SkillTrigger.cs
@@ -1,24 +1,38 @@
using System;
+using Server.Text;
namespace Server.Engines.ModernSpawner.Triggers;
/// Which attempt outcomes a skill trigger reacts to.
public enum SkillOutcome
{
+ /// Both successes and failures.
Any,
+
+ /// Successful attempts only (the + suffix).
Success,
+
+ /// Failed attempts only (the - suffix).
Failure
}
///
/// Fires when a player uses a skill near the spawner.
-/// Definition: skill:<Skill>[+|-]:<range>:<min>[-<max>]:<los>:<cooldownSeconds>.
+/// Definition: skill:<Skill>[+|-]:<range>:<min>[-<max>]:<los>:<cooldownSeconds>
+/// plus the shared .
/// + reacts to successes only, - to failures only; Any matches every skill.
/// Examples: skill:Mining:10, skill:Magery+:5:50-90, skill:Any-:8.
///
-public class SkillTrigger : ITrigger
+public class SkillTrigger : TriggerBase
{
- public string TriggerType => "skill";
+ // skill:skillName:range:valueWindow:requireLos:cooldownSeconds - tokens start after these.
+ private const int PositionalArity = 6;
+
+ ///
+ public override string TriggerType => "skill";
+
+ ///
+ public override TriggerKind Kind => TriggerKind.Event;
/// True when the trigger reacts to every skill; is then ignored.
public bool AnySkill { get; }
@@ -41,22 +55,36 @@ public class SkillTrigger : ITrigger
/// Whether the user must have line of sight to the spawner.
public bool RequireLOS { get; }
- /// Minimum time between firings.
- public TimeSpan Cooldown { get; }
-
- private ModernSpawner _spawner;
- private DateTime _lastTriggered;
-
+ /// Creates a skill trigger with the documented defaults.
+ /// The skill that fires it.
+ /// Range in tiles from the spawner.
+ /// Minimum skill value required.
+ /// Whether the user needs line of sight to the spawner.
public SkillTrigger(SkillName skill, int range = 10, double minSkillValue = 0, bool requireLOS = false)
: this(false, skill, SkillOutcome.Any, range, minSkillValue, -1, requireLOS, TimeSpan.FromSeconds(5))
{
}
+ /// Creates a skill trigger with an explicit cooldown.
+ /// The skill that fires it.
+ /// Range in tiles from the spawner.
+ /// Minimum skill value required.
+ /// Whether the user needs line of sight to the spawner.
+ /// Minimum time between two accepted events.
public SkillTrigger(SkillName skill, int range, double minSkillValue, bool requireLOS, TimeSpan cooldown)
: this(false, skill, SkillOutcome.Any, range, minSkillValue, -1, requireLOS, cooldown)
{
}
+ /// Creates a fully specified skill trigger.
+ /// Whether every skill fires it.
+ /// The skill that fires it when is false.
+ /// Which attempt outcomes react.
+ /// Range in tiles from the spawner.
+ /// Minimum skill value required.
+ /// Maximum skill value allowed, or -1 for no upper bound.
+ /// Whether the user needs line of sight to the spawner.
+ /// Minimum time between two accepted events.
public SkillTrigger(
bool anySkill,
SkillName skill,
@@ -78,24 +106,13 @@ TimeSpan cooldown
Cooldown = cooldown;
}
- public void Activate(ModernSpawner spawner)
- {
- _spawner = spawner;
- TriggerSystem.Instance.RegisterSkillTrigger(spawner, this);
- }
-
- public void Deactivate()
- {
- if (_spawner != null)
- {
- TriggerSystem.Instance.UnregisterSkillTrigger(_spawner, this);
- _spawner = null;
- }
- }
-
- public bool Evaluate(TriggerContext context)
+ ///
+ public override bool Evaluate(in TriggerContext context)
{
- if (_spawner == null || _spawner.Deleted || !_spawner.Running)
+ // A2: a stopped spawner still evaluates - a wake: trigger has to be able to start it, and a
+ // non-wake one queues a cycle for its first tick. Only deletion takes a trigger out.
+ var spawner = Spawner;
+ if (spawner == null || spawner.Deleted)
{
return false;
}
@@ -107,39 +124,39 @@ public bool Evaluate(TriggerContext context)
return false;
}
- // Check cooldown
- if (Core.Now - _lastTriggered < Cooldown)
+ // Cooldown is a read: the spawner advances it when it accepts the event.
+ if (!CooldownElapsed())
{
return false;
}
var mobile = context.TriggeringMobile;
- if (mobile == null || mobile.Map != _spawner.Map)
+ if (mobile == null || mobile.Map != spawner.Map)
{
return false;
}
// Check range
- if (!mobile.InRange(_spawner.Location, Range))
+ if (!mobile.InRange(spawner.Location, Range))
{
return false;
}
// Line of sight, not visibility: Mobile.CanSee(Item) ends in item.Visible, and a spawner is
// Visible = false, so CanSee could never pass here for a player.
- if (RequireLOS && !mobile.InLOS(_spawner))
- {
- return false;
- }
-
- _lastTriggered = Core.Now;
- return true;
+ return !RequireLOS || mobile.InLOS(spawner);
}
/// Whether this trigger reacts to at all.
+ /// The skill attempted.
+ /// True when the trigger reacts to it.
public bool MatchesSkill(SkillName skill) => AnySkill || skill == TargetSkill;
/// The pure part of : skill, outcome and value window.
+ /// The skill attempted.
+ /// The user's value in that skill.
+ /// Whether the attempt succeeded.
+ /// True when skill, outcome and value all match.
public bool MatchesContext(SkillName skill, double value, bool success)
{
if (!MatchesSkill(skill))
@@ -160,7 +177,8 @@ public bool MatchesContext(SkillName skill, double value, bool success)
return MaxSkillValue < 0 || value <= MaxSkillValue;
}
- public string Serialize()
+ ///
+ public override string Serialize()
{
var skill = AnySkill ? "Any" : TargetSkill.ToString();
var suffix = Outcome switch
@@ -169,14 +187,37 @@ public string Serialize()
SkillOutcome.Failure => "-",
_ => ""
};
- var window = MaxSkillValue < 0 ? $"{MinSkillValue}" : $"{MinSkillValue}-{MaxSkillValue}";
- return $"skill:{skill}{suffix}:{Range}:{window}:{RequireLOS}:{(int)Cooldown.TotalSeconds}";
+
+ var sb = ValueStringBuilder.CreateMT();
+ try
+ {
+ sb.Append($"skill:{skill}{suffix}:{Range}:");
+
+ if (MaxSkillValue < 0)
+ {
+ sb.Append($"{MinSkillValue}");
+ }
+ else
+ {
+ sb.Append($"{MinSkillValue}-{MaxSkillValue}");
+ }
+
+ sb.Append($":{RequireLOS}:{(int)Cooldown.TotalSeconds}");
+ AppendTokens(ref sb);
+ return sb.ToString();
+ }
+ finally
+ {
+ sb.Dispose();
+ }
}
///
/// Parses a skill trigger definition string.
/// Format: skill:<Skill>[+|-]:<range>:<min>[-<max>]:<los>:<cooldownSeconds>.
///
+ /// The definition text.
+ /// The parsed trigger, or null when the definition is malformed.
public static SkillTrigger Parse(string definition)
{
if (string.IsNullOrEmpty(definition))
@@ -184,7 +225,12 @@ public static SkillTrigger Parse(string definition)
return null;
}
- var parts = definition.Split(':');
+ var wake = false;
+ var mode = CycleMode.Now;
+ string when = null;
+ var positional = TriggerTokens.Strip(definition, PositionalArity, ref wake, ref mode, ref when);
+
+ var parts = positional.Split(':');
if (parts.Length < 2)
{
return null;
@@ -280,6 +326,8 @@ public static SkillTrigger Parse(string definition)
cooldown = TimeSpan.FromSeconds(cooldownSeconds);
}
- return new SkillTrigger(anySkill, skill, outcome, range, minValue, maxValue, requireLOS, cooldown);
+ var trigger = new SkillTrigger(anySkill, skill, outcome, range, minValue, maxValue, requireLOS, cooldown);
+ trigger.ApplyTokens(wake, mode, when);
+ return trigger;
}
}
diff --git a/Projects/ModernSpawner/Triggers/SpeechTrigger.cs b/Projects/ModernSpawner/Triggers/SpeechTrigger.cs
index 944d690..6b385fb 100644
--- a/Projects/ModernSpawner/Triggers/SpeechTrigger.cs
+++ b/Projects/ModernSpawner/Triggers/SpeechTrigger.cs
@@ -1,14 +1,34 @@
using System;
using System.Text.RegularExpressions;
+using Server.Logging;
+using Server.Text;
namespace Server.Engines.ModernSpawner.Triggers;
///
/// Trigger that activates when specific speech is detected near the spawner.
+/// Definition: speech:<base64 keyword>:<ignoreCase>:<useRegex>:<range>:<playersOnly>:<cooldownSeconds>
+/// plus the shared .
///
-public class SpeechTrigger : ITrigger
+public class SpeechTrigger : TriggerBase
{
- public string TriggerType => "speech";
+ private static readonly ILogger Logger = LogFactory.GetLogger(typeof(SpeechTrigger));
+
+ // speech:keyword:ignoreCase:useRegex:range:playersOnly:cooldownSeconds - tokens start after these.
+ private const int PositionalArity = 7;
+
+ ///
+ /// How long a regex keyword may spend on one line of speech before the match is abandoned. Keywords
+ /// are authored by staff but run against player speech, so a catastrophically backtracking pattern
+ /// would otherwise stall the game loop for every line spoken near the spawner.
+ ///
+ private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(50);
+
+ ///
+ public override string TriggerType => "speech";
+
+ ///
+ public override TriggerKind Kind => TriggerKind.Event;
///
/// The keyword or phrase to match.
@@ -35,29 +55,28 @@ public class SpeechTrigger : ITrigger
///
public bool PlayersOnly { get; set; } = true;
- ///
- /// Cooldown between trigger activations.
- ///
- public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(5);
-
- private ModernSpawner _spawner;
private Regex _compiledRegex;
- private DateTime _lastTriggered = DateTime.MinValue;
+ private bool _regexBuilt;
- public SpeechTrigger()
- {
- }
+ /// Creates a trigger with the documented defaults.
+ public SpeechTrigger() => Cooldown = TimeSpan.FromSeconds(5);
- public SpeechTrigger(string keyword, bool ignoreCase = true, int range = 10)
+ /// Creates a speech trigger.
+ /// The keyword, phrase or regex to match.
+ /// Whether the match ignores case.
+ /// Range in tiles from the spawner.
+ public SpeechTrigger(string keyword, bool ignoreCase = true, int range = 10) : this()
{
Keyword = keyword;
IgnoreCase = ignoreCase;
Range = range;
}
- public bool Evaluate(TriggerContext context)
+ ///
+ public override bool Evaluate(in TriggerContext context)
{
- if (string.IsNullOrEmpty(context.Speech) || _spawner == null)
+ var spawner = Spawner;
+ if (string.IsNullOrEmpty(context.Speech) || spawner == null)
{
return false;
}
@@ -75,31 +94,63 @@ public bool Evaluate(TriggerContext context)
}
// Check range
- if (!mobile.InRange(_spawner.Location, Range))
+ if (!mobile.InRange(spawner.Location, Range))
{
return false;
}
// Check map
- if (mobile.Map != _spawner.Map)
+ if (mobile.Map != spawner.Map)
{
return false;
}
- // Check cooldown
- if (Core.Now - _lastTriggered < Cooldown)
+ // Cooldown is a read: the spawner advances it when it accepts the event.
+ if (!CooldownElapsed())
{
return false;
}
- // Match the speech
- if (!MatchesSpeech(context.Speech))
+ return MatchesSpeech(context.Speech);
+ }
+
+ ///
+ /// Builds the pattern once, or gives up on it. Keywords are hand-authored, so an invalid pattern is a
+ /// configuration mistake rather than an exceptional condition: it must not escape registration, which
+ /// runs from world load and would otherwise leave a half-activated trigger set behind.
+ ///
+ private void EnsureRegex()
+ {
+ if (_regexBuilt)
{
- return false;
+ return;
+ }
+
+ _regexBuilt = true;
+
+ if (!UseRegex || string.IsNullOrEmpty(Keyword))
+ {
+ return;
}
- _lastTriggered = Core.Now;
- return true;
+ try
+ {
+ _compiledRegex = new Regex(
+ Keyword,
+ RegexOptions.Compiled | (IgnoreCase ? RegexOptions.IgnoreCase : RegexOptions.None),
+ RegexTimeout
+ );
+ }
+ catch (ArgumentException ex)
+ {
+ Logger.Warning(
+ ex,
+ "Speech trigger regex {Pattern} is not a valid pattern; the trigger will never match.",
+ Keyword
+ );
+
+ _compiledRegex = null;
+ }
}
private bool MatchesSpeech(string speech)
@@ -111,11 +162,23 @@ private bool MatchesSpeech(string speech)
if (UseRegex)
{
- _compiledRegex ??= new Regex(
- Keyword,
- IgnoreCase ? RegexOptions.IgnoreCase : RegexOptions.None
- );
- return _compiledRegex.IsMatch(speech);
+ // Memoization only - Activate builds this up front, so the dispatch path normally finds it
+ // already there. It carries no evaluation state, so Evaluate stays pure.
+ EnsureRegex();
+
+ if (_compiledRegex == null)
+ {
+ return false;
+ }
+
+ try
+ {
+ return _compiledRegex.IsMatch(speech);
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ return false;
+ }
}
var comparison = IgnoreCase
@@ -125,33 +188,57 @@ private bool MatchesSpeech(string speech)
return speech.Contains(Keyword, comparison);
}
- public void Activate(ModernSpawner spawner)
+ ///
+ public override void Activate(ModernSpawner spawner)
{
- _spawner = spawner;
- _compiledRegex = null; // Reset compiled regex
- TriggerSystem.Instance?.RegisterSpeechTrigger(spawner, this);
+ base.Activate(spawner);
+
+ // Compile once per registration so no dispatch pays for it, and so a keyword edited through the
+ // gump cannot leave a stale pattern behind.
+ _compiledRegex = null;
+ _regexBuilt = false;
+ EnsureRegex();
}
- public void Deactivate()
+ ///
+ public override void Deactivate()
{
- if (_spawner != null)
- {
- TriggerSystem.Instance?.UnregisterSpeechTrigger(_spawner, this);
- }
- _spawner = null;
+ base.Deactivate();
+ _compiledRegex = null;
+ _regexBuilt = false;
}
- public string Serialize()
+ ///
+ public override string Serialize()
{
// Format: speech:keyword:ignoreCase:useRegex:range:playersOnly:cooldownSeconds
// Keyword is base64 encoded to handle special characters
var encodedKeyword = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(Keyword ?? ""));
- return $"speech:{encodedKeyword}:{IgnoreCase}:{UseRegex}:{Range}:{PlayersOnly}:{(int)Cooldown.TotalSeconds}";
+
+ var sb = ValueStringBuilder.CreateMT();
+ try
+ {
+ sb.Append($"speech:{encodedKeyword}:{IgnoreCase}:{UseRegex}:{Range}:{PlayersOnly}:{(int)Cooldown.TotalSeconds}");
+ AppendTokens(ref sb);
+ return sb.ToString();
+ }
+ finally
+ {
+ sb.Dispose();
+ }
}
+ /// Parses a speech trigger definition.
+ /// The definition text.
+ /// The parsed trigger.
public static SpeechTrigger Parse(string definition)
{
- var parts = definition.Split(':');
+ var wake = false;
+ var mode = CycleMode.Now;
+ string when = null;
+ var positional = TriggerTokens.Strip(definition, PositionalArity, ref wake, ref mode, ref when);
+
+ var parts = positional.Split(':');
var trigger = new SpeechTrigger();
if (parts.Length > 1)
@@ -191,6 +278,7 @@ public static SpeechTrigger Parse(string definition)
trigger.Cooldown = TimeSpan.FromSeconds(cooldown);
}
+ trigger.ApplyTokens(wake, mode, when);
return trigger;
}
}
diff --git a/Projects/ModernSpawner/Triggers/TimeOfDayTrigger.cs b/Projects/ModernSpawner/Triggers/TimeOfDayTrigger.cs
deleted file mode 100644
index 091df48..0000000
--- a/Projects/ModernSpawner/Triggers/TimeOfDayTrigger.cs
+++ /dev/null
@@ -1,156 +0,0 @@
-using System;
-using Server.Items;
-
-namespace Server.Engines.ModernSpawner.Triggers;
-
-///
-/// Trigger that activates based on the in-game time of day.
-/// Supports specifying active hours during which spawning can occur.
-///
-public class TimeOfDayTrigger : ITrigger
-{
- public string TriggerType => "timeofday";
-
- ///
- /// The start hour (0-23) when this trigger becomes active.
- ///
- public int StartHour { get; set; }
-
- ///
- /// The end hour (0-23) when this trigger becomes inactive.
- /// If EndHour < StartHour, the period spans midnight.
- ///
- public int EndHour { get; set; } = 23;
-
- ///
- /// Whether to trigger only during nighttime (roughly 9pm - 5am game time).
- /// This is a convenience mode that overrides StartHour/EndHour.
- ///
- public bool NightOnly { get; set; }
-
- ///
- /// Whether to trigger only during daytime (roughly 5am - 9pm game time).
- /// This is a convenience mode that overrides StartHour/EndHour.
- ///
- public bool DayOnly { get; set; }
-
- ///
- /// Cooldown between trigger activations.
- ///
- public TimeSpan Cooldown { get; set; } = TimeSpan.FromMinutes(1);
-
- private ModernSpawner _spawner;
- private DateTime _lastTriggered = DateTime.MinValue;
-
- public TimeOfDayTrigger()
- {
- }
-
- public TimeOfDayTrigger(int startHour, int endHour)
- {
- StartHour = Math.Clamp(startHour, 0, 23);
- EndHour = Math.Clamp(endHour, 0, 23);
- }
-
- public bool Evaluate(TriggerContext context)
- {
- if (_spawner == null)
- {
- return false;
- }
-
- // Check cooldown
- if (Core.Now - _lastTriggered < Cooldown)
- {
- return false;
- }
-
- // Get current game time
- Clock.GetTime(_spawner.Map, _spawner.X, _spawner.Y, out var hours, out int _);
-
- bool isActiveTime;
-
- if (NightOnly)
- {
- // Night is roughly 9pm (21) to 5am (5)
- isActiveTime = hours >= 21 || hours < 5;
- }
- else if (DayOnly)
- {
- // Day is roughly 5am (5) to 9pm (21)
- isActiveTime = hours is >= 5 and < 21;
- }
- else if (EndHour >= StartHour)
- {
- // Normal time range (e.g., 8 to 17 means 8am to 5pm)
- isActiveTime = hours >= StartHour && hours <= EndHour;
- }
- else
- {
- // Time range spans midnight (e.g., 22 to 4 means 10pm to 4am)
- isActiveTime = hours >= StartHour || hours <= EndHour;
- }
-
- if (isActiveTime)
- {
- _lastTriggered = Core.Now;
- return true;
- }
-
- return false;
- }
-
- public void Activate(ModernSpawner spawner)
- {
- _spawner = spawner;
- TriggerSystem.Instance?.RegisterTimeOfDayTrigger(spawner, this);
- }
-
- public void Deactivate()
- {
- if (_spawner != null)
- {
- TriggerSystem.Instance?.UnregisterTimeOfDayTrigger(_spawner, this);
- }
- _spawner = null;
- }
-
- public string Serialize()
- {
- // Format: timeofday:startHour:endHour:nightOnly:dayOnly:cooldownSeconds
- return $"timeofday:{StartHour}:{EndHour}:{NightOnly}:{DayOnly}:{(int)Cooldown.TotalSeconds}";
- }
-
- public static TimeOfDayTrigger Parse(string definition)
- {
- var parts = definition.Split(':');
- var trigger = new TimeOfDayTrigger();
-
- if (parts.Length > 1 && int.TryParse(parts[1], out var startHour))
- {
- trigger.StartHour = Math.Clamp(startHour, 0, 23);
- }
-
- if (parts.Length > 2 && int.TryParse(parts[2], out var endHour))
- {
- trigger.EndHour = Math.Clamp(endHour, 0, 23);
- }
-
- if (parts.Length > 3 && bool.TryParse(parts[3], out var nightOnly))
- {
- trigger.NightOnly = nightOnly;
- }
-
- if (parts.Length > 4 && bool.TryParse(parts[4], out var dayOnly))
- {
- trigger.DayOnly = dayOnly;
- }
-
- if (parts.Length > 5 && int.TryParse(parts[5], out var cooldown))
- {
- trigger.Cooldown = TimeSpan.FromSeconds(cooldown);
- }
-
- return trigger;
- }
-}
diff --git a/Projects/ModernSpawner/Triggers/TriggerBase.cs b/Projects/ModernSpawner/Triggers/TriggerBase.cs
new file mode 100644
index 0000000..31bad21
--- /dev/null
+++ b/Projects/ModernSpawner/Triggers/TriggerBase.cs
@@ -0,0 +1,121 @@
+using System;
+using Server.Engines.ModernSpawner.Scripting.Expressions;
+using Server.Logging;
+using Server.Text;
+
+namespace Server.Engines.ModernSpawner.Triggers;
+
+///
+/// The registration-bound state every trigger carries, so the six trigger classes only implement what is
+/// actually different about them: their grammar and their .
+///
+public abstract class TriggerBase : ITrigger
+{
+ private static readonly ILogger Logger = LogFactory.GetLogger(typeof(TriggerBase));
+
+ ///
+ public abstract string TriggerType { get; }
+
+ ///
+ public abstract TriggerKind Kind { get; }
+
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ public int DefinitionIndex { get; set; } = -1;
+
+ ///
+ public TriggerRuntimeState State { get; set; }
+
+ ///
+ public bool Wake { get; set; }
+
+ ///
+ public CycleMode Mode { get; set; } = CycleMode.Now;
+
+ ///
+ public CompiledExpression When { get; private set; }
+
+ ///
+ /// The raw text of the when: token, kept so can write back exactly
+ /// what was parsed, or null when the definition carried no condition.
+ ///
+ public string WhenSource { get; private set; }
+
+ ///
+ public TimeSpan Cooldown { get; set; }
+
+ /// The spawner this trigger is registered on, or null while unbound.
+ protected ModernSpawner Spawner { get; private set; }
+
+ ///
+ public abstract bool Evaluate(in TriggerContext context);
+
+ ///
+ public virtual void Activate(ModernSpawner spawner) => Spawner = spawner;
+
+ ///
+ public virtual void Deactivate() => Spawner = null;
+
+ ///
+ public abstract string Serialize();
+
+ ///
+ /// Applies the values pulled out of a definition and compiles the
+ /// when: expression once, at parse time. Nothing on a dispatch path ever compiles.
+ ///
+ /// The wake: value.
+ /// The mode: value.
+ /// The raw when: expression, or null.
+ protected void ApplyTokens(bool wake, CycleMode mode, string when)
+ {
+ Wake = wake;
+ Mode = mode;
+ WhenSource = string.IsNullOrEmpty(when) ? null : when;
+
+ if (WhenSource == null)
+ {
+ When = null;
+ return;
+ }
+
+ var compiled = ExpressionEngine.Instance.Compile(WhenSource);
+ if (compiled != null && compiled.IsValid)
+ {
+ When = compiled;
+ return;
+ }
+
+ // A condition that will not compile would otherwise be a trigger that silently never fires:
+ // an invalid expression evaluates to false on every event. Report it once, at parse time, and
+ // let the trigger run unconditioned rather than dead.
+ Logger.Warning(
+ "Dropping the when: condition on a {TriggerType} trigger: {Expression} did not compile.",
+ TriggerType,
+ WhenSource
+ );
+
+ When = null;
+ WhenSource = null;
+ }
+
+ ///
+ /// Appends this trigger's tokens to a definition being serialized.
+ ///
+ /// The buffer holding the positional part of the definition.
+ protected void AppendTokens(scoped ref ValueStringBuilder sb) =>
+ TriggerTokens.Append(ref sb, Wake, Mode, WhenSource);
+
+ ///
+ /// Whether this trigger's cooldown has elapsed. An unbound trigger has no state to compare against
+ /// and is always eligible; the advance itself belongs to the spawner's acceptance path, so this only
+ /// ever reads.
+ ///
+ /// True when another event may be accepted.
+ protected bool CooldownElapsed()
+ {
+ var state = State;
+ return state == null || Core.Now >= state.CooldownUntil;
+ }
+}
diff --git a/Projects/ModernSpawner/Triggers/TriggerContext.cs b/Projects/ModernSpawner/Triggers/TriggerContext.cs
new file mode 100644
index 0000000..e5837e3
--- /dev/null
+++ b/Projects/ModernSpawner/Triggers/TriggerContext.cs
@@ -0,0 +1,60 @@
+namespace Server.Engines.ModernSpawner.Triggers;
+
+///
+/// Everything a trigger needs to decide whether it matches the event being dispatched.
+///
+///
+/// A readonly record struct so proximity, speech, kill and skill dispatch allocate nothing per
+/// event. Always take it as in and never store it in an or interface-typed
+/// slot: that would box it and put an allocation back on the movement path.
+///
+/// The spawner being evaluated.
+/// The mobile that raised the event, or null.
+/// The speech that raised the event, or null.
+/// The entity that was killed, or null.
+/// The skill that was attempted, when this is a skill event.
+/// The user's value in at the attempt.
+/// Whether the skill attempt succeeded.
+public readonly record struct TriggerContext(
+ ModernSpawner Spawner,
+ Mobile TriggeringMobile,
+ string Speech,
+ IEntity KilledEntity,
+ SkillName UsedSkill,
+ double SkillValue,
+ bool SkillSuccess
+)
+{
+ /// Context for a movement event near .
+ /// The spawner being evaluated.
+ /// The mobile that moved.
+ /// A context carrying only the mobile.
+ public static TriggerContext ForProximity(ModernSpawner spawner, Mobile mobile) =>
+ new(spawner, mobile, null, null, default, 0.0, false);
+
+ /// Context for speech near .
+ /// The spawner being evaluated.
+ /// The mobile that spoke.
+ /// What was said.
+ /// A context carrying the speaker and the text.
+ public static TriggerContext ForSpeech(ModernSpawner spawner, Mobile speaker, string text) =>
+ new(spawner, speaker, text, null, default, 0.0, false);
+
+ /// Context for the death of one of 's spawns.
+ /// The spawner being evaluated.
+ /// The entity that died.
+ /// The mobile credited with the kill, or null.
+ /// A context carrying the corpse and the killer.
+ public static TriggerContext ForKill(ModernSpawner spawner, IEntity killed, Mobile killer) =>
+ new(spawner, killer, null, killed, default, 0.0, false);
+
+ /// Context for a skill attempt near .
+ /// The spawner being evaluated.
+ /// The mobile that attempted the skill.
+ /// The skill attempted.
+ /// The mobile's value in that skill.
+ /// Whether the attempt succeeded.
+ /// A context carrying the skill, its value and the outcome.
+ public static TriggerContext ForSkill(ModernSpawner spawner, Mobile mobile, SkillName skill, double value, bool success) =>
+ new(spawner, mobile, null, null, skill, value, success);
+}
diff --git a/Projects/ModernSpawner/Triggers/TriggerDefinition.cs b/Projects/ModernSpawner/Triggers/TriggerDefinition.cs
new file mode 100644
index 0000000..00c45da
--- /dev/null
+++ b/Projects/ModernSpawner/Triggers/TriggerDefinition.cs
@@ -0,0 +1,60 @@
+using System;
+using ModernUO.Serialization;
+
+namespace Server.Engines.ModernSpawner.Triggers;
+
+///
+/// One trigger definition on a : the parse text plus a stable identity.
+/// The is generated once, when the definition is created, and never changes - it
+/// survives reordering and edits of , so per-trigger runtime state
+/// () and queued cycles () can name the
+/// definition they belong to across saves, gump edits and DTO round trips.
+///
+[SerializationGenerator(0, false)]
+public partial class TriggerDefinition
+{
+ // Editing a definition dirties the spawner that owns it; the generator resolves this on the
+ // declared type only, so it is declared here rather than inherited.
+ [DirtyTrackingEntity]
+ private ModernSpawner _spawner;
+
+ /// Stable identity of this definition. Version 7 so ids sort by creation time.
+ [SerializableField(0, setter: "private")]
+ private Guid _id;
+
+ /// The definition text the trigger system parses, e.g. proximity:8:true.
+ [SerializableField(1)]
+ private string _text;
+
+ ///
+ /// Constructor used by the serialization generator when reading a spawner's definition list.
+ /// The fields are overwritten by Deserialize immediately afterwards.
+ ///
+ /// The spawner that owns this definition.
+ public TriggerDefinition(ModernSpawner spawner) => _spawner = spawner;
+
+ /// Creates a definition with a freshly generated id.
+ /// The spawner that owns this definition.
+ /// The definition text the trigger system parses.
+ public TriggerDefinition(ModernSpawner spawner, string text) : this(spawner, Guid.Empty, text)
+ {
+ }
+
+ ///
+ /// Creates a definition with an explicit id. Import paths use this so ids survive an export and
+ /// re-import; asks for a fresh id.
+ ///
+ /// The spawner that owns this definition.
+ /// The id to keep, or to generate one.
+ /// The definition text the trigger system parses.
+ public TriggerDefinition(ModernSpawner spawner, Guid id, string text)
+ {
+ _spawner = spawner;
+ _id = id == Guid.Empty ? Guid.CreateVersion7() : id;
+ _text = text;
+ }
+
+ /// Re-parents this definition, e.g. after a dupe copies the list.
+ /// The spawner that now owns this definition.
+ public void SetParent(ModernSpawner spawner) => _spawner = spawner;
+}
diff --git a/Projects/ModernSpawner/Triggers/TriggerRuntimeState.cs b/Projects/ModernSpawner/Triggers/TriggerRuntimeState.cs
new file mode 100644
index 0000000..3e1a397
--- /dev/null
+++ b/Projects/ModernSpawner/Triggers/TriggerRuntimeState.cs
@@ -0,0 +1,58 @@
+using System;
+using ModernUO.Serialization;
+
+namespace Server.Engines.ModernSpawner.Triggers;
+
+///
+/// Per-definition runtime state, keyed by . Lives on the spawner
+/// rather than in the trigger system so a tick never has to look anything up; the parsed trigger
+/// object is bound to its state at registration.
+///
+[SerializationGenerator(0, false)]
+public partial class TriggerRuntimeState
+{
+ [DirtyTrackingEntity]
+ private ModernSpawner _spawner;
+
+ /// The this state belongs to.
+ [SerializableField(0, setter: "private")]
+ private Guid _id;
+
+ ///
+ /// Absolute instant before which this trigger cannot accept another event. Default
+ /// () means "no cooldown pending".
+ ///
+ [SerializableField(1)]
+ private DateTime _cooldownUntil;
+
+ /// Kills counted toward this trigger's threshold since it last fired or was reset.
+ [SerializableField(2)]
+ private int _killCount;
+
+ ///
+ /// Constructor used by the serialization generator when reading a spawner's state list.
+ /// The fields are overwritten by Deserialize immediately afterwards.
+ ///
+ /// The spawner that owns this state.
+ public TriggerRuntimeState(ModernSpawner spawner) => _spawner = spawner;
+
+ /// Creates empty state bound to a definition id.
+ /// The spawner that owns this state.
+ /// The this state belongs to.
+ public TriggerRuntimeState(ModernSpawner spawner, Guid id)
+ {
+ _spawner = spawner;
+ _id = id;
+ }
+
+ /// Re-parents this state, e.g. after a dupe copies the list.
+ /// The spawner that now owns this state.
+ public void SetParent(ModernSpawner spawner) => _spawner = spawner;
+
+ /// Clears the cooldown and the kill counter, leaving the binding intact.
+ public void Reset()
+ {
+ CooldownUntil = default;
+ KillCount = 0;
+ }
+}
diff --git a/Projects/ModernSpawner/Triggers/TriggerSet.cs b/Projects/ModernSpawner/Triggers/TriggerSet.cs
new file mode 100644
index 0000000..45a6fdf
--- /dev/null
+++ b/Projects/ModernSpawner/Triggers/TriggerSet.cs
@@ -0,0 +1,108 @@
+using System.Collections.Generic;
+
+namespace Server.Engines.ModernSpawner.Triggers;
+
+///
+/// One spawner's parsed triggers, filed by class. The trigger system keeps exactly one of these per
+/// registered spawner in a single dictionary, so a movement, speech or kill dispatch does one lookup and
+/// then walks a typed list by index - no per-type dictionary chain, no enumerator, no allocation.
+///
+public sealed class TriggerSet
+{
+ ///
+ /// The registration this set belongs to. A cycle request stamped with an older generation was
+ /// bought before the spawner was re-registered and is dropped rather than run against a set that no
+ /// longer contains its trigger.
+ ///
+ public int Generation { get; set; }
+
+ /// The map this spawner was filed under in the skill-dispatch candidate lists.
+ public Map SkillMap { get; set; }
+
+ /// Every parsed trigger, in definition order. Deactivation walks this one.
+ public List All { get; } = [];
+
+ /// The proximity triggers, dispatched from .
+ public List Proximity { get; } = [];
+
+ /// The speech triggers, dispatched from .
+ public List Speech { get; } = [];
+
+ /// The kill triggers, dispatched when one of the spawner's spawns dies.
+ public List Kill { get; } = [];
+
+ /// The skill triggers, dispatched from the server-wide skill event.
+ public List Skill { get; } = [];
+
+ /// The gates (time windows), which open and close rather than buying cycles.
+ public List Gates { get; } = [];
+
+ /// How many parsed triggers are event sources. Malformed definitions count for neither.
+ public int EventCount { get; private set; }
+
+ /// How many parsed triggers are gates. Malformed definitions count for neither.
+ public int GateCount { get; private set; }
+
+ ///
+ /// Files a parsed trigger under its class and advances the event/gate counts the tick guards read.
+ /// Null - a malformed definition the factory rejected - is ignored and counts for neither.
+ ///
+ /// The parsed trigger, or null.
+ public void Add(ITrigger trigger)
+ {
+ if (trigger == null)
+ {
+ return;
+ }
+
+ All.Add(trigger);
+
+ switch (trigger)
+ {
+ case ProximityTrigger proximity:
+ {
+ Proximity.Add(proximity);
+ break;
+ }
+ case SpeechTrigger speech:
+ {
+ Speech.Add(speech);
+ break;
+ }
+ case KillTrigger kill:
+ {
+ Kill.Add(kill);
+ break;
+ }
+ case SkillTrigger skill:
+ {
+ Skill.Add(skill);
+ break;
+ }
+ }
+
+ if (trigger.Kind == TriggerKind.Gate)
+ {
+ Gates.Add(trigger);
+ GateCount++;
+ }
+ else
+ {
+ EventCount++;
+ }
+ }
+
+ /// Empties every list and both counts, leaving the set reusable.
+ public void Clear()
+ {
+ All.Clear();
+ Proximity.Clear();
+ Speech.Clear();
+ Kill.Clear();
+ Skill.Clear();
+ Gates.Clear();
+ EventCount = 0;
+ GateCount = 0;
+ SkillMap = null;
+ }
+}
diff --git a/Projects/ModernSpawner/Triggers/TriggerSystem.cs b/Projects/ModernSpawner/Triggers/TriggerSystem.cs
index b0c5750..4195993 100644
--- a/Projects/ModernSpawner/Triggers/TriggerSystem.cs
+++ b/Projects/ModernSpawner/Triggers/TriggerSystem.cs
@@ -9,6 +9,12 @@ namespace Server.Engines.ModernSpawner.Triggers;
/// Default implementation of the trigger system.
/// Manages trigger registration, parsing, and event routing.
///
+///
+/// Registration is one per spawner in one dictionary, so a dispatch does a
+/// single lookup and then walks a typed list by index. Dispatch never runs a spawn cycle inline: a
+/// matching trigger asks the spawner for a cycle, and the outermost dispatch drains the
+/// requests once it returns, so a cycle can never re-enter an enumeration that is still running.
+///
public class TriggerSystem : ITriggerSystem
{
///
@@ -20,16 +26,24 @@ public class TriggerSystem : ITriggerSystem
private readonly Dictionary> _factories = new(StringComparer.OrdinalIgnoreCase);
- // Registered triggers by type for event routing
- private readonly Dictionary> _proximityTriggers = new();
- private readonly Dictionary> _speechTriggers = new();
- private readonly Dictionary> _timeOfDayTriggers = new();
- private readonly Dictionary> _killTriggers = new();
- private readonly Dictionary> _skillTriggers = new();
- private readonly Dictionary> _allTriggers = new();
+ // One set per registered spawner, replacing the six per-type dictionaries.
+ private readonly Dictionary _sets = new();
- private Timer _timeOfDayTimer;
+ // Skill attempts are dispatched server-wide, so skill triggers keep a candidate list per map rather
+ // than making every attempt scan the whole registry. Each entry carries the spawner's registered
+ // set with it, so an attempt costs one dictionary lookup for the map and none per candidate.
+ // Maintained on registration and map change.
+ private readonly Dictionary
- internal bool IsRegistered(ModernSpawner spawner) => spawner != null && _allTriggers.ContainsKey(spawner);
+ /// The spawner to check.
+ /// True when a set is registered for it.
+ internal bool IsRegistered(ModernSpawner spawner) => spawner != null && _sets.ContainsKey(spawner);
+ ///
public void DeactivateTriggers(ModernSpawner spawner)
{
- if (spawner == null)
+ if (spawner == null || !_sets.Remove(spawner, out var set))
{
return;
}
- if (_allTriggers.TryGetValue(spawner, out var triggers))
- {
- foreach (var trigger in triggers)
- {
- trigger.Deactivate();
- }
+ UnfileSkillCandidate(spawner, set);
- _allTriggers.Remove(spawner);
+ var triggers = set.All;
+ for (var i = 0; i < triggers.Count; i++)
+ {
+ triggers[i].Deactivate();
}
- // Clean up specific trigger type registrations and notify spawner
- if (_proximityTriggers.Remove(spawner))
+ // The set is not cleared: a dispatch further up the stack may still hold one of its typed lists,
+ // and dropping it from the dictionary is enough to retire it.
+ spawner.ClearRegistration();
+ spawner.SetHasProximityTriggers(false);
+ spawner.SetHasSpeechTriggers(false);
+ }
+
+ private void FileSkillCandidate(ModernSpawner spawner, TriggerSet set)
+ {
+ var map = spawner.Map;
+ if (map == null || map == Map.Internal)
{
- spawner.SetHasProximityTriggers(false);
- spawner.UnsubscribeFromExtendedAreaMovement();
+ return;
}
- if (_speechTriggers.Remove(spawner))
+ if (!_skillCandidates.TryGetValue(map, out var candidates))
{
- spawner.SetHasSpeechTriggers(false);
+ candidates = [];
+ _skillCandidates[map] = candidates;
}
- _timeOfDayTriggers.Remove(spawner);
- _killTriggers.Remove(spawner);
- _skillTriggers.Remove(spawner);
-
- // Stop time-of-day timer if no more time-based triggers
- if (_timeOfDayTriggers.Count == 0)
+ if (IndexOfCandidate(candidates, spawner) < 0)
{
- _timeOfDayTimer?.Stop();
- _timeOfDayTimer = null;
+ candidates.Add(new SkillCandidate(spawner, set));
}
+
+ set.SkillMap = map;
}
- public void RegisterProximityTrigger(ModernSpawner spawner, ProximityTrigger trigger)
+ private void UnfileSkillCandidate(ModernSpawner spawner, TriggerSet set)
{
- if (!_proximityTriggers.TryGetValue(spawner, out var list))
+ var map = set.SkillMap;
+ if (map == null || !_skillCandidates.TryGetValue(map, out var candidates))
{
- list = [];
- _proximityTriggers[spawner] = list;
+ return;
}
- if (!list.Contains(trigger))
+ var index = IndexOfCandidate(candidates, spawner);
+ if (index >= 0)
{
- list.Add(trigger);
+ candidates.RemoveAt(index);
}
- // Notify spawner it now has proximity triggers (enables HandlesOnMovement)
- spawner.SetHasProximityTriggers(true);
+ if (candidates.Count == 0)
+ {
+ _skillCandidates.Remove(map);
+ }
- // Check if any trigger requires extended range (beyond 24 tiles)
- UpdateExtendedProximityBounds(spawner, list);
+ set.SkillMap = null;
}
- public void UnregisterProximityTrigger(ModernSpawner spawner, ProximityTrigger trigger)
+ /// Position of in a map's candidate list, or -1.
+ /// The map's candidate list.
+ /// The spawner to find.
+ /// Its index, or -1.
+ private static int IndexOfCandidate(List candidates, ModernSpawner spawner)
{
- if (_proximityTriggers.TryGetValue(spawner, out var list))
+ for (var i = 0; i < candidates.Count; i++)
{
- list.Remove(trigger);
- if (list.Count == 0)
+ if (candidates[i].Spawner == spawner)
{
- _proximityTriggers.Remove(spawner);
- // Notify spawner it no longer has proximity triggers
- spawner.SetHasProximityTriggers(false);
- // Unsubscribe from extended area movement
- spawner.UnsubscribeFromExtendedAreaMovement();
- }
- else
- {
- // Recalculate extended bounds with remaining triggers
- UpdateExtendedProximityBounds(spawner, list);
+ return i;
}
}
+
+ return -1;
}
///
- /// Updates extended proximity bounds for a spawner based on its proximity triggers.
- /// Sets up area movement subscription if any trigger range exceeds 24 tiles.
+ /// Re-files a registered spawner's skill triggers after its map changed, so skill dispatch never has
+ /// to scan the whole registry to find the candidates on one map.
///
- private static void UpdateExtendedProximityBounds(ModernSpawner spawner, List triggers)
+ /// The spawner whose map changed.
+ internal void OnSpawnerMapChanged(ModernSpawner spawner)
{
- // Find the maximum trigger range
- var maxRange = 0;
- for (var i = 0; i < triggers.Count; i++)
+ if (spawner == null || !_sets.TryGetValue(spawner, out var set) || set.Skill.Count == 0)
{
- var trigger = triggers[i];
- if (trigger.Range > maxRange)
- {
- maxRange = trigger.Range;
- }
+ return;
}
- // If max range exceeds the normal OnMovement range (24 tiles), use extended area subscriptions
- if (maxRange > Core.GlobalMaxUpdateRange)
- {
- // Calculate bounds centered on spawner location with the max range
- var location = spawner.Location;
- var bounds = new Rectangle2D(
- location.X - maxRange,
- location.Y - maxRange,
- maxRange * 2 + 1,
- maxRange * 2 + 1
- );
-
- spawner.SetExtendedTriggerBounds(bounds);
- }
- else
+ if (set.SkillMap == spawner.Map)
{
- // No extended range needed - unsubscribe if previously subscribed
- spawner.UnsubscribeFromExtendedAreaMovement();
+ return;
}
+
+ UnfileSkillCandidate(spawner, set);
+ FileSkillCandidate(spawner, set);
}
- public void RegisterSpeechTrigger(ModernSpawner spawner, SpeechTrigger trigger)
+ ///
+ /// Queues for a drain once the outermost dispatch returns. Idempotent
+ /// within one outer dispatch: a spawner already queued is not queued twice.
+ ///
+ /// The spawner that wants to run a queued cycle.
+ public void RequestDrain(ModernSpawner spawner)
{
- if (!_speechTriggers.TryGetValue(spawner, out var list))
- {
- list = [];
- _speechTriggers[spawner] = list;
- }
-
- if (!list.Contains(trigger))
+ if (spawner == null || spawner.Deleted || spawner.DrainRequested)
{
- list.Add(trigger);
+ return;
}
- // Notify spawner it now has speech triggers (enables HandlesOnSpeech)
- spawner.SetHasSpeechTriggers(true);
- }
+ spawner.DrainRequested = true;
+ _drainList.Add(spawner);
- public void UnregisterSpeechTrigger(ModernSpawner spawner, SpeechTrigger trigger)
- {
- if (_speechTriggers.TryGetValue(spawner, out var list))
+ // Gates fire off timers and scripts call Trigger() straight from a command, so a request can
+ // arrive with no dispatch above it to unwind. There is nothing to wait for in that case.
+ if (_dispatchDepth == 0 && !_draining)
{
- list.Remove(trigger);
- if (list.Count == 0)
- {
- _speechTriggers.Remove(spawner);
- // Notify spawner it no longer has speech triggers
- spawner.SetHasSpeechTriggers(false);
- }
+ DrainAll();
}
}
- public void RegisterTimeOfDayTrigger(ModernSpawner spawner, TimeOfDayTrigger trigger)
+ ///
+ /// Drops a queued drain for without disturbing the list a drain in
+ /// progress is walking. Deleting, deactivating or resetting a spawner cancels what it was owed.
+ ///
+ /// The spawner whose queued drain is cancelled.
+ internal void CancelDrain(ModernSpawner spawner)
{
- if (!_timeOfDayTriggers.TryGetValue(spawner, out var list))
- {
- list = [];
- _timeOfDayTriggers[spawner] = list;
- }
-
- if (!list.Contains(trigger))
+ if (spawner == null)
{
- list.Add(trigger);
+ return;
}
- // Start the time-of-day timer if not already running
- StartTimeOfDayTimer();
- }
+ spawner.DrainRequested = false;
+ spawner.DrainsThisRound = 0;
- public void UnregisterTimeOfDayTrigger(ModernSpawner spawner, TimeOfDayTrigger trigger)
- {
- if (_timeOfDayTriggers.TryGetValue(spawner, out var list))
+ for (var i = 0; i < _drainList.Count; i++)
{
- list.Remove(trigger);
- if (list.Count == 0)
+ if (_drainList[i] == spawner)
{
- _timeOfDayTriggers.Remove(spawner);
+ // Nulled rather than removed: DrainAll may be walking this list by index right now.
+ _drainList[i] = null;
}
}
}
- public void RegisterKillTrigger(ModernSpawner spawner, KillTrigger trigger)
+ ///
+ /// Whether a trigger dispatch is in progress. A cycle must never observe this as true: dispatch
+ /// asks the spawner for a cycle and the outermost dispatch runs it once it has returned.
+ ///
+ internal bool IsDispatching => _dispatchDepth > 0;
+
+ ///
+ /// Runs every queued drain. Only ever called from the outermost dispatch, so a cycle that raises
+ /// further events queues into the same list and is picked up by this same loop rather than nesting.
+ ///
+ private void DrainAll()
{
- if (!_killTriggers.TryGetValue(spawner, out var list))
+ if (_drainList.Count == 0)
{
- list = [];
- _killTriggers[spawner] = list;
+ return;
}
- if (!list.Contains(trigger))
- {
- list.Add(trigger);
- }
- }
-
- public void UnregisterKillTrigger(ModernSpawner spawner, KillTrigger trigger)
- {
- if (_killTriggers.TryGetValue(spawner, out var list))
+ _draining = true;
+ try
{
- list.Remove(trigger);
- if (list.Count == 0)
+ // Count is re-read: a cycle can append to the list while it runs.
+ for (var i = 0; i < _drainList.Count; i++)
{
- _killTriggers.Remove(spawner);
+ var spawner = _drainList[i];
+
+ // Null means the entry was cancelled after it was queued.
+ if (spawner == null)
+ {
+ continue;
+ }
+
+ spawner.DrainRequested = false;
+
+ if (spawner.Deleted)
+ {
+ continue;
+ }
+
+ spawner.DrainOne();
}
}
- }
-
- public void RegisterSkillTrigger(ModernSpawner spawner, SkillTrigger trigger)
- {
- if (!_skillTriggers.TryGetValue(spawner, out var list))
+ finally
{
- list = [];
- _skillTriggers[spawner] = list;
- }
+ for (var i = 0; i < _drainList.Count; i++)
+ {
+ var spawner = _drainList[i];
+ if (spawner == null)
+ {
+ continue;
+ }
- if (!list.Contains(trigger))
- {
- list.Add(trigger);
+ spawner.DrainRequested = false;
+
+ // The recursion budget is per outer dispatch, so it resets with the list.
+ spawner.DrainsThisRound = 0;
+ }
+
+ _drainList.Clear();
+ _draining = false;
}
}
- public void UnregisterSkillTrigger(ModernSpawner spawner, SkillTrigger trigger)
+ private void EndDispatch()
{
- if (_skillTriggers.TryGetValue(spawner, out var list))
+ // A drain runs cycles, and a cycle can raise events that come back through a dispatch entry
+ // point; those must not start a second drain from inside the first.
+ if (--_dispatchDepth == 0 && !_draining)
{
- list.Remove(trigger);
- if (list.Count == 0)
- {
- _skillTriggers.Remove(spawner);
- }
+ DrainAll();
}
}
- ///
- /// Dispatches a skill attempt to every registered skill trigger on the mobile's map.
- /// Runs on every player skill attempt server-wide: is read once for the
- /// whole dispatch (each read re-derives the stat-scaled value plus the racial bonus), and a
- /// is allocated only for a spawner that holds a trigger for this skill.
- ///
- /// The mobile that attempted the skill.
- /// The skill attempted.
- /// Whether the attempt succeeded.
+ ///
public void OnSkillUse(Mobile mobile, Skill skill, bool success)
{
if (mobile == null || skill == null || mobile.Map == null || mobile.Map == Map.Internal)
@@ -368,42 +419,48 @@ public void OnSkillUse(Mobile mobile, Skill skill, bool success)
return;
}
- var registered = _skillTriggers.Count;
- if (registered == 0)
+ if (!_skillCandidates.TryGetValue(mobile.Map, out var candidates) || candidates.Count == 0)
{
return;
}
var skillName = skill.SkillName;
+
+ // Skill.Value re-derives the stat-scaled value plus the racial bonus on every read, so it is
+ // read once for the whole dispatch.
var skillValue = skill.Value;
- // spawner.Trigger() runs Spawn() and any attached script, and a DESPAWN script - or a spawned
- // ModernSpawner - can delete or register a spawner that carries a skill trigger. Dispatching off
- // a snapshot keeps that from invalidating the enumerator mid-loop.
- var pool = STArrayPool>>.Shared;
- var snapshot = pool.Rent(registered);
+ // A cycle can delete or re-register a spawner that carries a skill trigger, which would
+ // invalidate this list mid-loop; dispatch off a pooled snapshot instead.
+ var pool = STArrayPool.Shared;
+ var snapshot = pool.Rent(candidates.Count);
+ var taken = candidates.Count;
- var taken = 0;
+ _dispatchDepth++;
try
{
- foreach (var entry in _skillTriggers)
+ for (var i = 0; i < taken; i++)
{
- snapshot[taken++] = entry;
+ snapshot[i] = candidates[i];
}
for (var s = 0; s < taken; s++)
{
- var (spawner, triggers) = snapshot[s];
+ var spawner = snapshot[s].Spawner;
+ var set = snapshot[s].Set;
- // The snapshot can name a spawner that an earlier iteration of this dispatch deleted or
- // unregistered.
- if (spawner.Deleted || spawner.Map != mobile.Map || !_skillTriggers.ContainsKey(spawner))
+ // The snapshot can name a spawner an earlier iteration of this dispatch deleted or
+ // re-registered. Deletion and the map are checked here; a set the spawner has since
+ // replaced is caught by the generation stamp the request carries.
+ if (spawner.Deleted || spawner.Map != mobile.Map)
{
continue;
}
- // Cheap pre-scan: most spawners hold triggers for other skills, and those must not pay for
- // a context. Indexed loops here so this path has no enumerator and no closure.
+ var triggers = set.Skill;
+
+ // Cheap pre-scan: most spawners hold triggers for other skills, and those must not pay
+ // for a context. Indexed loops so this path has no enumerator and no closure.
var firstMatch = -1;
for (var i = 0; i < triggers.Count; i++)
{
@@ -419,22 +476,17 @@ public void OnSkillUse(Mobile mobile, Skill skill, bool success)
continue;
}
- var context = new TriggerContext(spawner)
- {
- TriggeringMobile = mobile,
- UsedSkill = skillName,
- SkillValue = skillValue,
- SkillSuccess = success
- };
+ var context = TriggerContext.ForSkill(spawner, mobile, skillName, skillValue, success);
// Everything before firstMatch is already known not to match this skill.
for (var i = firstMatch; i < triggers.Count; i++)
{
var trigger = triggers[i];
- if (trigger.MatchesSkill(skillName) && trigger.Evaluate(context))
+ if (trigger.MatchesSkill(skillName) &&
+ trigger.Evaluate(in context) &&
+ spawner.RequestCycle(trigger, set.Generation, in context))
{
- spawner.Trigger();
- break; // Only trigger once per spawner per skill use
+ break; // Only one cycle per spawner per skill use
}
}
}
@@ -445,135 +497,106 @@ public void OnSkillUse(Mobile mobile, Skill skill, bool success)
// spawner references, but the bucket-sized array can be far larger than `taken`.
snapshot.AsSpan(0, taken).Clear();
pool.Return(snapshot);
+ EndDispatch();
}
}
- private void StartTimeOfDayTimer()
+ ///
+ public void OnMobileProximity(Mobile mobile, Point3D location, Map map, ModernSpawner spawner)
{
- if (_timeOfDayTimer != null)
+ if (mobile == null || map == null || map == Map.Internal || spawner == null)
{
return;
}
- // Check time-based triggers every game minute (roughly every 2.5 real seconds)
- _timeOfDayTimer = Timer.DelayCall(TimeSpan.FromSeconds(2.5), TimeSpan.FromSeconds(2.5), CheckTimeOfDayTriggers);
- }
-
- private void CheckTimeOfDayTriggers()
- {
- if (_timeOfDayTriggers.Count == 0)
+ if (!_sets.TryGetValue(spawner, out var set) || set.Proximity.Count == 0)
{
- _timeOfDayTimer?.Stop();
- _timeOfDayTimer = null;
return;
}
- // Same hazard as OnSkillUse: spawner.Trigger() runs Spawn() and any attached script, which can
- // delete or register a spawner carrying a time-of-day trigger. Dispatch off a snapshot.
- var pool = STArrayPool>>.Shared;
- var snapshot = pool.Rent(_timeOfDayTriggers.Count);
-
- var taken = 0;
+ _dispatchDepth++;
try
{
- foreach (var entry in _timeOfDayTriggers)
- {
- snapshot[taken++] = entry;
- }
+ var context = TriggerContext.ForProximity(spawner, mobile);
+ var triggers = set.Proximity;
- for (var s = 0; s < taken; s++)
+ for (var i = 0; i < triggers.Count; i++)
{
- var (spawner, triggers) = snapshot[s];
-
- if (spawner.Deleted || !spawner.Running || !_timeOfDayTriggers.ContainsKey(spawner))
+ // A trigger that matches but is refused - its refractory, its when:, a full queue -
+ // does not end the dispatch: the next trigger gets its turn, the way the kill loop
+ // already worked. Only an accepted event stops the scan.
+ var trigger = triggers[i];
+ if (trigger.Evaluate(in context) && spawner.RequestCycle(trigger, set.Generation, in context))
{
- continue;
- }
-
- var context = new TriggerContext(spawner);
-
- for (var i = 0; i < triggers.Count; i++)
- {
- if (triggers[i].Evaluate(context))
- {
- spawner.Trigger();
- break;
- }
+ break; // Only one cycle per spawner per proximity event
}
}
}
finally
{
- // Clear only the entries written: the buffer outlives this call inside the pool and holds
- // spawner references, but the bucket-sized array can be far larger than `taken`.
- snapshot.AsSpan(0, taken).Clear();
- pool.Return(snapshot);
+ EndDispatch();
}
}
- ///
- /// Called when a mobile moves near a specific spawner (via Item.OnMovement).
- /// This is the optimized path using ModernUO's built-in sector-based dispatch.
- ///
- public void OnMobileProximity(Mobile mobile, Point3D location, Map map, ModernSpawner spawner)
+ ///
+ public void OnSpeech(Mobile speaker, string text, Point3D location, Map map, ModernSpawner spawner)
{
- if (mobile == null || map == null || map == Map.Internal || spawner == null)
+ if (speaker == null || string.IsNullOrEmpty(text) || map == null || map == Map.Internal || spawner == null)
{
return;
}
- if (!_proximityTriggers.TryGetValue(spawner, out var triggers))
+ if (!_sets.TryGetValue(spawner, out var set) || set.Speech.Count == 0)
{
return;
}
- var context = new TriggerContext(spawner)
+ _dispatchDepth++;
+ try
{
- TriggeringMobile = mobile
- };
+ var context = TriggerContext.ForSpeech(spawner, speaker, text);
+ var triggers = set.Speech;
- foreach (var trigger in triggers)
- {
- if (trigger.Evaluate(context))
+ for (var i = 0; i < triggers.Count; i++)
{
- spawner.Trigger();
- break; // Only trigger once per spawner per proximity event
+ // As for proximity, a refused trigger does not end the dispatch.
+ var trigger = triggers[i];
+ if (trigger.Evaluate(in context) && spawner.RequestCycle(trigger, set.Generation, in context))
+ {
+ break; // Only one cycle per spawner per speech event
+ }
}
}
+ finally
+ {
+ EndDispatch();
+ }
}
///
- /// Called when speech occurs near a specific spawner (via Item.OnSpeech).
- /// This is the optimized path using ModernUO's built-in sector-based dispatch.
+ /// One entry in a map's skill-dispatch candidate list. The set travels with the spawner so an
+ /// attempt does not pay a registry lookup per candidate on a path that runs for every skill use
+ /// on the shard.
///
- public void OnSpeech(Mobile speaker, string text, Point3D location, Map map, ModernSpawner spawner)
+ private readonly struct SkillCandidate
{
- if (speaker == null || string.IsNullOrEmpty(text) || map == null || map == Map.Internal || spawner == null)
- {
- return;
- }
+ /// The candidate spawner.
+ public ModernSpawner Spawner { get; }
- if (!_speechTriggers.TryGetValue(spawner, out var triggers))
- {
- return;
- }
+ /// The registration it was filed under.
+ public TriggerSet Set { get; }
- var context = new TriggerContext(spawner)
+ /// Files a spawner with the set it was registered with.
+ /// The candidate spawner.
+ /// Its registered set.
+ public SkillCandidate(ModernSpawner spawner, TriggerSet set)
{
- TriggeringMobile = speaker,
- Speech = text
- };
-
- foreach (var trigger in triggers)
- {
- if (trigger.Evaluate(context))
- {
- spawner.Trigger();
- break; // Only trigger once per spawner per speech event
- }
+ Spawner = spawner;
+ Set = set;
}
}
+ ///
public void OnEntityKilled(ModernSpawner spawner, IEntity killed, Mobile killer)
{
if (spawner == null || killed == null)
@@ -581,55 +604,34 @@ public void OnEntityKilled(ModernSpawner spawner, IEntity killed, Mobile killer)
return;
}
- if (!_killTriggers.TryGetValue(spawner, out var triggers))
+ if (!_sets.TryGetValue(spawner, out var set) || set.Kill.Count == 0)
{
return;
}
- var context = new TriggerContext(spawner)
+ _dispatchDepth++;
+ try
{
- KilledEntity = killed,
- TriggeringMobile = killer
- };
+ var context = TriggerContext.ForKill(spawner, killed, killer);
+ var triggers = set.Kill;
- foreach (var trigger in triggers)
- {
- if (trigger.Evaluate(context))
+ for (var i = 0; i < triggers.Count; i++)
{
- spawner.Trigger();
- break;
- }
- }
- }
+ var trigger = triggers[i];
- ///
- /// Gets all active spawners with proximity triggers in the given region.
- /// Used for optimized proximity checking.
- ///
- public IEnumerable GetSpawnersWithProximityTriggers(Map map)
- {
- foreach (var spawner in _proximityTriggers.Keys)
- {
- if (spawner.Map == map)
- {
- yield return spawner;
+ // Unlike the other classes, a kill trigger's match depends on a counter that lives on
+ // the spawner, so every kill that passes the trigger's filters is handed over and the
+ // spawner both evaluates it and moves the counter. A kill below the threshold counts
+ // and buys nothing; only the one that reaches it buys a cycle.
+ if (trigger.CountsKill(in context) && spawner.RequestCycle(trigger, set.Generation, in context))
+ {
+ break;
+ }
}
}
- }
-
- ///
- /// Gets all active spawners with speech triggers in the given region.
- /// Used for optimized speech checking.
- ///
- public IEnumerable GetSpawnersWithSpeechTriggers(Map map)
- {
- foreach (var spawner in _speechTriggers.Keys)
+ finally
{
- if (spawner.Map == map)
- {
- yield return spawner;
- }
+ EndDispatch();
}
}
-
}
diff --git a/Projects/ModernSpawner/Triggers/TriggerTokens.cs b/Projects/ModernSpawner/Triggers/TriggerTokens.cs
new file mode 100644
index 0000000..912b3b6
--- /dev/null
+++ b/Projects/ModernSpawner/Triggers/TriggerTokens.cs
@@ -0,0 +1,223 @@
+using System;
+using Server.Text;
+
+namespace Server.Engines.ModernSpawner.Triggers;
+
+///
+/// The per-event-trigger tokens shared by every event trigger grammar: wake:true|false,
+/// mode:now|tick and when:<expression>.
+///
+///
+///
+/// Tokens follow a definition's positional arguments, in any order among themselves, so
+/// proximity:8:true:false:5:Player:wake:true:mode:tick and
+/// proximity:8:true:false:5:Player:mode:tick:wake:true both parse. pulls them
+/// out and hands the caller back the positional-only definition, which each trigger's Parse then
+/// splits as before.
+///
+///
+/// A token is only looked for past the caller's positional arity: within the positional range every
+/// segment is an argument, whatever it spells. That is what keeps a kill filter type or a speech
+/// keyword literally named Wake, Mode or When from being eaten as a token and
+/// silently shifting every argument after it.
+///
+///
+/// when: takes the rest of the definition, so the expression may itself contain :; it is
+/// therefore always the last token in a definition. Parsing happens once per registration, never on a
+/// dispatch path, so is free to allocate the stripped string.
+///
+///
+public static class TriggerTokens
+{
+ private const string WakeToken = "wake";
+ private const string ModeToken = "mode";
+ private const string WhenToken = "when";
+
+ ///
+ /// Reads one name:value segment as a token.
+ ///
+ ///
+ /// A recognised name is consumed even when its value is malformed - mode:sideways leaves
+ /// at its default rather than dropping sideways into the positional
+ /// arguments, where it would silently shift every later argument.
+ ///
+ /// A name:value slice of a trigger definition.
+ /// Receives the wake: value; untouched for other tokens.
+ /// Receives the mode: value; untouched for other tokens.
+ /// Receives the raw when: expression; untouched for other tokens.
+ /// True when was a token and has been consumed.
+ public static bool TryParseToken(ReadOnlySpan segment, ref bool wake, ref CycleMode mode, ref string when)
+ {
+ var separator = segment.IndexOf(':');
+ if (separator <= 0)
+ {
+ return false;
+ }
+
+ var name = segment[..separator];
+ var value = segment[(separator + 1)..];
+
+ if (name.InsensitiveEquals(WakeToken))
+ {
+ if (bool.TryParse(value, out var parsed))
+ {
+ wake = parsed;
+ }
+
+ return true;
+ }
+
+ if (name.InsensitiveEquals(ModeToken))
+ {
+ if (value.InsensitiveEquals("tick"))
+ {
+ mode = CycleMode.Tick;
+ }
+ else if (value.InsensitiveEquals("now"))
+ {
+ mode = CycleMode.Now;
+ }
+
+ return true;
+ }
+
+ if (name.InsensitiveEquals(WhenToken))
+ {
+ when = value.Length == 0 ? null : value.ToString();
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Whether starts a token. when is excluded: it swallows the rest of
+ /// the definition and is handled separately by .
+ ///
+ /// A single segment of a trigger definition.
+ /// True for wake and mode.
+ private static bool IsPairToken(ReadOnlySpan name) =>
+ name.InsensitiveEquals(WakeToken) || name.InsensitiveEquals(ModeToken);
+
+ ///
+ /// Splits into its token values and the positional-only definition the
+ /// caller's own grammar parses.
+ ///
+ /// The full trigger definition text.
+ ///
+ /// How many leading :-separated segments the caller's grammar owns, counting the type name.
+ /// Segments before that index are always arguments; only segments at or past it can be tokens.
+ ///
+ /// Receives the wake: value, left alone when the token is absent.
+ /// Receives the mode: value, left alone when the token is absent.
+ /// Receives the raw when: expression, left alone when the token is absent.
+ ///
+ /// with every token removed, or itself
+ /// when it carried none.
+ ///
+ public static string Strip(string definition, int positionalArity, ref bool wake, ref CycleMode mode, ref string when)
+ {
+ if (string.IsNullOrEmpty(definition))
+ {
+ return definition;
+ }
+
+ var span = definition.AsSpan();
+ if (span.IndexOf(':') < 0)
+ {
+ return definition;
+ }
+
+ var sb = ValueStringBuilder.CreateMT(definition.Length);
+ var stripped = false;
+ var wrote = false;
+
+ try
+ {
+ var index = 0;
+ var segmentIndex = 0;
+
+ while (index < span.Length)
+ {
+ var remaining = span[index..];
+ var separator = remaining.IndexOf(':');
+ var segment = separator < 0 ? remaining : remaining[..separator];
+
+ if (segmentIndex >= positionalArity)
+ {
+ if (segment.InsensitiveEquals(WhenToken))
+ {
+ // when: takes everything that is left, colons included.
+ TryParseToken(remaining, ref wake, ref mode, ref when);
+ stripped = true;
+ break;
+ }
+
+ if (IsPairToken(segment) && separator >= 0)
+ {
+ // The value is the next segment; the pair is contiguous in the source, so the
+ // token text is a single slice.
+ var afterName = remaining[(separator + 1)..];
+ var valueEnd = afterName.IndexOf(':');
+ var pairLength = valueEnd < 0 ? remaining.Length : separator + 1 + valueEnd;
+
+ TryParseToken(remaining[..pairLength], ref wake, ref mode, ref when);
+ stripped = true;
+ index += pairLength + 1;
+ segmentIndex += 2;
+ continue;
+ }
+ }
+
+ if (wrote)
+ {
+ sb.Append(':');
+ }
+
+ sb.Append(segment);
+ wrote = true;
+ segmentIndex++;
+
+ if (separator < 0)
+ {
+ break;
+ }
+
+ index += separator + 1;
+ }
+
+ return stripped ? sb.ToString() : definition;
+ }
+ finally
+ {
+ sb.Dispose();
+ }
+ }
+
+ ///
+ /// Appends the non-default tokens to a definition being serialized. Defaults are omitted so a
+ /// definition written before the tokens existed round-trips to exactly its old text.
+ ///
+ /// The buffer holding the positional part of the definition.
+ /// The trigger's wake: value.
+ /// The trigger's mode: value.
+ /// The trigger's raw when: expression, or null.
+ public static void Append(scoped ref ValueStringBuilder sb, bool wake, CycleMode mode, string when)
+ {
+ if (wake)
+ {
+ sb.Append(":wake:true");
+ }
+
+ if (mode == CycleMode.Tick)
+ {
+ sb.Append(":mode:tick");
+ }
+
+ if (!string.IsNullOrEmpty(when))
+ {
+ sb.Append(":when:");
+ sb.Append(when);
+ }
+ }
+}
diff --git a/Projects/ModernSpawner/Triggers/WallTimeWindowTrigger.cs b/Projects/ModernSpawner/Triggers/WallTimeWindowTrigger.cs
index 319de24..975fec5 100644
--- a/Projects/ModernSpawner/Triggers/WallTimeWindowTrigger.cs
+++ b/Projects/ModernSpawner/Triggers/WallTimeWindowTrigger.cs
@@ -5,16 +5,20 @@
namespace Server.Engines.ModernSpawner.Triggers;
///
-/// Trigger that activates based on real-world (wall clock) time windows.
+/// Gate that opens and closes on real-world (wall clock) time windows.
/// Uses ModernUO's EventScheduler for efficient event-based scheduling.
///
///
/// This trigger is for wall-clock time (real time). For in-game time
/// (day/night cycles), use .
///
-public class WallTimeWindowTrigger : ITrigger
+public class WallTimeWindowTrigger : TriggerBase
{
- public string TriggerType => "wall_time_window";
+ ///
+ public override string TriggerType => "wall_time_window";
+
+ ///
+ public override TriggerKind Kind => TriggerKind.Gate;
///
/// The time when the spawn window opens (in the specified timezone).
@@ -49,9 +53,9 @@ public class WallTimeWindowTrigger : ITrigger
///
public bool IsWindowOpen => _scheduledEvent?.IsWindowOpen ?? false;
- private ModernSpawner _spawner;
private TimeWindowScheduledEvent _scheduledEvent;
+ /// Creates a gate with the documented defaults.
public WallTimeWindowTrigger()
{
}
@@ -59,6 +63,8 @@ public WallTimeWindowTrigger()
///
/// Creates a trigger for a specific time window.
///
+ /// When the window opens.
+ /// When the window closes.
public WallTimeWindowTrigger(TimeOnly startTime, TimeOnly endTime)
{
StartTime = startTime;
@@ -68,6 +74,9 @@ public WallTimeWindowTrigger(TimeOnly startTime, TimeOnly endTime)
///
/// Creates a trigger for weekend evenings only.
///
+ /// When the window opens.
+ /// When the window closes.
+ /// A gate open on Friday, Saturday and Sunday.
public static WallTimeWindowTrigger WeekendEvenings(TimeOnly startTime, TimeOnly endTime) =>
new(startTime, endTime)
{
@@ -77,6 +86,10 @@ public static WallTimeWindowTrigger WeekendEvenings(TimeOnly startTime, TimeOnly
///
/// Creates a trigger for a specific month (seasonal events).
///
+ /// The months the gate may open in.
+ /// When the window opens.
+ /// When the window closes.
+ /// A gate open only in those months.
public static WallTimeWindowTrigger Seasonal(AllowedMonths months, TimeOnly startTime, TimeOnly endTime) =>
new(startTime, endTime)
{
@@ -86,6 +99,7 @@ public static WallTimeWindowTrigger Seasonal(AllowedMonths months, TimeOnly star
///
/// Creates a Halloween event trigger (October evenings).
///
+ /// A gate open on October evenings.
public static WallTimeWindowTrigger Halloween() =>
new(new TimeOnly(18, 0), new TimeOnly(23, 59))
{
@@ -95,20 +109,20 @@ public static WallTimeWindowTrigger Halloween() =>
///
/// Creates a Christmas event trigger (December).
///
+ /// A gate open throughout December.
public static WallTimeWindowTrigger Christmas() =>
new(new TimeOnly(0, 0), new TimeOnly(23, 59))
{
AllowedMonths = AllowedMonths.December
};
- public bool Evaluate(TriggerContext context)
- {
- return IsWindowOpen;
- }
+ ///
+ public override bool Evaluate(in TriggerContext context) => IsWindowOpen;
- public void Activate(ModernSpawner spawner)
+ ///
+ public override void Activate(ModernSpawner spawner)
{
- _spawner = spawner;
+ base.Activate(spawner);
// Create and schedule the time window event
_scheduledEvent = new TimeWindowScheduledEvent(
@@ -128,31 +142,30 @@ public void Activate(ModernSpawner spawner)
}
}
- public void Deactivate()
+ ///
+ public override void Deactivate()
{
_scheduledEvent?.Cancel();
_scheduledEvent = null;
- _spawner = null;
+ base.Deactivate();
}
- private void OnWindowOpen()
- {
- // Notify the spawner that the time window is now active
- _spawner?.OnTriggerActivated(this);
- }
+ // Gates report their edges by definition index, so the spawner can keep a set of open gates without
+ // holding trigger references.
+ private void OnWindowOpen() => Spawner?.OnGateOpened(DefinitionIndex);
- private void OnWindowClose()
- {
- // Notify the spawner that the time window has closed
- _spawner?.OnTriggerDeactivated(this);
- }
+ private void OnWindowClose() => Spawner?.OnGateClosed(DefinitionIndex);
- public string Serialize()
+ ///
+ public override string Serialize()
{
// Format: wall_time_window:startHour:startMin:endHour:endMin:allowedDays:allowedMonths:timezone
return $"wall_time_window:{StartTime.Hour}:{StartTime.Minute}:{EndTime.Hour}:{EndTime.Minute}:{(int)AllowedDays}:{(int)AllowedMonths}:{TimeZone.Id}";
}
+ /// Parses a wall-clock window definition.
+ /// The definition text.
+ /// The parsed gate.
public static WallTimeWindowTrigger Parse(string definition)
{
var parts = definition.Split(':');
diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md
index 7d1db80..78f149c 100644
--- a/dev-docs/architecture.md
+++ b/dev-docs/architecture.md
@@ -1,8 +1,7 @@
# ModernSpawner — Architecture
Status: draft v1, 2026-09-08. Describes the code as it is (§2–3) and the target design the product spec
-assumes (§4–9). Line references are to `8935ca4` and ModernUO `79e3a8e34`. Decisions are labelled with the
-IDs from `product-spec.md` §10.
+assumes (§4–9). Decisions are labelled with the IDs from `product-spec.md` §10.
## 1. System context
@@ -37,12 +36,14 @@ Everything runs on the game loop. There are no threads, locks or `Task.Run` anyw
### 2.1 Core (`Core/`)
-`ModernSpawner` derives from `Spawner` and adds 17 serialized fields: `_spawnEntries`
-(`List`, field 0), four script serials (1–4), three positioning flags (5–7), trigger
-definitions and flags (8–10), notes (11), and five fields of cycle-mode state (12–16). `ModernSpawnerEntry`
-derives from `SpawnerEntry` and adds only its 11 extra fields (0–10): scripts, delays, positioning rule,
-group, LOS, area offset, range, loot template and subgroup — the six `SpawnerEntry` fields and the
-`Disabled` flag come from the base class.
+`ModernSpawner` derives from `Spawner` and adds 22 serialized fields (v1, orders 0–21): `_spawnEntries`
+(`List`, field 0), four script serials (1–4), three positioning flags (5–7), the
+trigger definition list and the `TriggerActivated` flag (8–9), `_notes` (10), five fields of cycle-mode
+state (11–15), and the D2 trigger runtime — the pending-cycle queue (16), `MaxPendingCycles` (17), the
+refractory range and its absolute deadline (18–20) and the per-definition state list (21).
+`ModernSpawnerEntry` derives from `SpawnerEntry` and adds 12 extra fields (0–11): scripts, delays,
+positioning rule, group, LOS, area offset, range, loot template, subgroup and `NextEligible` — the six
+`SpawnerEntry` fields and the `Disabled` flag come from the base class.
**Entry ownership.** `ModernSpawner` owns `_spawnEntries` and implements the base contract over it:
`Entries` and `EntrySpan` (via `ReadOnlySpan.CastUp`) expose it to `Spawner`/`BaseSpawner`,
@@ -55,48 +56,56 @@ list. Typed conveniences (`ModernEntries`, `AddModernEntry`) remain for callers
"dual-list" design where the spawner kept its own `_spawnEntries` alongside an always-empty base
`_entries`; that design, and the bugs it caused, is history — see `docs/audit/core.md` §3.
-Spawn flow as ported: the timer calls `OnTick` → `Spawn()`, which runs the before-spawn script veto,
-defrags, then selects an entry by cycle mode (`SpawnWeightedOne` for Random/Sequential, `SpawnGroupMode`
-for Group) over `_spawnEntries` with plain `for` loops and calls the base `Spawn(entry, out flags)` for the
-chosen entry. That base call positions the entity through the entry-aware `GetSpawnPosition(entry, spawned,
-map)` override (entry `PositioningRule`, else the entry's `SpawnAreaOffset`, else the spawner's own
-positioning) and, once placed, calls `OnSpawned(entry, spawned)` to apply the entry's loot template and
-`OnSpawnScript`. On death, `OnSpawnedDeath(entry, spawned, killer)` notifies `TriggerSystem` for kill
-triggers and runs the entry's `OnDespawnScript`.
+Spawn flow: the timer calls the overridden `OnTick`, which applies the T0–T6 precedence of §5 and either
+parks (leaving the timer unarmed) or runs one cycle. `Spawn()` is no longer the tick path — it is the
+manual API (M1), trigger-bypassing and deadline-bypassing, and both it and the tick end in the same
+private cycle body. That body runs the before-spawn script veto, defrags, then selects an entry by cycle
+mode (`SpawnWeightedOne` for Random/Sequential, `SpawnAllEntries` for `AllEntries`) over `_spawnEntries`
+with plain `for` loops, filtering on `IsEligible && (bypassDeadlines || entry.IsDue(now))`, and calls the
+base `Spawn(entry, out flags)` for the chosen entry. That base call positions the entity through the
+entry-aware `GetSpawnPosition(entry, spawned, map)` override (entry `PositioningRule`, else the entry's
+`SpawnAreaOffset`, else the spawner's own positioning) and, once placed, calls `OnSpawned(entry, spawned)`
+to apply the entry's loot template and `OnSpawnScript`. Each attempt then moves the entry's own
+`NextEligible`: its full delay after a placement, `min(30s, MinDelay)` after a failure. On death,
+`OnSpawnedDeath(entry, spawned, killer)` notifies `TriggerSystem` for kill triggers and runs the entry's
+`OnDespawnScript`.
### 2.2 Triggers (`Triggers/`)
-`TriggerSystem` is a singleton registry keyed by spawner with per-type lists. Triggers are parsed from
-`type:field:field` strings stored on the spawner (`_triggerDefinitions`). Registration goes through one
-guarded helper, `ModernSpawner.EnsureTriggersActive()`, the only caller of
-`TriggerSystem.ActivateTriggers` outside the trigger system, within the engine project: it deactivates
-first and re-registers only when the spawner is running, is `TriggerActivated` and actually has
-definitions, which makes it idempotent. `ActivateTriggers` on its own is not: it *replaces*
-`_allTriggers[spawner]` with the batch it just parsed while the per-type lists it feeds
-(`_proximityTriggers`, `_speechTriggers`, …) *append*, so calling it twice duplicates dispatch and orphans
-the first batch — those triggers are no longer reachable for `Deactivate()`. `OnStarted` and
-`[AfterDeserialization]` call it, and so does every construction path that hands back an already-running
-spawner — `OnAfterDuped`, `ModernSpawnerDto.ToSpawner`, both JSON importer entry points,
-`XmlSpawnerImporter` and `XmlSpawnerMigrator` — because `BaseSpawner.Start()` only reaches `OnStarted`
-when `Running` actually flips. The same helper is the mandatory follow-up for every other list or flag change: the
-`TriggerActivated` setter calls it, and so do `TriggerConfigGump`'s add/remove handlers and the JSON
-importer's clear path. Those list mutations go only through the generated
-`AddToTriggerDefinitions`/`RemoveFromTriggerDefinitionsAt`/`ClearTriggerDefinitions` helpers, so the change
-is tracked for serialization before triggers are re-registered. Deactivation is unconditional in
-`OnStopped` (reached by `Stop()` and, through `BaseSpawner.OnDelete`, by deletion) and in `OnDelete`, and
-`XmlSpawnerMigrator` honours an explicit `Running="false"` on both node forms it reads. Wiring:
+`TriggerSystem` keeps one `TriggerSet` per registered spawner in a single dictionary, so a movement,
+speech or kill dispatch does one lookup and then walks a typed list by index. Triggers are parsed from
+`type:field:field` strings held as `List` (`{ Id, Text }`, stable `Guid.CreateVersion7`
+ids). Registration goes through one guarded helper, `ModernSpawner.EnsureTriggersActive()`, the only
+caller of `TriggerSystem.ActivateTriggers` outside the trigger system, within the engine project (tests
+call it deliberately too, to build the stale-registration cases teardown has to survive): it deactivates
+first — retiring the previous `TriggerSet`'s window timers and skill candidacy instead of leaving them
+live alongside the replacement — rebinds trigger state by id, then re-registers only when the spawner is
+not deleted, is `TriggerActivated` and actually has definitions. **Registration is independent of
+`Running`** (A1/A2 of §5): `Start()`/`Stop()` only arm or disarm the timer and never call
+`EnsureTriggersActive`, so a stopped spawner keeps hearing its own triggers and a `wake:` one can restart
+it. The mandatory callers are the `TriggerActivated` setter; `AddTriggerDefinition`/
+`RemoveTriggerDefinitionAt`/`ClearTriggerDefinitions` (the only way to mutate the definition list — they
+wrap the generated collection helpers so the change is tracked for serialization first); the deferred
+`[AfterDeserialization(false)]` load hook; and every construction path that hands back an already-running
+spawner (`OnAfterDuped`, `ModernSpawnerDto.ToSpawner`, both JSON importer entry points,
+`XmlSpawnerImporter`, `XmlSpawnerMigrator`). Wiring:
| Trigger | Source event | Wired |
|---|---|---|
-| proximity | `Item.OnMovement` (24-tile radius, engine-fixed) | yes |
-| speech | `Item.OnSpeech` (15/18-tile radius) | yes |
+| proximity | `Item.OnMovement` (24-tile radius, engine-fixed; wider ranges clamp to `Core.GlobalMaxUpdateRange`) | yes |
+| speech | `Item.OnSpeech` (15/18-tile radius); regex match has a timeout | yes |
| kill | `OnSpawnedDeath` via `BaseSpawner.NotifySpawnedDeath`, called from `BaseCreature.OnDeath` | yes, tested |
| skill | `SkillEvents.SkillUsed` → `ModernSpawnerEvents.OnSkillUsed` (players only) → `TriggerSystem.OnSkillUse` | yes, tested |
-| timeofday | 2.5 s polling timer | yes |
-| game_time_window | one transition timer | yes (wrong clock constant) |
-| wall_time_window | `EventScheduler` + `BaseScheduledEvent` subclass | yes (close-edge filter bug) |
+| game_time_window | one transition timer; `timeofday` is retired as a trigger class and parses only as an alias onto this one | yes; real time per game hour is derived from `Clock.SecondsPerUOMinute` (5 s per UO minute → 5 real minutes per game hour) |
+| wall_time_window | `EventScheduler` + `BaseScheduledEvent` subclass; day/month filters apply to the open edge only, by design | yes |
-`Trigger()` sets `_triggered` and forces a `Spawn()`; nothing reads `_triggered` to gate the timer.
+Dispatch never calls `Spawn()` directly: a match calls
+`spawner.RequestCycle(trigger, set.Generation, in context)`, which only mutates spawner state (cooldown,
+refractory, the pending-cycle queue) and asks `TriggerSystem` for a drain; the outermost dispatch runs the
+actual cycle once it returns (§5). The generation stamp is what makes a request from a registration the
+spawner has since replaced — a definition edit, a map move, a deactivation — a no-op instead of a cycle
+run against state its trigger is no longer bound to. A trigger that matches but is refused does not end
+the dispatch: the loop moves on to the next trigger until one is accepted.
### 2.3 Scripting (`Scripting/`)
@@ -134,8 +143,8 @@ Five formats:
| Format | Writer/Reader | Completeness |
|---|---|---|
| Binary world save | generator | complete; round trip tested (`Binary_RoundTrip_RebuildsSpawnedOverModernEntries`) |
-| ModernUO `SpawnerDto` JSON | `ModernSpawner.Dto.cs` | complete: base fields, `List` entries, scripts, options, trigger definitions and cycle state; round trip tested, and `ToSpawner` registers the imported triggers |
-| Own JSON `modernspawner/v1/spawner.json` | `SpawnerJsonExporter/Importer` | drops 8 entry fields, cooldowns; property syntax unusable (V-1) |
+| ModernUO `SpawnerDto` JSON | `ModernSpawner.Dto.cs` | complete: base fields (base `Group` included), `List` entries, scripts, options, trigger definitions as `{ id, text }`, `maxPendingCycles`, the refractory range and cycle state; round trip tested, and `ToSpawner` registers the imported triggers. Runtime state — queued cycles, cooldowns, kill counts, `RefractoryUntil`, entry deadlines — is world-save only and never exported |
+| Own JSON `modernspawner/v1/spawner.json` | `SpawnerJsonExporter/Importer` | drops 8 entry fields, cooldowns, and the D2 fields the DTO carries (`maxPendingCycles`, the refractory range, trigger definition ids — triggers are bare strings, so ids are minted fresh on import); property syntax unusable (V-1) |
| YAML `modernspawner/v1/script.yaml` | `ScriptYamlSerializer` | script→actions is a stub |
| XmlSpawner `.xml` | `XmlSpawnerImporter` (real layout, wrong columns), `XmlSpawnerMigrator` (imaginary layouts) | partial / dead |
@@ -151,16 +160,22 @@ an opt-in, zero-alloc counter set with seven `[ModernSpawnerPerf*` commands.
keeps `private`/`private protected`/non-virtual is unreachable. ModernUO PR #2619 widened the DTO
helpers for exactly this reason.
- **Serialization generator** must be referenced directly (`PrivateAssets="all"` upstream). Version bumps
- need `Migrations/*.vN.json` produced by `ModernUOSchemaGenerator`; the repo has none yet.
+ need `Migrations/*.vN.json` produced by `ModernUOSchemaGenerator`; the repo has seven: `ModernSpawner`
+ v0 and v1, `ModernSpawnerEntry` v0 and v1, and v0 for `PendingCycle`, `TriggerDefinition` and
+ `TriggerRuntimeState`.
- **Bootstrap order.** `EventScheduler.Configure` runs before world load, so scheduling during
deserialization is safe; `TriggerSystem` and `ScriptRegistry` instances are created in `Configure()`.
- **Hot paths.** `OnTick`→`Spawn()`, `OnMovement` (every step of every mobile within 24 tiles of a spawner
with proximity triggers), `OnSpeech`, and script execution per spawn. The closure and temp-entry
allocations the dual-list design forced on the spawn path are gone: entry selection is plain `for` loops
over `_spawnEntries` and the base `Spawn(entry, out flags)` is handed the real entry. What still
- allocates per event includes a `TriggerContext` (a class) on every proximity/speech/kill dispatch and a
- `ScriptContext` per script execution. Trigger definition strings are `Split` only in `Parse`, once at
- activation, never per event.
+ allocates per event: `TriggerContext` is a `readonly record struct` passed `in`, so proximity, speech,
+ kill and skill dispatch allocate nothing, and the skill candidate list carries each spawner's
+ `TriggerSet` so an attempt costs one dictionary lookup for the map and none per candidate. What still
+ allocates is a `ScriptContext` per script execution, and one per candidate event for a trigger that
+ carries a `when:` condition — bounded in practice by that trigger's cooldown, since an event inside it
+ never reaches the `when:` check. Trigger definition strings are `Split` only in `Parse`, once at
+ activation, never per event; `when:` expressions are compiled there too.
## 4. Target: entry ownership (D1)
@@ -272,45 +287,125 @@ differences from the original plan noted inline.
(upstream change), overridden here so `Running = …` reaches trigger activation and the
activate/deactivate scripts.
-## 5. Target: triggers (D2, D3)
-
-- **Semantics.** Two classes: *event* triggers (proximity, speech, kill, skill, external) and *gate*
- triggers (game/wall time windows). A single `_triggered` bool cannot represent this (overlapping windows,
- an event reopening a closed window, fullness closing a still-open window). Target state is a small
- state machine, to be written as a transition table and approved before Phase 2: `GateSet` (which gate
- triggers are currently open; the gate is open when the set is non-empty or there are no gate triggers),
- `PendingCycles` (event requests, bounded), and the timer. Timer ticks spawn only when the gate is open
- and (`!TriggerActivated` or `PendingCycles > 0`); an event trigger enqueues one cycle (D2's "one cycle")
- and does not latch. Manual `Spawn()`, `Respawn()`, indexed spawn and inter-spawner `spawn()` bypass the
- gate explicitly and say so. Persisted: gate-trigger definitions, `PendingCycles`, cooldown deadlines and
- kill counters as absolute timestamps/ints; never timer tokens. Recomputed on load: window open/closed
- state (including overnight, day/month filters, time zone and DST).
-- **Liveness.** Three independent things: administrative enablement (`Running`, controls the timer),
- trigger registration (whenever `TriggerActivated` and not deleted, independent of `Running`), and the gate.
- A stopped spawner keeps its triggers registered; an event on a stopped spawner enqueues a cycle but does not
- start the timer unless the trigger definition says `wake:true`. Registration moves to the deferred
- `[AfterDeserialization(false)]` hook.
+## 5. Triggers (D2, D3) — as built
+
+- **Two trigger classes.** *Gate* triggers (`wall_time_window`, `game_time_window`) open and close a
+ spawner's gate; `timeofday` is retired as a class and parses only as an alias onto `game_time_window`.
+ *Event* triggers (proximity, speech, kill, skill, and the external `Trigger()` source) each buy at most
+ one spawn cycle per accepted match and never latch.
+- **State lives on the spawner**, not the trigger system, so a tick compares fields and does no lookup:
+ the open-gate set (a `ulong` bitmask over definition index, with a `List` overflow past 64), a
+ bounded FIFO of pending cycles (`PendingCycle { TriggerId, TriggeringMobile }`, `0..MaxPendingCycles`
+ entries, default 1), a spawner-wide `RefractoryUntil`, per-definition `TriggerRuntimeState
+ { CooldownUntil, KillCount }` keyed by the definition's stable `Id`, and a per-entry `NextEligible`
+ deadline with failure backoff.
+- **Tick authorization** (`IsAuthorizedForTick`): `Running && !Deleted && GateOpen && !IsFull &&
+ (EventCount == 0 || PendingCycleCount > 0)`. `GateOpen` is true whenever the spawner has no gates, is not
+ `TriggerActivated`, or at least one window is currently open.
+- **Tick precedence** (`OnTick`, evaluated top to bottom, first match wins) and the rest of the state
+ machine, by row id (T = tick, E = event acceptance, D = drain, G = gate edge, A = administration,
+ M = manual API, L = load, X = delete, F = base arms a tick):
+
+ | Row | Meaning |
+ |---|---|
+ | T0 | Deleted or not running: nothing, never re-arm |
+ | T1 | Base `Group`: any spawn alive → park until removed; else if authorized → consume one pending slot (if any), run the bulk group respawn once, re-arm |
+ | T2 | Gate closed: park until a gate opens |
+ | T3 | Full: park until a spawn is removed |
+ | T4 | Event-sourced with nothing queued: park until an event |
+ | T5 | No entry due: arm at the earliest future entry deadline |
+ | T6 | Otherwise: pop one slot if event-sourced, run the cycle, re-arm at the earliest deadline |
+ | E0 | Event candidate not accepted: no state change |
+ | E1 | Accepted, `mode:now`, running/open/not full: push a slot; drain once the dispatch that raised it returns |
+ | E2 | Accepted, `mode:now`, gate closed or full: `Max > 0` → push and wait (T2/T3 arm later); `Max = 0` → drop |
+ | E3 | Accepted, `mode:tick`: push a slot; arm an immediate tick so it runs with normal T0–T6 ordering |
+ | E4 | Accepted, stopped, `wake:true`: push a slot, `Start()`; run as E1/E2 if it started, else keep the slot |
+ | E5 | Accepted, stopped, `wake:false`: `Max > 0` → push; `Max = 0` → drop |
+ | E6 | Queue already at `Max`: reject before any acceptance side effect |
+ | D1 | Drain requested but deleted/stopped/deactivated/gate-closed/full: discard the slot if deleted/deactivated, else keep it |
+ | G1 | Gate opens, `0 → 1`, running: add the id; run a cycle now (event-free) or drain one queued slot (event-sourced); re-arm |
+ | G2 | Gate opens, already open or the set stays non-empty: add the id only |
+ | G3 | Gate closes, `1 → 0`: remove the id; live spawns and queued cycles are untouched |
+ | G4 | Gate closes, id absent/stale: no-op |
+ | A1 | `Start()`: arms the timer only; never reparses or re-registers |
+ | A2 | `Stop()`: stops the timer only; registration, cooldowns, counters and gate schedulers stay live |
+ | A3 | `TriggerActivated` false→true, definitions change, or map/location change: (re)register, hydrate the gate set and counts silently, rebind state by id |
+ | A4 | `TriggerActivated` true→false: unregister; clears the gate set, the queue and any pending drain |
+ | A5 | `MaxPendingCycles` changed: clamp ≥ 0; lowering trims the oldest queued slots |
+ | M1 | Manual `Spawn()`/indexed `Spawn(entry)`/script `spawn()`: bypasses triggers and entry deadlines; queue and cooldowns untouched |
+ | M2 | `Respawn()`: trigger-bypassing bulk op; queue untouched; re-arms only if running |
+ | M3 | `Reset()`: stops and clears spawns; clears the queue, cooldowns, refractory and kill counters; keeps registration |
+ | M4 | `ResetTrigger()`: clears the queue and cancels any pending drain |
+ | L1 | Load, activated and not deleted: rebind state by id, clamp the queue, hydrate the gate set from the clock, register; no spawn during load |
+ | L2 | Load, deactivated or deleted: no registration; queue cleared |
+ | X1 | Delete: invalidate the registration first, cancel timers and drains, unregister after lifecycle scripts |
+ | F1 | A spawn is removed or `Count` changes: the resulting tick follows T0–T6 as usual |
+
+- **Acceptance order** (`RequestCycle`, every check runs before any state change): registration live →
+ the trigger matches (kill triggers evaluate here, since their threshold is spawner state; every other
+ class arrives already evaluated by its dispatcher) → the trigger's own `CooldownUntil` has passed → the
+ spawner's `RefractoryUntil` has passed → the `when:` expression passes → the queue has room, or
+ (`MaxPendingCycles == 0`) the cycle can run right now. Only an accepted event moves the cooldown and the
+ refractory, together.
+- **Drain after dispatch.** A trigger match never calls `Spawn()`. `RequestCycle` mutates state and asks
+ `TriggerSystem` to drain the spawner; the outermost dispatch (proximity, speech, kill, skill, or a
+ script's `spawn()`) runs the actual cycle once it returns. A cycle's own scripts can raise further
+ events; those queue into the same drain list rather than nesting, bounded by 10 drains per spawner per
+ outer round (matching the script recursion limit) and by a per-spawner re-entry guard.
+- **Registration is independent of `Running`.** `Start()`/`Stop()` only arm or disarm the timer; the
+ `TriggerActivated` setter, the definition-list mutators (`AddTriggerDefinition`/
+ `RemoveTriggerDefinitionAt`/`ClearTriggerDefinitions` — the only way to change the list), load and
+ delete are what (re)register. A stopped spawner keeps hearing its own triggers, and an event with
+ `wake:true` can restart it.
+- **Rulings that affect semantics.** The kill counter advances on every counted kill, including while its
+ trigger is on cooldown, and resets only when a cycle is accepted (otherwise `kill:N>1` could never fire).
+ `mode:` is ignored when `MaxPendingCycles = 0` — there is no slot to defer into, so the event runs now or
+ is dropped; the migrator compensates by mapping XmlSpawner's `SpawnOnTrigger=false` to
+ `MaxPendingCycles = 1` plus `mode:tick`. Only the `mode:now` drain path bypasses per-entry deadlines — a
+ `mode:tick` slot later consumed by a `mode:now` drain still gets the bypass, and a gate's window-open
+ cycle (G1) honours deadlines like a timer cycle. On a base-`Group` spawner, T1's "any spawn alive → park,
+ else one bulk respawn" applies identically whether the cycle source is the tick, a drained slot, or a
+ gate opening. `wake:`/`mode:`/`when:` are recognised only as a suffix after each grammar's full
+ positional list, so a positional value spelled like a token is never misread.
+- **Shape.** `TriggerSet` (one per registered spawner, in one dictionary keyed by spawner) replaces six
+ per-type dictionaries; gate and pending-cycle state stay on the spawner so ticks do no lookup.
+ `TriggerContext` is a `readonly record struct` passed `in`; `ITrigger.Evaluate` is pure (reads
+ `TriggerRuntimeState` but never writes it) and contains no iterator (`yield`). Extended proximity clamps
+ to `Core.GlobalMaxUpdateRange` with a warning.
- **Kill.** `CreatureEvents.CreatureDeathEvent` fires *after* `Mobile.OnDeath`, which deletes non-player
- mobiles and clears `Spawner` on the way (`Mobile.cs:4647,4899`), so `bc.Spawner` is null by then. An
- upstream PR adds `protected virtual void OnSpawnedDeath(SpawnerEntry entry, ISpawnable spawned, Mobile killer)`
- on `BaseSpawner`, invoked from `BaseCreature.OnDeath` before base death while the link is intact. Death is
- distinct from removal (taming, pickup, delete). `RequireAllDead` is evaluated after removal against the
- entry's remaining live count.
+ mobiles and clears `Spawner` on the way, so `bc.Spawner` is null by then. `BaseSpawner.OnSpawnedDeath(entry,
+ spawned, killer)` (an upstream hook, merged) is invoked from `BaseCreature.OnDeath` before base death
+ runs, while the link is intact. Death is distinct from removal (taming, pickup, delete). Because the
+ notification precedes removal, `RequireAllDead` is evaluated against the spawner's live count with the
+ dying spawn excluded — otherwise the kill that actually clears the pack could never satisfy it.
- **Skill.** `SkillCheck`'s four `Mobile_SkillCheck*` handlers raise `SkillEvents.SkillUsed(Mobile, Skill,
bool success)` once per attempt (short-circuited attempts included; not raised when the mobile lacks the
skill). `ModernSpawnerEvents.OnSkillUsed` forwards only players (`mobile is { Player: true }`) to
`TriggerSystem.OnSkillUse`, which pre-scans `SkillTrigger.MatchesSkill` before allocating a
- `TriggerContext` so spawners with no matching trigger allocate nothing (a deleted check, a map compare, a
- registry lookup and a linear scan of their trigger list). `SkillTrigger` adds an outcome filter (any/success/failure) and a min/max
- skill-value window on top of range and line-of-sight. Line of sight is `Mobile.InLOS`: `CanSee` ends in
- `Item.Visible`, which a spawner never is. Dispatch iterates a pooled snapshot of the registration map,
- because `Trigger()` reaches `Spawn()` and a script there can delete or register a spawner.
-- **Grammar.** One definition grammar owned by each trigger's `Serialize()`. Gumps and importers construct
- trigger objects. `TriggerContext` becomes a `readonly record struct`.
-- **Extended proximity.** Clamp to `Core.GlobalMaxUpdateRange` with a warning; the sector-range
- subscription API stays a tracked ModernUO prerequisite.
-- **Clock.** `RealTimePerGameHour = TimeSpan.FromSeconds(Clock.SecondsPerUOMinute * 60)`; recompute on map
- change. `timeofday` retired.
+ `TriggerContext` so spawners with no matching trigger allocate nothing. `SkillTrigger` adds an outcome
+ filter (any/success/failure) and a min/max skill-value window on top of range and line-of-sight. Line of
+ sight is `Mobile.InLOS`: `CanSee` ends in `Item.Visible`, which a spawner never is. Dispatch iterates a
+ pooled snapshot of the registration map, because a script reached from `Trigger()`'s drain can delete or
+ register a spawner.
+- **Grammar.** One definition grammar per trigger's `Serialize()`; gumps and importers construct trigger
+ objects and call it. The per-event-trigger tokens (`wake:true|false`, `mode:now|tick`, `when:`) are
+ a suffix recognised only after the grammar's full positional list.
+- **Clock.** `timeofday` is retired and parses only as an alias onto `game_time_window`. The game-time
+ window derives real time per game hour from `Clock.SecondsPerUOMinute` (5 s per UO minute, so a game
+ hour is 5 real minutes and a game day two real hours) rather than restating it. A map change
+ re-registers a spawner's triggers, so a gate recomputes its window against the new map's clock and
+ skill candidacy moves with it.
+- **Performance** (D2's own condition: no per-tick/per-movement regression at 12k+ spawners). Measured in
+ `Release`, 12,000 spawners on one map, one player walking a 2,000-step lap: movement-dispatch steady
+ state was ~144–162 ns/call and 72 B/call before D2, ~131–133 ns/call and 0 B/call after; ticking the
+ whole population (gate closed or open, queues empty) costs roughly 49–70 ns/call, 0 B/call either way.
+ BenchmarkDotNet micro-benchmarks (mock types, no ModernUO reference) cover `TriggerSet` lookup and the
+ request/drain path in isolation. Run the world-backed harness with:
+ ```sh
+ MODERNSPAWNER_PERF=1 dotnet test Projects/ModernSpawner.Tests \
+ --filter "FullyQualifiedName~TriggerPerfHarness" --logger "console;verbosity=detailed"
+ ```
+ and the micro-benchmarks with `dotnet run -c Release --project Projects/ModernSpawner.Benchmarks --triggers`.
## 6. Target: one script language (D4)
@@ -349,9 +444,11 @@ carried across `Timer.DelayCall`; mutation-safe iteration and registration befor
## 7. Target: persistence and export (D5, D6)
- **One export format**: ModernUO `SpawnerDto`. `ModernSpawnerDto` gains `List`
- (all entry fields), `triggers: List`, `cycleMode`, `currentSubgroup`, `sequentialResetTime/To`,
- `holdSequence`. `WhenWritingDefault` defaults handled with `ShouldSerialize` modifiers like upstream
- `homeRange`. Own JSON and YAML removed with their gumps' import/export switched to the DTO path.
+ (all entry fields), `triggers: List<{ id, text }>` (the pre-id shape, a bare definition string, still
+ reads), `maxPendingCycles`, `refractoryMin`/`refractoryMax`, `cycleMode`, `currentSubgroup`,
+ `sequentialResetTime/To`, `holdSequence`. `WhenWritingDefault` defaults handled with `ShouldSerialize`
+ modifiers like upstream `homeRange`. Own JSON and YAML removed with their gumps' import/export switched
+ to the DTO path.
- **Entry `Properties`** use ModernUO's `Name Value` syntax everywhere. `PropertyBuilderGump` emits it;
`{min-max}` ranges become `target.Str = random(80, 120)` in the entry spawn script.
- **Script registry**: remove-on-delete; entry scripts cached by source hash.
@@ -405,12 +502,13 @@ carried across `Timer.DelayCall`; mutation-safe iteration and registration befor
## 11. ModernUO prerequisites created by this design
-Tracked in `modernuo-prerequisites.md`: DTO helper visibility (done), abstract entry ownership (§4.2),
-`OnStarted/OnStopped` and `OnConfigureSpawned` virtuals, `OnSpawnedDeath` hook, `SkillEvents.SkillUsed`
-(done, #2636) with `InternalsVisibleTo("ModernSpawner.Tests")` on `Server.csproj` (so the test fixture can
-seed `Core._now`), GUID-based replacement in `[ImportSpawners` (today it deletes co-located same-type
-spawners and calls `Respawn()` unconditionally, `ImportSpawnersCommand.cs:259`), sector-range movement
-subscription (deferred).
+Tracked in `modernuo-prerequisites.md`: DTO helper visibility (done), abstract entry ownership (§4.2,
+done), `OnStarted/OnStopped` and `OnConfigureSpawned` virtuals (done), `OnSpawnedDeath` hook (done),
+`SkillEvents.SkillUsed` (done, #2636) with `InternalsVisibleTo("ModernSpawner.Tests")` on `Server.csproj`
+(so the test fixture can seed `Core._now`), virtual `BaseSpawner.OnTick` and `group` in the stock DTO
+(done, #2640 — §5 needs a tick gate that does not also gate manual `Spawn()`), GUID-based replacement in
+`[ImportSpawners` (today it deletes co-located same-type spawners and calls `Respawn()` unconditionally,
+`ImportSpawnersCommand.cs:259`), sector-range movement subscription (deferred).
## 12. Review history
diff --git a/dev-docs/modernuo-prerequisites.md b/dev-docs/modernuo-prerequisites.md
index 898ee18..8226faf 100644
--- a/dev-docs/modernuo-prerequisites.md
+++ b/dev-docs/modernuo-prerequisites.md
@@ -17,6 +17,7 @@ per-movement paths without a measurement, because shards run 12k+ spawners.
| [#2619](https://github.com/modernuo/ModernUO/pull/2619) | `BaseSpawner.Dto.cs`: `private protected` DTO helpers → `protected` | `ModernSpawner.ToDto()` lives in another assembly and needs the `Dto*` helpers and `BoundsFromHomeRange` |
| [#2621](https://github.com/modernuo/ModernUO/pull/2621) | Subclass-owned entries (`BaseSpawner` v13 / `Spawner` v2 owner contract), lifecycle hooks + `NotifySpawnedDeath`, `SpawnerEntry` v2 `Disabled`, DTO records own `entries`, save migration | D1/D11/D12: `ModernSpawner : Spawner` owns `List` with `ModernSpawnerEntry : SpawnerEntry`; kill trigger via the death hook. The ModernSpawner side is ported in [ModernSpawner #1](https://github.com/modernuo/ModernSpawner/pull/1); submodule at `a52ce6ef7` |
| [#2636](https://github.com/modernuo/ModernUO/pull/2636) | `SkillEvents.SkillUsed` (`Action`, `Server.Misc`) raised once per attempt from the four `Mobile_SkillCheck*` handlers; `InternalsVisibleTo("ModernSpawner.Tests")` on `Server.csproj` | D3 skill triggers subscribe cross-assembly; the test fixture seeds `Core._now`; submodule at `309fcfeb2` |
+| [#2640](https://github.com/modernuo/ModernUO/pull/2640) | `BaseSpawner.OnTick` made `virtual`; `SpawnerDto`/`ApplyDto` carry the `group` flag | D2 needs a tick gate that does not also gate manual `Spawn()` — the non-virtual `OnTick` called the same virtual `Spawn()` every manual path uses; `group` was binary-persisted but absent from the DTO. Submodule at `c02909e2c` |
## Planned (see `architecture.md` §4–§5, §11; decisions D1, D2, D3, D11, D12)
diff --git a/dev-docs/product-spec.md b/dev-docs/product-spec.md
index 71b0978..360e425 100644
--- a/dev-docs/product-spec.md
+++ b/dev-docs/product-spec.md
@@ -91,11 +91,11 @@ Status columns reflect the audit at `8935ca4`. "Target" is the v1 commitment.
| Skill | Stubbed | Wired to ModernUO's `SkillEvents.SkillUsed`, forwarded to players only (**D3**); outcome (any/success/failure) and min/max value-window semantics; `RequireLOS` is line of sight (`Mobile.InLOS`), not visibility; grammar owned by `SkillTrigger.Serialize()` |
| Game-time window | Partial | Constant derived from `Clock.SecondsPerUOMinute`; recomputed on map change |
| Wall-clock window | Partial | Day/month filters apply to the open edge only; weekly/monthly recurrence exposed |
-| Legacy `timeofday` | Implemented | Retired in favour of `game_time_window` (importer maps to it) |
-| Gate vs fire semantics | Incoherent | Defined in **D2** as a state machine (gate set + pending cycles), transition table approved before implementation |
-| Triggers on stopped spawners | Missing | Registered whenever `TriggerActivated`, independent of `Running`; events on a stopped spawner queue a cycle and only start the timer if the trigger says `wake:true` |
-| Composition (AND/OR) | Missing | v1: implicit OR across triggers plus a per-trigger `when:` expression; explicit AND groups deferred |
-| Definition grammar | Three producers disagree | One grammar; gumps and importers construct trigger objects and call `Serialize()` |
+| Legacy `timeofday` | Implemented | Retired as a trigger class; parses only as an alias onto `game_time_window` (importer maps to it) |
+| Gate vs fire semantics | Incoherent | Implemented as a state machine (gate set + bounded pending-cycle queue); tick precedence and event/administration transition table in `architecture.md` §5 |
+| Triggers on stopped spawners | Missing | Implemented: registered whenever `TriggerActivated`, independent of `Running`; an event on a stopped spawner queues a cycle and only starts the timer when the trigger carries `wake:true` |
+| Composition (AND/OR) | Missing | Implemented as scoped for v1: implicit OR across triggers plus a per-trigger `when:` expression; migration folds XmlSpawner's conjunctive proximity+speech+property triggers into one `speech` trigger with a `when:` condition; explicit AND groups still deferred |
+| Definition grammar | Three producers disagree | Implemented: one grammar per trigger's `Serialize()`; `wake:`/`mode:`/`when:` tokens are a suffix recognised only after each grammar's positional list; gumps and importers construct trigger objects through the definition-list wrappers rather than writing strings directly |
### 5.3 Scripting
@@ -206,7 +206,7 @@ stated conditions.
|---|---|---|---|
| **D0** | Distribution: source submodule vs DLL | Source submodule for v1; DLL later | Open (default assumed) |
| **D1** | Entry ownership | Change ModernUO: abstract entry ownership (`architecture.md` §4), including any streamlining of `BaseSpawner` that makes it more agnostic. **Condition:** no performance regression; trade-offs reported before merge | **Ruled** |
-| **D2** | Trigger semantics | State machine: gate set (windows) + bounded pending-cycle queue (events); `architecture.md` §5. **Condition:** no per-tick/per-movement cost growth at 12k+ spawners; implementation reviewed | **Ruled** |
+| **D2** | Trigger semantics | State machine: gate set (windows) + bounded pending-cycle queue (events); `architecture.md` §5. **Condition:** no per-tick/per-movement cost growth at 12k+ spawners; implementation reviewed | **Ruled and implemented** (perf condition met) |
| **D3** | Skill trigger source | Upstream `SkillEvents.SkillUsed` hook (ModernUO #2636) | **Ruled** |
| **D4** | Script language | Retire ModernSpawner's current `SET/Hits/100` command syntax (a copy of XmlSpawner's style, not XmlSpawner itself); add statements and actions on top of the existing, tested expression engine rather than writing a new engine | **Ruled** (retire); statement design pending review |
| **D5** | Canonical export format | ModernUO `SpawnerDto`; own JSON and YAML removed; generalise upstream where needed | **Ruled** |
diff --git a/dev-docs/xmlspawner-migration.md b/dev-docs/xmlspawner-migration.md
index 9eee995..42eb901 100644
--- a/dev-docs/xmlspawner-migration.md
+++ b/dev-docs/xmlspawner-migration.md
@@ -74,25 +74,26 @@ older RunUO XmlSpawner2 exports may differ and are reported, not silently accept
| `MinDelay`, `MaxDelay`, `DelayInSec` | `minDelay`, `maxDelay` | convert to TimeSpan |
| `Team` | `team` | copy |
| `WayPoint` | `wayPoint` (base) | name or `SERIAL,n`; resolve at import; warn if missing |
-| `IsGroup` | `cycleMode = Group` | XmlSpawner "group" = respawn all when all dead; maps to Group mode, base `Group` left false |
+| `IsGroup` | base `Group` | XmlSpawner "group" = respawn all when all dead; maps to base `Group` (all-dead-then-bulk-respawn), not the `AllEntries` cycle mode |
| `IsRunning` | `running` | copy (DTO needs a `running` field — add) |
| `SequentialSpawning` (≥0) | `cycleMode = Sequential`, `currentSubgroup` | value is the starting subgroup |
| `Amount` | — | stack amount for item spawns; emit `target.Amount = n` in entry script when > 1 |
-| `ProximityRange` (≥0) | trigger `proximity:range` | with `AllowGhostTriggering`, `AllowNPCTriggering` → `playersOnly` = !NPC; ghost flag unsupported → warn |
+| `ProximityRange` (≥0) | trigger `proximity:range`, or the range folded into a `speech` trigger when `SpeechTrigger` is also present | with `AllowGhostTriggering`, `AllowNPCTriggering` → `playersOnly` = !NPC; ghost flag unsupported → warn |
| `ProximityTriggerSound`, `ProximityTriggerMessage` | trigger `onTriggered` feedback: `sound(id)`, `msg(trigMob, "text")` | fires on an *accepted* trigger with the triggering mobile (`XmlSpawner.cs:2324`), not on activate |
| `TriggerProbability` (fraction) | spawner-level trigger `chance` | one roll per accepted trigger, not per trigger type |
-| `SpeechTrigger` | trigger `speech:text` | one case-insensitive substring match (`XmlSpawner.cs:2385`); do not split on commas |
+| `SpeechTrigger` | trigger `speech:text`, folding `ProximityRange` (or a default range) and `PlayerPropertyName` into the same definition | one case-insensitive substring match (`XmlSpawner.cs:2385`); do not split on commas; XmlSpawner tests proximity, speech and the player property conjunctively, so all three become one `speech` trigger with a `when:` condition rather than independent (effectively OR'd) triggers |
| `SkillTrigger` | trigger `skill:[+\|-]::[-]::` | XmlSpawner syntax `SkillName[+/-][,min,max]` (`+` success only, `-` failure only) maps directly onto the outcome suffix and value window; `` is the node's `ProximityRange` (10 when absent); XmlSpawner's own skill trigger never fired (verified in both the ServUO sources and the ModernUO port: the parsed skill-trigger fields are declared and read, but never assigned), so there is no runtime behaviour to preserve — only the intended semantics carry over |
-| `TODStart`, `TODEnd`, `TODMode` | `game_time_window` (mode 1) / `wall_time_window` (mode 0) | minutes → hour:minute |
-| `MinRefractory`, `MaxRefractory` | spawner-level trigger refractory `random(min,max)` | belongs to the spawner's accepted-trigger state, not to each translated trigger |
+| `TODStart`, `TODEnd`, `TODMode` | `game_time_window` (mode 1) / `wall_time_window` (mode 0) gate | minutes → hour:minute; XmlSpawner despawned live spawns when the window closed, D2 keeps them running (lifetimes are D10) — reported as a difference |
+| `MinRefractory`, `MaxRefractory` | spawner-wide `RefractoryMin`/`RefractoryMax` | minutes → `TimeSpan`; belongs to the spawner's accepted-event state (rolled once per accepted event), not to each translated trigger |
| `KillReset` | kill trigger `resetAfterTicks` | count of spawn ticks without a kill before the kill counter resets (`XmlSpawner.cs:6735`) — add field or warn |
| `TickReset` | `disableGlobalAutoReset` | XmlSpawner semantics; drop only with a warning, never silently |
-| `Duration` (minutes) | entry `despawnAfter` | per-spawn lifetime; **not in ModernSpawner today** — add per-entry despawn timer or warn |
+| `Duration` (minutes) | — | per-spawn lifetime; **not in ModernSpawner today** (D10, still open) — reported in the migration report and dropped, not approximated |
| `DespawnTime` (hours) | spawner `despawnWhenIdle` | spawner-level despawn when no players nearby; distinct mechanism — warn in v1 |
| `ExternalTriggering` | `triggerActivated = true` with no event triggers | external only via `Trigger()` |
-| `SpawnOnTrigger` | trigger fires an immediate spawn cycle | XmlSpawner conditions are **conjunctive** (running, TOD, refractory, external, speech, property all checked together, `XmlSpawner.cs:2236`); translated triggers must reproduce that with a per-spawner condition set, not independent OR triggers |
+| `SpawnOnTrigger` | `mode:tick` + `MaxPendingCycles = 1`, or `MaxPendingCycles = 0` | `False` defers the accepted event to the next tick behind a one-slot queue (`mode:tick`); absent or `True` reproduces XmlSpawner's own run-now-or-drop semantics (`MaxPendingCycles = 0`, never latches) |
| `RegionName` | trigger/positioning `region:name` | positioning rule `region` (planned) |
-| `ObjectPropertyItemName/Name`, `SetPropertyItemName`, `Item/NoItem/Mob/Player*TriggerName/PropertyName` | property triggers | **unsupported** (PropertyTrigger removed by design) → warn and drop, include the expression in the report |
+| `PlayerPropertyName` | per-trigger `when:` expression on the folded `speech`/`proximity` trigger (§8) | translated where the property test allows it; an untranslatable expression is reported and the trigger is migrated without its `when:` condition, not dropped |
+| `ObjectPropertyItemName/Name`, `SetPropertyItemName`, `Item/NoItem/Mob/Player*TriggerName` | property triggers | **unsupported** (PropertyTrigger removed by design) → warn and drop, include the expression in the report. `PlayerTriggerName` belongs here too: it names a specific player who may trigger the spawner, which no D2 trigger class expresses |
| `InContainer`, `Container*` | container spawning | unsupported → warn |
| `ConfigFile`, `TickReset` | — | drop silently |