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} + Felucca + 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> _skillCandidates = new(); + // Reused across drains: the outer dispatch clears it rather than releasing it, so the steady state + // allocates nothing. + private readonly List _drainList = []; + + private int _dispatchDepth; + private bool _draining; + private int _generation; + + /// Registers the built-in trigger factories. public TriggerSystem() { // Register built-in trigger types @@ -38,19 +52,22 @@ public TriggerSystem() RegisterTriggerType("kill", KillTrigger.Parse); RegisterTriggerType("skill", SkillTrigger.Parse); - // Time-based triggers (legacy polling-based) - RegisterTriggerType("timeofday", TimeOfDayTrigger.Parse); - - // New efficient event-based time triggers + // Event-based time gates RegisterTriggerType("wall_time_window", WallTimeWindowTrigger.Parse); RegisterTriggerType("game_time_window", GameTimeWindowTrigger.Parse); + + // "timeofday" is retired as a class and survives only as an alias onto the game-time window, so + // saved worlds, exports and XmlSpawner imports carrying the old text keep parsing. + RegisterTriggerType("timeofday", GameTimeWindowTrigger.ParseLegacyTimeOfDay); } + /// public void RegisterTriggerType(string triggerType, Func factory) { _factories[triggerType] = factory; } + /// public ITrigger ParseTrigger(string definition) { if (string.IsNullOrWhiteSpace(definition)) @@ -87,6 +104,18 @@ public ITrigger ParseTrigger(string definition) return null; } + /// + public TriggerSet GetSet(ModernSpawner spawner) + { + if (spawner == null) + { + return null; + } + + return _sets.GetValueOrDefault(spawner); + } + + /// public void ActivateTriggers(ModernSpawner spawner) { // Parse and activate all trigger definitions @@ -96,21 +125,56 @@ public void ActivateTriggers(ModernSpawner spawner) return; } - var triggers = new List(); + var set = new TriggerSet { Generation = ++_generation }; - foreach (var definition in definitions) + for (var i = 0; i < definitions.Count; i++) { - var trigger = ParseTrigger(definition); - if (trigger != null) + var definition = definitions[i]; + var trigger = ParseTrigger(definition.Text); + if (trigger == null) { - trigger.Activate(spawner); - triggers.Add(trigger); + continue; } + + // Bind identity before anything can fire: a gate's Activate can open its window inside this + // call, and it reports that edge by definition index. + trigger.Id = definition.Id; + trigger.DefinitionIndex = i; + trigger.State = spawner.GetOrCreateTriggerState(definition.Id); + + set.Add(trigger); + } + + if (set.All.Count == 0) + { + return; } - if (triggers.Count > 0) + // Register before activating: a gate that opens during Activate runs spawner code, and that code + // must see a consistent registry - including the counts and the generation its tick guards and + // its acceptance path read. + _sets[spawner] = set; + spawner.SetRegistration(set.Generation, set.EventCount, set.GateCount); + spawner.SetHasProximityTriggers(set.Proximity.Count > 0); + spawner.SetHasSpeechTriggers(set.Speech.Count > 0); + + if (set.Skill.Count > 0) + { + FileSkillCandidate(spawner, set); + } + + var triggers = set.All; + for (var i = 0; i < triggers.Count; i++) { - _allTriggers[spawner] = triggers; + // Activating one trigger can re-enter and replace this whole registration (a gate opening + // runs a cycle, a script deletes or reconfigures the spawner). Once that has happened the + // rest of this batch belongs to a registration that no longer exists. + if (!_sets.TryGetValue(spawner, out var current) || current != set) + { + return; + } + + triggers[i].Activate(spawner); } } @@ -118,249 +182,236 @@ public void ActivateTriggers(ModernSpawner spawner) /// Whether currently has triggers registered with this system, i.e. whether /// has run for it without a matching . /// - 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 |