From d7e05f43f3d0aba1deb1224af944c1aafa7fc054 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:29:30 -0700 Subject: [PATCH 01/15] feat(triggers): stable trigger ids, runtime state and pending slots; serialization v1 Trigger definitions gain identity. `List` becomes `List` ({ Guid Id (v7), string Text }), so per-trigger runtime state and queued cycles can name the definition they belong to across saves, gump reorders and DTO round trips. ModernSpawner moves to SerializationGenerator(1): the definition list is typed, the one-bit `_triggered` flag is gone, and the slots after it shift down by one. New fields carry the state the D2 machine needs: a bounded `PendingCycles` queue whose slots carry the triggering mobile's serial, `MaxPendingCycles` (default 1; 0 is XmlSpawner run-now-or-drop), a spawner-wide refractory (min, max, absolute until), and `TriggerStates` keyed by definition id. ModernSpawnerEntry moves to version 1 with an absolute `NextEligible` deadline. Both get `MigrateFrom(V0Content)`; schemas regenerated, and the schema generator is now pinned in .config/dotnet-tools.json. The generated collection helpers for the definition list are private - they cannot mint an id - so AddTriggerDefinition / RemoveTriggerDefinitionAt / ClearTriggerDefinitions are the only way in, each marking the spawner dirty and re-registering through EnsureTriggersActive, which now also re-binds state by id and drops the state and pending slots of definitions that went away. Runtime state is world-save only: the DTO exports definitions as { id, text } plus the limits and nothing else. SpawnCycleMode.Group is renamed AllEntries, with Group kept one release as an obsolete alias so exported JSON still parses. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .config/dotnet-tools.json | 12 + .../Core/ModernSpawnerLifecycleTests.cs | 319 ++++++++++++++- .../ModernSpawnerTriggerRegistrationTests.cs | 24 +- .../Core/SkillTriggerTests.cs | 22 +- .../SpawnerJsonRoundTripTests.cs | 19 +- .../XmlSpawnerLegacyMappingTests.cs | 6 +- .../ModernSpawner/Core/ModernSpawner.Dto.cs | 84 +++- .../Core/ModernSpawner.Migrations.cs | 56 +++ Projects/ModernSpawner/Core/ModernSpawner.cs | 378 ++++++++++++++++-- .../ModernSpawner/Core/ModernSpawnerEntry.cs | 36 +- Projects/ModernSpawner/Core/SpawnCycleMode.cs | 12 +- .../ModernSpawner/Gumps/TriggerConfigGump.cs | 24 +- .../Migration/XmlSpawnerMigrator.cs | 8 +- ...ngines.ModernSpawner.ModernSpawner.v0.json | 140 +++++++ ...ngines.ModernSpawner.ModernSpawner.v1.json | 179 +++++++++ ...s.ModernSpawner.ModernSpawnerEntry.v0.json | 88 ++++ ...s.ModernSpawner.ModernSpawnerEntry.v1.json | 97 +++++ ...odernSpawner.Triggers.PendingCycle.v0.json | 19 + ...Spawner.Triggers.TriggerDefinition.v0.json | 19 + ...awner.Triggers.TriggerRuntimeState.v0.json | 27 ++ .../ModernSpawner/Perf/SpawnerPerfCommands.cs | 4 +- .../Serialization/SpawnerJsonExporter.cs | 4 +- .../Serialization/SpawnerJsonImporter.cs | 13 +- .../Serialization/XmlSpawnerImporter.cs | 8 +- .../ModernSpawner/Triggers/PendingCycle.cs | 52 +++ .../Triggers/TriggerDefinition.cs | 60 +++ .../Triggers/TriggerRuntimeState.cs | 58 +++ .../ModernSpawner/Triggers/TriggerSystem.cs | 4 +- 28 files changed, 1651 insertions(+), 121 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 Projects/ModernSpawner/Core/ModernSpawner.Migrations.cs create mode 100644 Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawner.v0.json create mode 100644 Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawner.v1.json create mode 100644 Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawnerEntry.v0.json create mode 100644 Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.ModernSpawnerEntry.v1.json create mode 100644 Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.PendingCycle.v0.json create mode 100644 Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.TriggerDefinition.v0.json create mode 100644 Projects/ModernSpawner/Migrations/Server.Engines.ModernSpawner.Triggers.TriggerRuntimeState.v0.json create mode 100644 Projects/ModernSpawner/Triggers/PendingCycle.cs create mode 100644 Projects/ModernSpawner/Triggers/TriggerDefinition.cs create mode 100644 Projects/ModernSpawner/Triggers/TriggerRuntimeState.cs 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/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs index 9d7fad2..7ce0fe5 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs @@ -1,6 +1,7 @@ -using System; +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,15 +149,18 @@ 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"); 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); @@ -177,7 +192,7 @@ public void Dto_RoundTrip_CarriesEntriesTriggersAndCycleState() var spawner = Place("Rabbit"); spawner.ModernEntries[0].LootTemplate = "goblin"; spawner.CycleMode = SpawnCycleMode.Sequential; - spawner.AddToTriggerDefinitions("proximity:8:true"); + spawner.AddTriggerDefinition("proximity:8:true"); var json = SpawnerJsonSerializer.SerializeCompact>([spawner.ToDto()]); var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); @@ -185,7 +200,7 @@ 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.Equal("proximity:8:true", Assert.Single(loaded.TriggerDefinitions).Text); DeleteSpawned(loaded); loaded.Delete(); @@ -252,14 +267,14 @@ 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(); Assert.True(spawner.Running); - Assert.False(spawner.Triggered); + Assert.Equal(0, spawner.PendingCycleCount); spawner.Spawn(); var rabbit = (BaseCreature)Assert.Single(spawner.Spawned).Key; @@ -270,10 +285,296 @@ 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); + 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(); + } } diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs index 243f13d..d7e5de9 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Xml; using Server.Engines.ModernSpawner.Migration; using Server.Engines.ModernSpawner.Triggers; @@ -27,7 +27,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 +45,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 +59,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); @@ -74,7 +74,7 @@ public void DeletingAnActivatedSpawner_LeavesNothingRegistered() public void Stop_UnregistersEvenWhenFlagWasClearedAfterRegistration() { var spawner = Place(); - spawner.AddToTriggerDefinitions(Proximity); + spawner.AddTriggerDefinition(Proximity); spawner.TriggerActivated = true; spawner.Stop(); Assert.False(TriggerSystem.Instance.IsRegistered(spawner)); @@ -108,8 +108,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 +120,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)); @@ -195,7 +195,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 +216,11 @@ 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 diff --git a/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs b/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs index 3ca03bb..7359f8f 100644 --- a/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs +++ b/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using Server.Engines.ModernSpawner.Tests.Fixtures; using Server.Engines.ModernSpawner.Triggers; using Server.Misc; @@ -19,7 +19,7 @@ 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.AddToTriggerDefinitions(definition); + spawner.AddTriggerDefinition(definition); spawner.TriggerActivated = true; return spawner; } @@ -40,9 +40,9 @@ public void PlayerSkillUse_InRange_FiresTrigger() var player = PlacePlayer(new Point3D(1503, 1500, 0)); try { - Assert.False(spawner.Triggered); + Assert.Equal(0, spawner.PendingCycleCount); SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.True(spawner.Triggered); + Assert.Equal(1, spawner.PendingCycleCount); } finally { @@ -59,7 +59,7 @@ public void PlayerSkillUse_OutOfRange_DoesNotFire() try { SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.False(spawner.Triggered); + Assert.Equal(0, spawner.PendingCycleCount); } finally { @@ -76,9 +76,9 @@ public void FailureOnlyTrigger_IgnoresSuccess() try { SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.False(spawner.Triggered); + Assert.Equal(0, spawner.PendingCycleCount); SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, -0.1); - Assert.True(spawner.Triggered); + Assert.Equal(1, spawner.PendingCycleCount); } finally { @@ -96,7 +96,7 @@ public void CreatureSkillUse_DoesNotFire() try { SkillCheck.Mobile_SkillCheckDirectTarget(rabbit, SkillName.Mining, null, 1.0); - Assert.False(spawner.Triggered); + Assert.Equal(0, spawner.PendingCycleCount); } finally { @@ -113,14 +113,14 @@ public void Cooldown_SuppressesSecondFiringUntilElapsed() try { SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.True(spawner.Triggered); + Assert.Equal(1, spawner.PendingCycleCount); spawner.ResetTrigger(); SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.False(spawner.Triggered); + Assert.Equal(0, spawner.PendingCycleCount); ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(6)); SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.True(spawner.Triggered); + Assert.Equal(1, spawner.PendingCycleCount); } finally { diff --git a/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs b/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs index b774db2..1bad2e0 100644 --- a/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs +++ b/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.Json; using Server.Engines.Events; @@ -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,15 @@ 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..39387c6 100644 --- a/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs +++ b/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs @@ -1,4 +1,4 @@ -using Server.Engines.ModernSpawner.Serialization; +using Server.Engines.ModernSpawner.Serialization; using Xunit; namespace Server.Engines.ModernSpawner.Tests.Serialization; @@ -10,7 +10,7 @@ public class XmlSpawnerLegacyMappingTests [InlineData(false, 0, SpawnCycleMode.Sequential)] [InlineData(false, 1, SpawnCycleMode.Sequential)] [InlineData(false, 5, SpawnCycleMode.Sequential)] - [InlineData(true, -1, SpawnCycleMode.Group)] + [InlineData(true, -1, SpawnCycleMode.AllEntries)] public void MapLegacyCycleMode_ReturnsExpectedMode(bool isGroup, int sequentialSpawn, SpawnCycleMode expected) { Assert.Equal(expected, XmlSpawnerImporter.MapLegacyCycleMode(isGroup, sequentialSpawn)); @@ -21,7 +21,7 @@ public void MapLegacyCycleMode_IsGroup_Wins_Over_SequentialSpawn() { // IsGroup takes precedence even if SequentialSpawn is also set. Assert.Equal( - SpawnCycleMode.Group, + SpawnCycleMode.AllEntries, XmlSpawnerImporter.MapLegacyCycleMode(isGroup: true, sequentialSpawn: 3)); } } diff --git a/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs b/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs index e6db313..7ce06ca 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Text.Json.Serialization; using Server.Engines.ModernSpawner.Scripting; +using Server.Engines.ModernSpawner.Triggers; using Server.Engines.Spawners; using Server.Json; @@ -38,15 +39,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 +98,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, 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 +183,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 +205,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 +246,20 @@ 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. +/// +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; } +} 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.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs index 1e4bde2..38ca544 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.Serialization; @@ -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 @@ -107,24 +105,17 @@ public bool TriggerActivated } } - /// - /// 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 +123,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 +133,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 +148,55 @@ 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. + /// + [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; @@ -172,6 +208,25 @@ public bool TriggerActivated private bool _hasExtendedProximityTriggers; private Rectangle2D _extendedTriggerBounds; + /// + /// 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 => _spawnEntries ?? (IReadOnlyList)Array.Empty(); @@ -436,8 +491,8 @@ public override void Spawn() case SpawnCycleMode.Sequential: SpawnWeightedOne(_currentSubgroup); break; - case SpawnCycleMode.Group: - SpawnGroupMode(); + case SpawnCycleMode.AllEntries: + SpawnAllEntries(); break; default: SpawnWeightedOne(-1); @@ -457,7 +512,7 @@ public override void Spawn() /// Spawns one entity from every eligible entry this cycle. When all entries are at /// their max count, no further spawns happen until the pack is cleared. /// - private void SpawnGroupMode() + private void SpawnAllEntries() { var entries = _spawnEntries; for (var i = 0; i < entries.Count; i++) @@ -610,12 +665,215 @@ 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(); + + if (Running && _triggerActivated && _triggerDefs is { Count: > 0 }) { TriggerSystem.Instance.ActivateTriggers(this); } } + /// + /// 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, id, text)); + EnsureTriggersActive(); + } + + /// + /// 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; + } + + /// + /// 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 for the queue until RequestCycle lands (task 3): same bounded enqueue the + /// trigger path will use. + /// + /// 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); + } + } + /// protected override void OnStarted() { @@ -930,7 +1188,7 @@ public override void GetSpawnerProperties(IPropertyList list) if (_triggerActivated) { - list.Add(1050039, $"{"trigger:"}\t{(_triggered ? "active" : "waiting")}"); + list.Add(1050039, $"{"trigger:"}\t{(PendingCycleCount > 0 ? "pending" : "waiting")}"); } if (_useSmartPositioning) @@ -949,8 +1207,8 @@ public void Trigger() return; } - _triggered = true; - this.MarkDirty(); + // Guid.Empty: an external caller is not one of this spawner's definitions. + EnqueuePendingCycle(Guid.Empty, Serial.Zero); if (!Running) { @@ -964,12 +1222,12 @@ public void Trigger() } /// - /// Resets the trigger state. + /// Resets the trigger state: every queued cycle is dropped (M4). Registrations, cooldowns and + /// kill counters survive. /// public void ResetTrigger() { - _triggered = false; - this.MarkDirty(); + ClearPendingCycles(); } /// @@ -984,8 +1242,7 @@ public void OnTriggerActivated(ITrigger trigger) return; } - _triggered = true; - this.MarkDirty(); + EnqueuePendingCycle(TriggerIdOf(trigger), Serial.Zero); // Force an immediate spawn check when trigger activates Spawn(); @@ -1003,8 +1260,31 @@ public void OnTriggerDeactivated(ITrigger trigger) return; } - _triggered = false; - this.MarkDirty(); + ClearPendingCycles(); + } + + /// + /// Definition id behind a parsed trigger. Until the trigger system binds definitions to their + /// parsed objects (task 2) a trigger carries no id, so this matches on the definition text. + /// + private Guid TriggerIdOf(ITrigger trigger) + { + var definitions = _triggerDefs; + if (trigger == null || definitions == null) + { + return Guid.Empty; + } + + var serialized = trigger.Serialize(); + for (var i = 0; i < definitions.Count; i++) + { + if (definitions[i].Text == serialized) + { + return definitions[i].Id; + } + } + + return Guid.Empty; } [AfterDeserialization] @@ -1024,14 +1304,27 @@ private void AfterDeserializationModernSpawner() [AfterDeserialization(false)] private void AfterWorldLoad() { + // L1: a save written before MaxPendingCycles was lowered can carry more slots than the bound + // now allows, and a deactivated spawner holds none at all. + if (!_triggerActivated) + { + ClearPendingCycles(); + } + else + { + TrimPendingCycles(); + } + // Extended area movement subscription is not yet supported in ModernUO // TODO: Implement extended proximity trigger support when Map APIs are available } /// /// 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 +1336,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(); } diff --git a/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs b/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs index d59df77..53df0c4 100644 --- a/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs +++ b/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text.Json.Serialization; using ModernUO.Serialization; using Server.Engines.Spawners; @@ -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) { } 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..93214d1 100644 --- a/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs +++ b/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs @@ -1,5 +1,6 @@ -using System; +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/XmlSpawnerMigrator.cs b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs index 683cb69..f9c6ff4 100644 --- a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs +++ b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Xml; @@ -182,7 +182,7 @@ internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node) if (proximityRange >= 0) { spawner.TriggerActivated = true; - spawner.AddToTriggerDefinitions($"proximity:{proximityRange}:true:false:5:0"); + spawner.AddTriggerDefinition($"proximity:{proximityRange}:true:false:5:0"); } var speechTrigger = GetAttribute(node, "SpeechTrigger", null); @@ -190,7 +190,7 @@ internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node) { spawner.TriggerActivated = true; var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(speechTrigger)); - spawner.AddToTriggerDefinitions($"speech:{encoded}:true:false:10:true:5"); + spawner.AddTriggerDefinition($"speech:{encoded}:true:false:10:true:5"); } var skillTrigger = GetAttribute(node, "SkillTrigger", null); @@ -199,7 +199,7 @@ internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node) var definition = MapSkillTrigger(skillTrigger, proximityRange < 0 ? 10 : proximityRange); if (definition != null) { - spawner.AddToTriggerDefinitions(definition); + spawner.AddTriggerDefinition(definition); spawner.TriggerActivated = 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/SpawnerPerfCommands.cs b/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs index 236bc66..2b1cd95 100644 --- a/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs +++ b/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Server.Collections; using Server.Commands; @@ -120,7 +120,7 @@ private static void PerfSeed_OnCommand(CommandEventArgs e) // 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.AddTriggerDefinition("proximity:8:true"); spawner.TriggerActivated = true; _seeded.Add(spawner); diff --git a/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs b/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs index 214d370..f18aa6a 100644 --- a/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs +++ b/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; @@ -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..28962da 100644 --- a/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs +++ b/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; @@ -202,12 +202,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 +300,7 @@ private static void ImportTriggers(ModernSpawner spawner, List trig var definition = BuildTriggerDefinition(trigger); if (!string.IsNullOrEmpty(definition)) { - spawner.AddToTriggerDefinitions(definition); + spawner.AddTriggerDefinition(definition); } } diff --git a/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs index 1d4bd8c..530f6b3 100644 --- a/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs +++ b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Xml; @@ -216,7 +216,7 @@ private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point) // Add proximity trigger if specified if (proximityRange > 0) { - spawner.AddToTriggerDefinitions($"proximity:{proximityRange}:true"); + spawner.AddTriggerDefinition($"proximity:{proximityRange}:true"); spawner.TriggerActivated = true; } @@ -225,7 +225,7 @@ private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point) { var startHour = (int)todStart; var endHour = (int)todEnd; - spawner.AddToTriggerDefinitions($"game_time_window:{startHour}:{endHour}:false:false"); + spawner.AddTriggerDefinition($"game_time_window:{startHour}:{endHour}:false:false"); spawner.TriggerActivated = true; } @@ -248,7 +248,7 @@ public static SpawnCycleMode MapLegacyCycleMode(bool isGroup, int sequentialSpaw { if (isGroup) { - return SpawnCycleMode.Group; + return SpawnCycleMode.AllEntries; } if (sequentialSpawn >= 0) 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/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/TriggerSystem.cs b/Projects/ModernSpawner/Triggers/TriggerSystem.cs index b0c5750..d71e360 100644 --- a/Projects/ModernSpawner/Triggers/TriggerSystem.cs +++ b/Projects/ModernSpawner/Triggers/TriggerSystem.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Server.Buffers; using Server.Logging; @@ -100,7 +100,7 @@ public void ActivateTriggers(ModernSpawner spawner) foreach (var definition in definitions) { - var trigger = ParseTrigger(definition); + var trigger = ParseTrigger(definition.Text); if (trigger != null) { trigger.Activate(spawner); From 4e23b23a5fb5a954a7fcc4915a5671b08fa14aa5 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:32:25 -0700 Subject: [PATCH 02/15] chore: pin ModernUO to 908e9ffbc (virtual OnTick, DTO group; PR #2640) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- ModernUO | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ModernUO b/ModernUO index 309fcfe..908e9ff 160000 --- a/ModernUO +++ b/ModernUO @@ -1 +1 @@ -Subproject commit 309fcfeb27aa7cc943d44e8c6e17d2ae2e4d687a +Subproject commit 908e9ffbc2a0b311433522926794e681cb15e4ce From 0231ebeb56f3d9e4e5e237af0e66b0c1b87507e8 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:46:50 -0700 Subject: [PATCH 03/15] fix(triggers): unique ids on import; accept the legacy triggers JSON shape; drop the text-matching id stopgap Three findings from the task 1 review. An id arriving from outside - a hand-edited export, a trigger block copied between spawners - could already be in use, and two definitions sharing one id alias onto a single TriggerRuntimeState and onto each other's pending slots. AddTriggerDefinition and ApplyModernDto now route every incoming id through UniqueDefinitionId, which mints a fresh v7 id on collision. Exports written before definitions had ids wrote `"triggers": ["proximity:8:true"]`, which threw on TriggerDefinitionDto. A JsonConverter on the DTO reads either that bare string (id minted on import) or the `{ id, text }` object, and always writes the object, so an old file imports and re-exports into the new shape with stable ids. TriggerIdOf matched a parsed trigger to its definition by comparing Serialize() to the definition text. The gump and the importers write shorter forms than Serialize() produces, so in production it never matched and every window-open slot fell back to Guid.Empty anyway. Removed; OnTriggerActivated passes Guid.Empty directly until task 2 binds definition ids onto trigger instances at registration. Minors: strip the UTF-8 BOM this branch introduced on fourteen pre-existing files; correct the OnTriggerDeactivated comment (window close is G3, which does not itself clear pending - the clearing is interim and task 3 replaces it); mark _refractoryUntil [SerializedIgnoreDupe] alongside the other runtime state; restore the blank lines before three [Fact] attributes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../Core/ModernSpawnerLifecycleTests.cs | 90 ++++++++++++++++++- .../ModernSpawnerTriggerRegistrationTests.cs | 2 +- .../Core/SkillTriggerTests.cs | 2 +- .../SpawnerJsonRoundTripTests.cs | 3 +- .../XmlSpawnerLegacyMappingTests.cs | 2 +- .../ModernSpawner/Core/ModernSpawner.Dto.cs | 89 +++++++++++++++++- Projects/ModernSpawner/Core/ModernSpawner.cs | 51 +++++------ .../ModernSpawner/Core/ModernSpawnerEntry.cs | 2 +- .../ModernSpawner/Gumps/TriggerConfigGump.cs | 2 +- .../Migration/XmlSpawnerMigrator.cs | 2 +- .../ModernSpawner/Perf/SpawnerPerfCommands.cs | 2 +- .../Serialization/SpawnerJsonExporter.cs | 2 +- .../Serialization/SpawnerJsonImporter.cs | 2 +- .../Serialization/XmlSpawnerImporter.cs | 2 +- .../ModernSpawner/Triggers/TriggerSystem.cs | 2 +- 15 files changed, 211 insertions(+), 44 deletions(-) diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs index 7ce0fe5..d587717 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.Json; using Server.Engines.ModernSpawner.Triggers; @@ -291,6 +291,7 @@ public void Kill_DispatchesOnDespawnScriptAndKillTrigger() DeleteSpawned(spawner); spawner.Delete(); } + [Fact] public void Binary_RoundTrip_CarriesTriggerIdsRuntimeStateAndPendingSlots() { @@ -467,6 +468,7 @@ public void MaxPendingCycles_ClampsAndTrimsOldestSlots() DeleteSpawned(spawner); spawner.Delete(); } + /// /// Writes the layer plus a v0 payload. /// The base layer comes from a stock entry because ModernSpawnerEntry.Serialize opens with @@ -577,4 +579,90 @@ public void Binary_V0Save_MigratesDefinitionsToIdsAndDropsTriggered() 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 d7e5de9..b7453a1 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Xml; using Server.Engines.ModernSpawner.Migration; using Server.Engines.ModernSpawner.Triggers; diff --git a/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs b/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs index 7359f8f..92c15ad 100644 --- a/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs +++ b/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using Server.Engines.ModernSpawner.Tests.Fixtures; using Server.Engines.ModernSpawner.Triggers; using Server.Misc; diff --git a/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs b/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs index 1bad2e0..f3d1973 100644 --- a/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs +++ b/Projects/ModernSpawner.Tests/Serialization/SpawnerJsonRoundTripTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.Json; using Server.Engines.Events; @@ -403,6 +403,7 @@ public void Empty_Entries_And_Triggers_Default_To_Empty_Lists() Assert.NotNull(restored.Triggers); Assert.Empty(restored.Triggers); } + [Fact] public void SpawnCycleMode_Group_AliasParses() { diff --git a/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs b/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs index 39387c6..67b9d8b 100644 --- a/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs +++ b/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs @@ -1,4 +1,4 @@ -using Server.Engines.ModernSpawner.Serialization; +using Server.Engines.ModernSpawner.Serialization; using Xunit; namespace Server.Engines.ModernSpawner.Tests.Serialization; diff --git a/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs b/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs index 7ce06ca..0ec60fb 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs @@ -1,5 +1,6 @@ -using System; +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; @@ -110,7 +111,7 @@ internal void ApplyModernDto(ModernSpawnerDto dto) var trigger = triggers[i]; if (trigger != null) { - _triggerDefs.Add(new TriggerDefinition(this, trigger.Id, trigger.Text)); + _triggerDefs.Add(new TriggerDefinition(this, UniqueDefinitionId(trigger.Id), trigger.Text)); } } } @@ -250,7 +251,10 @@ 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. @@ -263,3 +267,84 @@ public sealed record TriggerDefinitionDto [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.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs index 38ca544..919b759 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.Serialization; @@ -184,6 +184,7 @@ public bool TriggerActivated /// 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))] @@ -692,10 +693,22 @@ internal void EnsureTriggersActive() public void AddTriggerDefinition(Guid id, string text) { TriggerDefs ??= []; - AddToTriggerDefs(new TriggerDefinition(this, id, text)); + 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. @@ -1242,15 +1255,19 @@ public void OnTriggerActivated(ITrigger trigger) return; } - EnqueuePendingCycle(TriggerIdOf(trigger), Serial.Zero); + // Guid.Empty: a parsed trigger does not know which definition produced it yet. Task 2 binds + // definition ids onto trigger instances at registration, and this passes the real id then. + EnqueuePendingCycle(Guid.Empty, Serial.Zero); // 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. + /// Called by a time-window trigger when its window closes (G3). Interim behaviour: the queue is + /// dropped, standing in for the removed _triggered flag. G3 itself does not clear pending - + /// task 3 replaces this with the gate set, which closes the window without discarding cycles that + /// were already bought. /// /// The trigger that deactivated. public void OnTriggerDeactivated(ITrigger trigger) @@ -1263,30 +1280,6 @@ public void OnTriggerDeactivated(ITrigger trigger) ClearPendingCycles(); } - /// - /// Definition id behind a parsed trigger. Until the trigger system binds definitions to their - /// parsed objects (task 2) a trigger carries no id, so this matches on the definition text. - /// - private Guid TriggerIdOf(ITrigger trigger) - { - var definitions = _triggerDefs; - if (trigger == null || definitions == null) - { - return Guid.Empty; - } - - var serialized = trigger.Serialize(); - for (var i = 0; i < definitions.Count; i++) - { - if (definitions[i].Text == serialized) - { - return definitions[i].Id; - } - } - - return Guid.Empty; - } - [AfterDeserialization] private void AfterDeserializationModernSpawner() { diff --git a/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs b/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs index 53df0c4..238ac11 100644 --- a/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs +++ b/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text.Json.Serialization; using ModernUO.Serialization; using Server.Engines.Spawners; diff --git a/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs b/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs index 93214d1..8c3d6d9 100644 --- a/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs +++ b/Projects/ModernSpawner/Gumps/TriggerConfigGump.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Server.Engines.ModernSpawner.Triggers; using Server.Gumps; diff --git a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs index f9c6ff4..5a44b85 100644 --- a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs +++ b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Xml; diff --git a/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs b/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs index 2b1cd95..5193008 100644 --- a/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs +++ b/Projects/ModernSpawner/Perf/SpawnerPerfCommands.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Server.Collections; using Server.Commands; diff --git a/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs b/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs index f18aa6a..de03f65 100644 --- a/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs +++ b/Projects/ModernSpawner/Serialization/SpawnerJsonExporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; diff --git a/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs b/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs index 28962da..5f3bca8 100644 --- a/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs +++ b/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; diff --git a/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs index 530f6b3..d91123a 100644 --- a/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs +++ b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Xml; diff --git a/Projects/ModernSpawner/Triggers/TriggerSystem.cs b/Projects/ModernSpawner/Triggers/TriggerSystem.cs index d71e360..3c410a1 100644 --- a/Projects/ModernSpawner/Triggers/TriggerSystem.cs +++ b/Projects/ModernSpawner/Triggers/TriggerSystem.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Server.Buffers; using Server.Logging; From 758bfe645a675243876d35b8b666b1cc2a48678f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:21:54 -0700 Subject: [PATCH 04/15] refactor(triggers): TriggerSet per spawner, struct context, pure Evaluate, outer-dispatch drain, shared tokens; timeofday retired One TriggerSet per spawner in one dictionary replaces the six per-type dictionaries, so a movement, speech or kill dispatch does one lookup and then walks a typed list by index. TriggerContext becomes a readonly record struct passed `in`, and both GetSpawnersWith... yield iterators are gone; skill dispatch keeps a per-map candidate list maintained on registration and map change. Evaluate is now pure: the cooldown is a read of TriggerRuntimeState.CooldownUntil and kill counting reads State.KillCount, so an evaluation the spawner goes on to reject leaves no trace. Dispatch never calls Spawn(): a match calls spawner.RequestCycle(trigger, in context), and the trigger system keeps a dispatch-depth counter that drains a pooled list once the outermost dispatch returns. ITrigger gains Kind, Id, DefinitionIndex, State, Wake, Mode, When and Cooldown; Id, DefinitionIndex and State are bound at registration before anything can fire, and the gates report their open and close edges by definition index through ModernSpawner.OnGateOpened/OnGateClosed. TriggerTokens parses and writes the shared wake:, mode: and when: tokens in any order, leaving pre-token definitions byte-identical on round trip; when: compiles once at parse time. TimeOfDayTrigger is deleted and survives only as a factory alias onto game_time_window, whose exclusive end hour now reaches 24 so the legacy inclusive end maps cleanly. Extended proximity is clamped to Core.GlobalMaxUpdateRange with a warning and the _extendedTriggerBounds machinery is removed; SpeechTrigger's regex gains a 50ms match timeout. RequestCycle, DrainOne and the gate callbacks are interim shims that keep the old behaviour until the spawner state machine lands. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../Triggers/TimeOfDayAliasTests.cs | 87 +++ .../Triggers/TimeOfDayTriggerTests.cs | 73 --- .../Triggers/TriggerSetTests.cs | 101 +++ .../Triggers/TriggerTokenTests.cs | 176 ++++++ Projects/ModernSpawner/Core/ModernSpawner.cs | 192 ++++-- .../Serialization/SpawnerJsonImporter.cs | 5 +- .../Triggers/GameTimeWindowTrigger.cs | 122 +++- Projects/ModernSpawner/Triggers/ITrigger.cs | 114 ++-- .../ModernSpawner/Triggers/ITriggerSystem.cs | 33 + .../ModernSpawner/Triggers/KillTrigger.cs | 144 +++-- .../Triggers/ProximityTrigger.cs | 127 ++-- .../ModernSpawner/Triggers/SkillTrigger.cs | 127 ++-- .../ModernSpawner/Triggers/SpeechTrigger.cs | 131 ++-- .../Triggers/TimeOfDayTrigger.cs | 156 ----- .../ModernSpawner/Triggers/TriggerBase.cs | 94 +++ .../ModernSpawner/Triggers/TriggerContext.cs | 66 ++ Projects/ModernSpawner/Triggers/TriggerSet.cs | 108 ++++ .../ModernSpawner/Triggers/TriggerSystem.cs | 585 ++++++++---------- .../ModernSpawner/Triggers/TriggerTokens.cs | 205 ++++++ .../Triggers/WallTimeWindowTrigger.cs | 59 +- 20 files changed, 1794 insertions(+), 911 deletions(-) create mode 100644 Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs delete mode 100644 Projects/ModernSpawner.Tests/Triggers/TimeOfDayTriggerTests.cs create mode 100644 Projects/ModernSpawner.Tests/Triggers/TriggerSetTests.cs create mode 100644 Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs delete mode 100644 Projects/ModernSpawner/Triggers/TimeOfDayTrigger.cs create mode 100644 Projects/ModernSpawner/Triggers/TriggerBase.cs create mode 100644 Projects/ModernSpawner/Triggers/TriggerContext.cs create mode 100644 Projects/ModernSpawner/Triggers/TriggerSet.cs create mode 100644 Projects/ModernSpawner/Triggers/TriggerTokens.cs diff --git a/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs b/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs new file mode 100644 index 0000000..60ae651 --- /dev/null +++ b/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs @@ -0,0 +1,87 @@ +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)] + 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..28ab546 --- /dev/null +++ b/Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs @@ -0,0 +1,176 @@ +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:wake:true:mode:tick"); + var modeFirst = ProximityTrigger.Parse("proximity:8: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); + } + + [Fact] + public void Tokens_DoNotDisplacePositionalArguments() + { + // The tokens sit between positional arguments: stripping them must leave 12/false/true/30 in place. + var trigger = ProximityTrigger.Parse("proximity:12:wake:true:false:mode:tick:true:30"); + + Assert.Equal(12, trigger.Range); + Assert.False(trigger.PlayersOnly); + Assert.True(trigger.RequireLineOfSight); + Assert.Equal(TimeSpan.FromSeconds(30), trigger.Cooldown); + Assert.True(trigger.Wake); + Assert.Equal(CycleMode.Tick, trigger.Mode); + } + + [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: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: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.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs index 919b759..511608e 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -205,9 +205,11 @@ 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 @@ -760,6 +762,32 @@ public TriggerRuntimeState GetTriggerState(Guid id) 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 @@ -1244,33 +1272,44 @@ public void ResetTrigger() } /// - /// Called by a time-window trigger when its window opens. - /// This enables spawning during the trigger's active window. + /// Called by a gate when its window opens, naming the gate by its position in + /// (-1 while unbound). /// - /// The trigger that activated. - public void OnTriggerActivated(ITrigger trigger) + /// + /// Interim: this still does what the old window-open bridge did, queue a cycle and spawn. D2's + /// G1-G4 rows replace it with the spawner's set of open gates, where an open edge only authorizes + /// the tick and a close edge leaves queued cycles alone. + /// + /// Position of the gate's definition, or -1. + public void OnGateOpened(int definitionIndex) { if (!_triggerActivated || !Running) { return; } - // Guid.Empty: a parsed trigger does not know which definition produced it yet. Task 2 binds - // definition ids onto trigger instances at registration, and this passes the real id then. - EnqueuePendingCycle(Guid.Empty, Serial.Zero); + var definitions = _triggerDefs; + var id = definitionIndex >= 0 && definitions != null && definitionIndex < definitions.Count + ? definitions[definitionIndex].Id + : Guid.Empty; - // Force an immediate spawn check when trigger activates + EnqueuePendingCycle(id, Serial.Zero); + + // Force an immediate spawn check when the window opens Spawn(); } /// - /// Called by a time-window trigger when its window closes (G3). Interim behaviour: the queue is - /// dropped, standing in for the removed _triggered flag. G3 itself does not clear pending - - /// task 3 replaces this with the gate set, which closes the window without discarding cycles that - /// were already bought. + /// Called by a gate when its window closes, naming the gate by its position in + /// (-1 while unbound). /// - /// The trigger that deactivated. - public void OnTriggerDeactivated(ITrigger trigger) + /// + /// Interim: the queue is dropped, standing in for the removed _triggered flag. G3 itself does + /// not clear pending - task 3 replaces this with the gate set, which closes the window without + /// discarding cycles that were already bought. + /// + /// Position of the gate's definition, or -1. + public void OnGateClosed(int definitionIndex) { if (!_triggerActivated) { @@ -1280,6 +1319,67 @@ public void OnTriggerDeactivated(ITrigger trigger) ClearPendingCycles(); } + /// + /// 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. + /// + /// + /// Interim: this is the old bridge with the definition id, the triggering + /// mobile and the cooldown advance filled in, so it still queues a slot and spawns inline. D2's + /// acceptance order (E0-E6) - refractory, when:, mode:, wake:, queue bound - and + /// the deferred drain that makes the queued slot the thing that actually runs land with + /// . + /// + /// The trigger that matched, or null for an external source. + /// The event being dispatched. + internal void RequestCycle(ITrigger trigger, in TriggerContext context) + { + if (!_triggerActivated) + { + return; + } + + var id = trigger?.Id ?? Guid.Empty; + + var mobile = context.TriggeringMobile; + var serial = mobile == null ? Serial.Zero : mobile.Serial; + + EnqueuePendingCycle(id, serial); + + // Accepting the event is what advances the cooldown; Evaluate only ever compared against it. + if (trigger != null) + { + var state = trigger.State; + if (state != null && trigger.Cooldown > TimeSpan.Zero) + { + state.CooldownUntil = Core.Now + trigger.Cooldown; + } + } + + TriggerSystem.Instance.RequestDrain(this); + + if (!Running) + { + Start(); + } + else + { + Spawn(); + } + } + + /// + /// Runs one queued cycle, called by the trigger system once the outermost dispatch has returned. + /// + /// + /// Interim: a no-op. still spawns inline, so there is nothing left for the + /// drain to do; D2's D1 re-validation (deleted, stopped, deactivated, gate closed, full) and the + /// RunCycle that consumes the queued slot land with the spawner side of the state machine. + /// + internal void DrainOne() + { + } + [AfterDeserialization] private void AfterDeserializationModernSpawner() { @@ -1308,8 +1408,6 @@ private void AfterWorldLoad() TrimPendingCycles(); } - // Extended area movement subscription is not yet supported in ModernUO - // TODO: Implement extended proximity trigger support when Map APIs are available } /// @@ -1346,9 +1444,6 @@ 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); @@ -1362,16 +1457,10 @@ 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 + // 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) @@ -1410,11 +1499,13 @@ 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) @@ -1422,12 +1513,8 @@ public override void OnMovement(Mobile m, Point3D oldLocation) 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); } /// @@ -1447,27 +1534,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/Serialization/SpawnerJsonImporter.cs b/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs index 5f3bca8..dbfa929 100644 --- a/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs +++ b/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs @@ -334,7 +334,10 @@ 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, whose end hour is exclusive + // where the legacy one was inclusive. + var endHour = Math.Clamp(trigger.EndHour, 0, 23) + 1; + return $"game_time_window:{Math.Clamp(trigger.StartHour, 0, 23)}:{endHour}:{trigger.NightOnly}:{trigger.DayOnly}"; } if (typeSpan.InsensitiveEquals("game_time_window")) { diff --git a/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs b/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs index 67a4e6c..300a20c 100644 --- a/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs +++ b/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs @@ -4,20 +4,27 @@ 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); - 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 +32,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 +55,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 +65,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 +73,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 +109,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 +134,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 +201,7 @@ private void ScheduleNextTransition(int effectiveStart, int effectiveEnd) private void OnTransition() { - if (_spawner == null) + if (Spawner == null) { return; } @@ -205,6 +220,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 +241,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 +255,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,7 +275,51 @@ 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; + } + + /// + /// 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. + /// + /// + /// The legacy end hour was inclusive (timeofday:8:17 covered 08:00-17:59), so it becomes the + /// window grammar's exclusive end: 17 becomes 18, and the legacy whole-day default 23 becomes + /// . 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(':'); + var trigger = new GameTimeWindowTrigger + { + // The legacy default range was 0..23 inclusive, i.e. the whole day. + EndHour = EndOfDay + }; + + 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) + 1; } if (parts.Length > 3 && bool.TryParse(parts[3], out var 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..18d7b04 100644 --- a/Projects/ModernSpawner/Triggers/KillTrigger.cs +++ b/Projects/ModernSpawner/Triggers/KillTrigger.cs @@ -1,14 +1,21 @@ 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"; + /// + public override string TriggerType => "kill"; + + /// + public override TriggerKind Kind => TriggerKind.Event; /// /// The number of kills required before triggering. @@ -37,34 +44,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; - - public KillTrigger() - { - } + /// Creates a trigger with the documented defaults. + public KillTrigger() => Cooldown = TimeSpan.FromSeconds(5); - 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 kill being dispatched. + /// True when the kill counts. + public bool CountsKill(in TriggerContext context) { - if (_spawner == null || context.KilledEntity == null) + if (Spawner == null || context.KilledEntity == null) { return false; } // Check cooldown - if (Core.Now - _lastTriggered < Cooldown) + if (!CooldownElapsed()) { return false; } @@ -89,50 +95,57 @@ public bool Evaluate(TriggerContext context) } } - // Increment kill count - _currentKillCount++; + return true; + } - // Check if all dead is required - if (RequireAllDead) + /// + /// + /// 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. + /// + public override bool Evaluate(in TriggerContext context) + { + if (!CountsKill(in context)) { - // Check if spawner has any remaining spawned entities - if (_spawner.Spawned.Count > 0) - { - return false; - } + return false; } - // Check if we've reached required kills - if (_currentKillCount >= RequiredKills) + // Check if all dead is required + if (RequireAllDead && Spawner.Spawned.Count > 0) { - _lastTriggered = Core.Now; - - if (ResetOnTrigger) - { - _currentKillCount = 0; - } - - return true; + 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. + /// + /// + /// Interim: a kill advances the counter whether or not the spawner accepts the resulting event, and + /// D2's acceptance path (ModernSpawner.RequestCycle) is where that advance belongs. Until + /// that lands, the kill dispatch calls this so multi-kill thresholds keep working. + /// + /// 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 +153,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, 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 +226,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/ProximityTrigger.cs b/Projects/ModernSpawner/Triggers/ProximityTrigger.cs index db56906..8ee3c0e 100644 --- a/Projects/ModernSpawner/Triggers/ProximityTrigger.cs +++ b/Projects/ModernSpawner/Triggers/ProximityTrigger.cs @@ -1,18 +1,37 @@ 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)); + + /// + 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 +48,44 @@ public class ProximityTrigger : ITrigger /// public AccessLevel MinAccessLevel { get; set; } = AccessLevel.Player; - /// - /// Cooldown between trigger activations. - /// - public TimeSpan Cooldown { get; set; } = TimeSpan.FromSeconds(5); + /// Creates a trigger with the documented defaults. + public ProximityTrigger() => Cooldown = TimeSpan.FromSeconds(5); - private ModernSpawner _spawner; - private DateTime _lastTriggered = DateTime.MinValue; - - 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 (context.TriggeringMobile == null || _spawner == null) + 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) + { + var spawner = Spawner; + if (context.TriggeringMobile == null || spawner == null) { return false; } @@ -70,60 +105,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)) - { - return false; - } - - // Check cooldown - if (Core.Now - _lastTriggered < Cooldown) + if (RequireLineOfSight && !mobile.InLOS(spawner)) { 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, 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 +183,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..87b37ac 100644 --- a/Projects/ModernSpawner/Triggers/SkillTrigger.cs +++ b/Projects/ModernSpawner/Triggers/SkillTrigger.cs @@ -1,24 +1,35 @@ 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"; + /// + 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 +52,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 +103,11 @@ TimeSpan cooldown Cooldown = cooldown; } - public void Activate(ModernSpawner spawner) - { - _spawner = spawner; - TriggerSystem.Instance.RegisterSkillTrigger(spawner, this); - } - - public void Deactivate() + /// + public override bool Evaluate(in TriggerContext context) { - if (_spawner != null) - { - TriggerSystem.Instance.UnregisterSkillTrigger(_spawner, this); - _spawner = null; - } - } - - public bool Evaluate(TriggerContext context) - { - if (_spawner == null || _spawner.Deleted || !_spawner.Running) + var spawner = Spawner; + if (spawner == null || spawner.Deleted || !spawner.Running) { return false; } @@ -107,39 +119,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 +172,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 +182,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 +220,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, ref wake, ref mode, ref when); + + var parts = positional.Split(':'); if (parts.Length < 2) { return null; @@ -280,6 +321,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..ca44b5e 100644 --- a/Projects/ModernSpawner/Triggers/SpeechTrigger.cs +++ b/Projects/ModernSpawner/Triggers/SpeechTrigger.cs @@ -1,14 +1,28 @@ using System; using System.Text.RegularExpressions; +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"; + /// + /// 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 +49,27 @@ 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; - 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,33 +87,33 @@ 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 false; - } - - _lastTriggered = Core.Now; - return true; + return MatchesSpeech(context.Speech); } + private Regex BuildRegex() => + new( + Keyword ?? string.Empty, + RegexOptions.Compiled | (IgnoreCase ? RegexOptions.IgnoreCase : RegexOptions.None), + RegexTimeout + ); + private bool MatchesSpeech(string speech) { if (string.IsNullOrEmpty(Keyword)) @@ -111,11 +123,18 @@ 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. + _compiledRegex ??= BuildRegex(); + + try + { + return _compiledRegex.IsMatch(speech); + } + catch (RegexMatchTimeoutException) + { + return false; + } } var comparison = IgnoreCase @@ -125,33 +144,54 @@ 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 = UseRegex && !string.IsNullOrEmpty(Keyword) ? BuildRegex() : null; } - public void Deactivate() + /// + public override void Deactivate() { - if (_spawner != null) - { - TriggerSystem.Instance?.UnregisterSpeechTrigger(_spawner, this); - } - _spawner = null; + base.Deactivate(); + _compiledRegex = null; } - 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, ref wake, ref mode, ref when); + + var parts = positional.Split(':'); var trigger = new SpeechTrigger(); if (parts.Length > 1) @@ -191,6 +231,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..6b84183 --- /dev/null +++ b/Projects/ModernSpawner/Triggers/TriggerBase.cs @@ -0,0 +1,94 @@ +using System; +using Server.Engines.ModernSpawner.Scripting.Expressions; +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 +{ + /// + 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; + When = WhenSource == null ? null : ExpressionEngine.Instance.Compile(WhenSource); + } + + /// + /// 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..22de133 --- /dev/null +++ b/Projects/ModernSpawner/Triggers/TriggerContext.cs @@ -0,0 +1,66 @@ +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); + + /// Context for a gate re-evaluation, which depends on the clock rather than an actor. + /// The spawner being evaluated. + /// A context carrying only the spawner. + public static TriggerContext ForGate(ModernSpawner spawner) => + new(spawner, null, null, null, default, 0.0, false); +} 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 3c410a1..fd05412 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,22 @@ 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. 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 +50,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 +102,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,271 +123,214 @@ 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 definition = definitions[i]; var trigger = ParseTrigger(definition.Text); - if (trigger != null) + 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 (triggers.Count > 0) + if (set.All.Count == 0) { - _allTriggers[spawner] = triggers; + return; } - } - /// - /// 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); + // Register before activating: a gate that opens during Activate runs spawner code, and that code + // must see a consistent registry. + _sets[spawner] = set; + spawner.SetHasProximityTriggers(set.Proximity.Count > 0); + spawner.SetHasSpeechTriggers(set.Speech.Count > 0); - public void DeactivateTriggers(ModernSpawner spawner) - { - if (spawner == null) + if (set.Skill.Count > 0) { - return; + FileSkillCandidate(spawner, set); } - if (_allTriggers.TryGetValue(spawner, out var triggers)) + var triggers = set.All; + for (var i = 0; i < triggers.Count; i++) { - foreach (var trigger in 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) { - trigger.Deactivate(); + return; } - _allTriggers.Remove(spawner); + triggers[i].Activate(spawner); } + } - // Clean up specific trigger type registrations and notify spawner - if (_proximityTriggers.Remove(spawner)) - { - spawner.SetHasProximityTriggers(false); - spawner.UnsubscribeFromExtendedAreaMovement(); - } + /// + /// Whether currently has triggers registered with this system, i.e. whether + /// has run for it without a matching . + /// + /// The spawner to check. + /// True when a set is registered for it. + internal bool IsRegistered(ModernSpawner spawner) => spawner != null && _sets.ContainsKey(spawner); - if (_speechTriggers.Remove(spawner)) + /// + public void DeactivateTriggers(ModernSpawner spawner) + { + if (spawner == null || !_sets.Remove(spawner, out var set)) { - spawner.SetHasSpeechTriggers(false); + return; } - _timeOfDayTriggers.Remove(spawner); - _killTriggers.Remove(spawner); - _skillTriggers.Remove(spawner); + UnfileSkillCandidate(spawner, set); - // Stop time-of-day timer if no more time-based triggers - if (_timeOfDayTriggers.Count == 0) + var triggers = set.All; + for (var i = 0; i < triggers.Count; i++) { - _timeOfDayTimer?.Stop(); - _timeOfDayTimer = null; + triggers[i].Deactivate(); } + + // 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.SetHasProximityTriggers(false); + spawner.SetHasSpeechTriggers(false); } - public void RegisterProximityTrigger(ModernSpawner spawner, ProximityTrigger trigger) + private void FileSkillCandidate(ModernSpawner spawner, TriggerSet set) { - if (!_proximityTriggers.TryGetValue(spawner, out var list)) + var map = spawner.Map; + if (map == null || map == Map.Internal) { - list = []; - _proximityTriggers[spawner] = list; + return; } - if (!list.Contains(trigger)) + if (!_skillCandidates.TryGetValue(map, out var candidates)) { - list.Add(trigger); + candidates = []; + _skillCandidates[map] = candidates; } - // Notify spawner it now has proximity triggers (enables HandlesOnMovement) - spawner.SetHasProximityTriggers(true); - - // Check if any trigger requires extended range (beyond 24 tiles) - UpdateExtendedProximityBounds(spawner, list); - } - - public void UnregisterProximityTrigger(ModernSpawner spawner, ProximityTrigger trigger) - { - if (_proximityTriggers.TryGetValue(spawner, out var list)) + if (!candidates.Contains(spawner)) { - list.Remove(trigger); - if (list.Count == 0) - { - _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); - } + candidates.Add(spawner); } + + set.SkillMap = map; } - /// - /// Updates extended proximity bounds for a spawner based on its proximity triggers. - /// Sets up area movement subscription if any trigger range exceeds 24 tiles. - /// - private static void UpdateExtendedProximityBounds(ModernSpawner spawner, List triggers) + private void UnfileSkillCandidate(ModernSpawner spawner, TriggerSet set) { - // Find the maximum trigger range - var maxRange = 0; - for (var i = 0; i < triggers.Count; i++) + var map = set.SkillMap; + if (map == null || !_skillCandidates.TryGetValue(map, out var candidates)) { - 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 + candidates.Remove(spawner); + if (candidates.Count == 0) { - // No extended range needed - unsubscribe if previously subscribed - spawner.UnsubscribeFromExtendedAreaMovement(); + _skillCandidates.Remove(map); } + + set.SkillMap = null; } - public void RegisterSpeechTrigger(ModernSpawner spawner, SpeechTrigger trigger) + /// + /// 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. + /// + /// The spawner whose map changed. + internal void OnSpawnerMapChanged(ModernSpawner spawner) { - if (!_speechTriggers.TryGetValue(spawner, out var list)) + if (spawner == null || !_sets.TryGetValue(spawner, out var set) || set.Skill.Count == 0) { - list = []; - _speechTriggers[spawner] = list; + return; } - if (!list.Contains(trigger)) + if (set.SkillMap == spawner.Map) { - list.Add(trigger); + return; } - // Notify spawner it now has speech triggers (enables HandlesOnSpeech) - spawner.SetHasSpeechTriggers(true); + UnfileSkillCandidate(spawner, set); + FileSkillCandidate(spawner, set); } - public void UnregisterSpeechTrigger(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)) + if (spawner == null || spawner.Deleted || spawner.DrainRequested) { - list.Remove(trigger); - if (list.Count == 0) - { - _speechTriggers.Remove(spawner); - // Notify spawner it no longer has speech triggers - spawner.SetHasSpeechTriggers(false); - } + return; } + + spawner.DrainRequested = true; + _drainList.Add(spawner); } - public void RegisterTimeOfDayTrigger(ModernSpawner spawner, TimeOfDayTrigger trigger) + /// + /// 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 (!_timeOfDayTriggers.TryGetValue(spawner, out var list)) + if (_drainList.Count == 0) { - list = []; - _timeOfDayTriggers[spawner] = list; - } - - if (!list.Contains(trigger)) - { - list.Add(trigger); + return; } - // Start the time-of-day timer if not already running - StartTimeOfDayTimer(); - } - - public void UnregisterTimeOfDayTrigger(ModernSpawner spawner, TimeOfDayTrigger trigger) - { - if (_timeOfDayTriggers.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++) { - _timeOfDayTriggers.Remove(spawner); - } - } - } + var spawner = _drainList[i]; + spawner.DrainRequested = false; - public void RegisterKillTrigger(ModernSpawner spawner, KillTrigger trigger) - { - if (!_killTriggers.TryGetValue(spawner, out var list)) - { - list = []; - _killTriggers[spawner] = list; - } + if (spawner.Deleted) + { + continue; + } - if (!list.Contains(trigger)) - { - list.Add(trigger); + spawner.DrainOne(); + } } - } - - public void UnregisterKillTrigger(ModernSpawner spawner, KillTrigger trigger) - { - if (_killTriggers.TryGetValue(spawner, out var list)) + finally { - list.Remove(trigger); - if (list.Count == 0) + for (var i = 0; i < _drainList.Count; i++) { - _killTriggers.Remove(spawner); + _drainList[i].DrainRequested = false; } - } - } - - public void RegisterSkillTrigger(ModernSpawner spawner, SkillTrigger trigger) - { - if (!_skillTriggers.TryGetValue(spawner, out var list)) - { - list = []; - _skillTriggers[spawner] = list; - } - if (!list.Contains(trigger)) - { - list.Add(trigger); + _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 +338,46 @@ 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]; - // The snapshot can name a spawner that an earlier iteration of this dispatch deleted or + // The snapshot can name a spawner an earlier iteration of this dispatch deleted or // unregistered. - if (spawner.Deleted || spawner.Map != mobile.Map || !_skillTriggers.ContainsKey(spawner)) + if (spawner.Deleted || spawner.Map != mobile.Map || !_sets.TryGetValue(spawner, out var set)) { 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 +393,16 @@ 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.Trigger(); - break; // Only trigger once per spawner per skill use + spawner.RequestCycle(trigger, in context); + break; // Only one cycle per spawner per skill use } } } @@ -445,107 +413,46 @@ 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)) - { - continue; - } - - var context = new TriggerContext(spawner); - - for (var i = 0; i < triggers.Count; i++) + var trigger = triggers[i]; + if (trigger.Evaluate(in context)) { - if (triggers[i].Evaluate(context)) - { - spawner.Trigger(); - break; - } + spawner.RequestCycle(trigger, in context); + 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) - { - if (mobile == null || map == null || map == Map.Internal || spawner == null) - { - return; - } - - if (!_proximityTriggers.TryGetValue(spawner, out var triggers)) - { - return; - } - - var context = new TriggerContext(spawner) - { - TriggeringMobile = mobile - }; - - foreach (var trigger in triggers) - { - if (trigger.Evaluate(context)) - { - spawner.Trigger(); - break; // Only trigger once per spawner per proximity event - } - } - } - - /// - /// Called when speech occurs near a specific spawner (via Item.OnSpeech). - /// This is the optimized path using ModernUO's built-in sector-based dispatch. - /// + /// public void OnSpeech(Mobile speaker, string text, Point3D location, Map map, ModernSpawner spawner) { if (speaker == null || string.IsNullOrEmpty(text) || map == null || map == Map.Internal || spawner == null) @@ -553,27 +460,34 @@ public void OnSpeech(Mobile speaker, string text, Point3D location, Map map, Mod return; } - if (!_speechTriggers.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 = speaker, - Speech = text - }; + 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 speech event + var trigger = triggers[i]; + if (trigger.Evaluate(in context)) + { + spawner.RequestCycle(trigger, in context); + break; // Only one cycle per spawner per speech event + } } } + finally + { + EndDispatch(); + } } + /// public void OnEntityKilled(ModernSpawner spawner, IEntity killed, Mobile killer) { if (spawner == null || killed == null) @@ -581,55 +495,40 @@ 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]; + var accepted = trigger.Evaluate(in context); - /// - /// 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; + // Interim: a kill that passes the trigger's filters advances its counter whether or not + // the resulting event is accepted, and Evaluate is pure, so the advance happens here + // until the spawner's acceptance path owns it. + if (trigger.CountsKill(in context)) + { + trigger.AdvanceKillCount(accepted); + } + + if (accepted) + { + spawner.RequestCycle(trigger, 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..b0051a0 --- /dev/null +++ b/Projects/ModernSpawner/Triggers/TriggerTokens.cs @@ -0,0 +1,205 @@ +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 are name/value pairs that sit anywhere among a definition's positional arguments and in any +/// order, so proximity:8:true:false:5:Player:wake:true:mode:tick and +/// proximity:8: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. +/// +/// +/// 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. + /// 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, 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; + while (index < span.Length) + { + var remaining = span[index..]; + var separator = remaining.IndexOf(':'); + var segment = separator < 0 ? remaining : remaining[..separator]; + + 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; + continue; + } + + if (wrote) + { + sb.Append(':'); + } + + sb.Append(segment); + wrote = true; + + 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(':'); From dc095e625e61462bf6e71706c418d3868d6da1dd Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:40:50 -0700 Subject: [PATCH 05/15] fix(triggers): timeofday whole-day wrap; tolerate a bad speech regex; kill-counter tests; token scan past positionals The legacy timeofday grammar read end < start as a wrap past midnight, so end == start - 1 (mod 24) - 0:23, 10:9, 23:22, 1:0 - named every hour of the day. Mapping the inclusive end to an exclusive one unconditionally turned those into [start, start), the empty window and the exact opposite, leaving a gate that never opens. One helper, MapLegacyTimeOfDayHours, now owns the legacy rules and both the timeofday factory alias and the JSON importer go through it. A speech trigger's regex is compiled at registration, and registration runs from world load, so an invalid hand-authored pattern threw ArgumentException out of EnsureTriggersActive and left a half-activated trigger set behind. The pattern is now built at most once behind EnsureRegex, a bad one is logged and leaves the trigger unable to match, and matching tolerates the missing regex. TriggerTokens.Strip now takes the caller's positional arity and only looks for tokens past it, so a kill filter type or a keyword literally spelled Wake, Mode or When stays an argument instead of being eaten and shifting every later argument. Tokens are therefore a suffix: positional arguments have to be written out before them, which is what Serialize already does. KillTrigger.CountsKill no longer needs a bound spawner - RequireAllDead is the only part that reads one, so that requirement moved into Evaluate - which lets the kill-counter split be tested purely above threshold 1: the threshold kill, ResetOnTrigger on and off, a filtered-out kill, RequireAllDead blocking a cycle without dropping the kill, and kill progress surviving a re-registration bound to a fresh trigger object by definition id. Also drops the unused TriggerContext.ForGate and rewrites EnsureTriggersActive's summary, which still described the per-type dispatch lists that are gone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../Triggers/KillTriggerCounterTests.cs | 189 ++++++++++++++++++ .../Triggers/SpeechTriggerRegexTests.cs | 85 ++++++++ .../Triggers/TimeOfDayAliasTests.cs | 5 + .../Triggers/TriggerTokenTests.cs | 31 +-- Projects/ModernSpawner/Core/ModernSpawner.cs | 13 +- .../Serialization/SpawnerJsonImporter.cs | 15 +- .../Triggers/GameTimeWindowTrigger.cs | 66 ++++-- .../ModernSpawner/Triggers/KillTrigger.cs | 19 +- .../Triggers/ProximityTrigger.cs | 5 +- .../ModernSpawner/Triggers/SkillTrigger.cs | 5 +- .../ModernSpawner/Triggers/SpeechTrigger.cs | 65 +++++- .../ModernSpawner/Triggers/TriggerContext.cs | 6 - .../ModernSpawner/Triggers/TriggerTokens.cs | 66 +++--- 13 files changed, 486 insertions(+), 84 deletions(-) create mode 100644 Projects/ModernSpawner.Tests/Triggers/KillTriggerCounterTests.cs create mode 100644 Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs 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..2f7e5e3 --- /dev/null +++ b/Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs @@ -0,0 +1,85 @@ +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); + } + 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.Equal(0, spawner.PendingCycleCount); + + TriggerSystem.Instance.OnSpeech(player, "hail friend", player.Location, player.Map, spawner); + Assert.Equal(1, spawner.PendingCycleCount); + } + finally + { + player.Delete(); + spawner.Delete(); + } + } +} diff --git a/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs b/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs index 60ae651..146e09a 100644 --- a/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs +++ b/Projects/ModernSpawner.Tests/Triggers/TimeOfDayAliasTests.cs @@ -35,6 +35,11 @@ public void LegacyDefinition_ParsesToAGameTimeWindow_WithAnExclusiveEnd(string d [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}"); diff --git a/Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs b/Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs index 28ab546..786b9bd 100644 --- a/Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs +++ b/Projects/ModernSpawner.Tests/Triggers/TriggerTokenTests.cs @@ -29,8 +29,8 @@ public void Proximity_TokensAfterPositionalArguments() [Fact] public void Tokens_ParseInAnyOrder() { - var wakeFirst = ProximityTrigger.Parse("proximity:8:wake:true:mode:tick"); - var modeFirst = ProximityTrigger.Parse("proximity:8:mode:tick:wake:true"); + 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); @@ -41,18 +41,23 @@ public void Tokens_ParseInAnyOrder() Assert.Equal(CycleMode.Tick, modeFirst.Mode); } - [Fact] - public void Tokens_DoNotDisplacePositionalArguments() + [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) { - // The tokens sit between positional arguments: stripping them must leave 12/false/true/30 in place. - var trigger = ProximityTrigger.Parse("proximity:12:wake:true:false:mode:tick:true:30"); + var trigger = KillTrigger.Parse(definition); - Assert.Equal(12, trigger.Range); - Assert.False(trigger.PlayersOnly); - Assert.True(trigger.RequireLineOfSight); + Assert.Equal(filter, trigger.FilterType); + Assert.True(trigger.RequirePlayerKiller); Assert.Equal(TimeSpan.FromSeconds(30), trigger.Cooldown); - Assert.True(trigger.Wake); - Assert.Equal(CycleMode.Tick, trigger.Mode); + Assert.False(trigger.Wake); + Assert.Equal(CycleMode.Now, trigger.Mode); + Assert.Null(trigger.When); } [Fact] @@ -77,7 +82,7 @@ public void Tokens_RoundTripThroughSerialize() [Fact] public void When_CompilesOnceAtParseTime() { - var trigger = ProximityTrigger.Parse("proximity:8:when:1 + 1"); + var trigger = ProximityTrigger.Parse("proximity:8:true:false:5:0:when:1 + 1"); Assert.NotNull(trigger.When); Assert.True(trigger.When.IsValid); @@ -151,7 +156,7 @@ public void Skill_CarriesTokens() [Fact] public void UnknownTokenValue_KeepsTheDefault() { - var trigger = ProximityTrigger.Parse("proximity:8:mode:sideways:wake:maybe"); + 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); diff --git a/Projects/ModernSpawner/Core/ModernSpawner.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs index 511608e..7c873bf 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -656,12 +656,13 @@ 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 + /// 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. 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. /// internal void EnsureTriggersActive() diff --git a/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs b/Projects/ModernSpawner/Serialization/SpawnerJsonImporter.cs index dbfa929..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; @@ -334,10 +335,16 @@ private static string BuildTriggerDefinition(TriggerData trigger) } if (typeSpan.InsensitiveEquals("timeofday")) { - // "timeofday" is retired: it maps onto the game-time window, whose end hour is exclusive - // where the legacy one was inclusive. - var endHour = Math.Clamp(trigger.EndHour, 0, 23) + 1; - return $"game_time_window:{Math.Clamp(trigger.StartHour, 0, 23)}:{endHour}:{trigger.NightOnly}:{trigger.DayOnly}"; + // "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/Triggers/GameTimeWindowTrigger.cs b/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs index 300a20c..3fd9810 100644 --- a/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs +++ b/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs @@ -292,36 +292,72 @@ public static GameTimeWindowTrigger Parse(string definition) } /// - /// 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. + /// 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 the - /// window grammar's exclusive end: 17 becomes 18, and the legacy whole-day default 23 becomes - /// . The legacy polling cooldown has no counterpart on a gate and is dropped. + /// 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(':'); - var trigger = new GameTimeWindowTrigger - { - // The legacy default range was 0..23 inclusive, i.e. the whole day. - EndHour = EndOfDay - }; - if (parts.Length > 1 && int.TryParse(parts[1], out var startHour)) + // 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)) { - trigger.StartHour = Math.Clamp(startHour, 0, 23); + legacyStart = parsedStart; } - if (parts.Length > 2 && int.TryParse(parts[2], out var endHour)) + if (parts.Length > 2 && int.TryParse(parts[2], out var parsedEnd)) { - trigger.EndHour = Math.Clamp(endHour, 0, 23) + 1; + 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/KillTrigger.cs b/Projects/ModernSpawner/Triggers/KillTrigger.cs index 18d7b04..82d50c7 100644 --- a/Projects/ModernSpawner/Triggers/KillTrigger.cs +++ b/Projects/ModernSpawner/Triggers/KillTrigger.cs @@ -11,6 +11,10 @@ namespace Server.Engines.ModernSpawner.Triggers; /// public class KillTrigger : TriggerBase { + // 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"; @@ -64,7 +68,7 @@ public KillTrigger(int requiredKills, bool requireAllDead = false) : this() /// True when the kill counts. public bool CountsKill(in TriggerContext context) { - if (Spawner == null || context.KilledEntity == null) + if (context.KilledEntity == null) { return false; } @@ -111,10 +115,15 @@ public override bool Evaluate(in TriggerContext context) return false; } - // Check if all dead is required - if (RequireAllDead && Spawner.Spawned.Count > 0) + // 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) { - return false; + var spawner = Spawner; + if (spawner == null || spawner.Spawned.Count > 0) + { + return false; + } } var state = State; @@ -187,7 +196,7 @@ public static KillTrigger Parse(string definition) var wake = false; var mode = CycleMode.Now; string when = null; - var positional = TriggerTokens.Strip(definition, ref wake, ref mode, ref when); + var positional = TriggerTokens.Strip(definition, PositionalArity, ref wake, ref mode, ref when); var parts = positional.Split(':'); var trigger = new KillTrigger(); diff --git a/Projects/ModernSpawner/Triggers/ProximityTrigger.cs b/Projects/ModernSpawner/Triggers/ProximityTrigger.cs index 8ee3c0e..e502f96 100644 --- a/Projects/ModernSpawner/Triggers/ProximityTrigger.cs +++ b/Projects/ModernSpawner/Triggers/ProximityTrigger.cs @@ -13,6 +13,9 @@ public class ProximityTrigger : TriggerBase { 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"; @@ -152,7 +155,7 @@ public static ProximityTrigger Parse(string definition) var wake = false; var mode = CycleMode.Now; string when = null; - var positional = TriggerTokens.Strip(definition, ref wake, ref mode, ref when); + var positional = TriggerTokens.Strip(definition, PositionalArity, ref wake, ref mode, ref when); // Skip the "proximity:" prefix var parts = positional.Split(':'); diff --git a/Projects/ModernSpawner/Triggers/SkillTrigger.cs b/Projects/ModernSpawner/Triggers/SkillTrigger.cs index 87b37ac..728aed0 100644 --- a/Projects/ModernSpawner/Triggers/SkillTrigger.cs +++ b/Projects/ModernSpawner/Triggers/SkillTrigger.cs @@ -25,6 +25,9 @@ public enum SkillOutcome /// public class SkillTrigger : TriggerBase { + // skill:skillName:range:valueWindow:requireLos:cooldownSeconds - tokens start after these. + private const int PositionalArity = 6; + /// public override string TriggerType => "skill"; @@ -223,7 +226,7 @@ public static SkillTrigger Parse(string definition) var wake = false; var mode = CycleMode.Now; string when = null; - var positional = TriggerTokens.Strip(definition, ref wake, ref mode, ref when); + var positional = TriggerTokens.Strip(definition, PositionalArity, ref wake, ref mode, ref when); var parts = positional.Split(':'); if (parts.Length < 2) diff --git a/Projects/ModernSpawner/Triggers/SpeechTrigger.cs b/Projects/ModernSpawner/Triggers/SpeechTrigger.cs index ca44b5e..6b385fb 100644 --- a/Projects/ModernSpawner/Triggers/SpeechTrigger.cs +++ b/Projects/ModernSpawner/Triggers/SpeechTrigger.cs @@ -1,5 +1,6 @@ using System; using System.Text.RegularExpressions; +using Server.Logging; using Server.Text; namespace Server.Engines.ModernSpawner.Triggers; @@ -11,6 +12,11 @@ namespace Server.Engines.ModernSpawner.Triggers; /// public class SpeechTrigger : TriggerBase { + 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 @@ -50,6 +56,7 @@ public class SpeechTrigger : TriggerBase public bool PlayersOnly { get; set; } = true; private Regex _compiledRegex; + private bool _regexBuilt; /// Creates a trigger with the documented defaults. public SpeechTrigger() => Cooldown = TimeSpan.FromSeconds(5); @@ -107,12 +114,44 @@ public override bool Evaluate(in TriggerContext context) return MatchesSpeech(context.Speech); } - private Regex BuildRegex() => - new( - Keyword ?? string.Empty, - RegexOptions.Compiled | (IgnoreCase ? RegexOptions.IgnoreCase : RegexOptions.None), - RegexTimeout - ); + /// + /// 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; + } + + _regexBuilt = true; + + if (!UseRegex || string.IsNullOrEmpty(Keyword)) + { + return; + } + + 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) { @@ -125,7 +164,12 @@ private bool MatchesSpeech(string 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. - _compiledRegex ??= BuildRegex(); + EnsureRegex(); + + if (_compiledRegex == null) + { + return false; + } try { @@ -151,7 +195,9 @@ public override void Activate(ModernSpawner 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 = UseRegex && !string.IsNullOrEmpty(Keyword) ? BuildRegex() : null; + _compiledRegex = null; + _regexBuilt = false; + EnsureRegex(); } /// @@ -159,6 +205,7 @@ public override void Deactivate() { base.Deactivate(); _compiledRegex = null; + _regexBuilt = false; } /// @@ -189,7 +236,7 @@ public static SpeechTrigger Parse(string definition) var wake = false; var mode = CycleMode.Now; string when = null; - var positional = TriggerTokens.Strip(definition, ref wake, ref mode, ref when); + var positional = TriggerTokens.Strip(definition, PositionalArity, ref wake, ref mode, ref when); var parts = positional.Split(':'); var trigger = new SpeechTrigger(); diff --git a/Projects/ModernSpawner/Triggers/TriggerContext.cs b/Projects/ModernSpawner/Triggers/TriggerContext.cs index 22de133..e5837e3 100644 --- a/Projects/ModernSpawner/Triggers/TriggerContext.cs +++ b/Projects/ModernSpawner/Triggers/TriggerContext.cs @@ -57,10 +57,4 @@ public static TriggerContext ForKill(ModernSpawner spawner, IEntity killed, Mobi /// 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); - - /// Context for a gate re-evaluation, which depends on the clock rather than an actor. - /// The spawner being evaluated. - /// A context carrying only the spawner. - public static TriggerContext ForGate(ModernSpawner spawner) => - new(spawner, null, null, null, default, 0.0, false); } diff --git a/Projects/ModernSpawner/Triggers/TriggerTokens.cs b/Projects/ModernSpawner/Triggers/TriggerTokens.cs index b0051a0..912b3b6 100644 --- a/Projects/ModernSpawner/Triggers/TriggerTokens.cs +++ b/Projects/ModernSpawner/Triggers/TriggerTokens.cs @@ -9,10 +9,17 @@ namespace Server.Engines.ModernSpawner.Triggers; /// /// /// -/// Tokens are name/value pairs that sit anywhere among a definition's positional arguments and in any -/// order, so proximity:8:true:false:5:Player:wake:true:mode:tick and -/// proximity:8: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. +/// 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 @@ -97,6 +104,10 @@ private static bool IsPairToken(ReadOnlySpan name) => /// 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. @@ -104,7 +115,7 @@ private static bool IsPairToken(ReadOnlySpan name) => /// with every token removed, or itself /// when it carried none. /// - public static string Strip(string definition, ref bool wake, ref CycleMode mode, ref string when) + public static string Strip(string definition, int positionalArity, ref bool wake, ref CycleMode mode, ref string when) { if (string.IsNullOrEmpty(definition)) { @@ -124,32 +135,38 @@ public static string Strip(string definition, ref bool wake, ref CycleMode mode, 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 (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) + if (segmentIndex >= positionalArity) { - // 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; - continue; + 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) @@ -159,6 +176,7 @@ public static string Strip(string definition, ref bool wake, ref CycleMode mode, sb.Append(segment); wrote = true; + segmentIndex++; if (separator < 0) { From 1f95298a6283fe0632784db43da3086cbc7217ae Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:42:17 -0700 Subject: [PATCH 06/15] chore: pin ModernUO to c02909e2c (main after #2640) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- ModernUO | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ModernUO b/ModernUO index 908e9ff..c02909e 160000 --- a/ModernUO +++ b/ModernUO @@ -1 +1 @@ -Subproject commit 908e9ffbc2a0b311433522926794e681cb15e4ce +Subproject commit c02909e2c8cffe8834ab35ad5e5e41e3b16d26cf From 5657ad9ee6fe36114317b9ec25f67ac893b95290 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:14:59 -0700 Subject: [PATCH 07/15] feat(triggers): gate set, bounded pending cycles, refractory, per-entry deadlines; OnTick applies the D2 table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spawner side of the D2 trigger state machine replaces the interim bridge that spawned from inside event dispatch. - ModernSpawner.Triggers.cs (new): the gate set (a ulong bitmask by definition index with a List overflow past 64), the acceptance order every event passes through (registration, Evaluate, cooldown, refractory, when:, queue room) with every side effect applied only on acceptance, the E0-E6 routing, D1 re-validation in DrainOne, RunCycle with its re-entrancy guard and a per-dispatch drain bound of 10, G1-G4 on the gate edges, M3/M4, X1 and the deferred L1/L2 restore. - OnTick is overridden with the T0-T6 precedence; a row that parks leaves the timer unarmed, and a row that runs re-arms at the earliest entry deadline. - Entry selection filters on ModernSpawnerEntry.NextEligible; a placement sets the entry's own delay, a failure backs it off by min(30s, MinDelay). Event cycles and manual spawns bypass the deadlines, timer cycles honour them. - Registration no longer depends on Running (A1/A2): OnStopped keeps it, only deactivation and deletion tear it down, and movement/speech dispatch while stopped so a wake: trigger can start the spawner. - Group respawn is one bulk operation with the before/after scripts run once around it rather than once per Spawn() inside it. - The kill counter moves to the spawner's acceptance path; the kill dispatch hands over every kill that passes the trigger's filters and the spawner both evaluates it and advances the counter. - The cycle's triggering mobile is resolved from the queued slot and threaded into PositioningContext and the script contexts, so player_relative still positions relative to the player after a deferred drain. Tests: TriggerStateMachineTests covers the design's §11 matrix row by row; SkillTriggerTests, SpeechTriggerRegexTests and the lifecycle and registration tests are updated to the D2 contract. 539 pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../Core/ModernSpawnerLifecycleTests.cs | 10 +- .../ModernSpawnerTriggerRegistrationTests.cs | 29 +- .../Core/SkillTriggerTests.cs | 27 +- .../Core/TriggerStateMachineTests.cs | 1272 +++++++++++++++++ .../Triggers/SpeechTriggerRegexTests.cs | 8 +- .../Core/ModernSpawner.Triggers.cs | 829 +++++++++++ Projects/ModernSpawner/Core/ModernSpawner.cs | 374 +++-- .../ModernSpawner/Core/ModernSpawnerEntry.cs | 8 + .../ModernSpawner/Triggers/KillTrigger.cs | 9 +- .../ModernSpawner/Triggers/TriggerSystem.cs | 77 +- 10 files changed, 2393 insertions(+), 250 deletions(-) create mode 100644 Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs create mode 100644 Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs index d587717..e103c8e 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs @@ -269,10 +269,8 @@ public void Kill_DispatchesOnDespawnScriptAndKillTrigger() spawner.TriggerActivated = true; 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.Equal(0, spawner.PendingCycleCount); @@ -284,7 +282,9 @@ 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. + // ...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(); diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs index b7453a1..0e393e1 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs @@ -71,26 +71,33 @@ public void DeletingAnActivatedSpawner_LeavesNothingRegistered() } [Fact] - public void Stop_UnregistersEvenWhenFlagWasClearedAfterRegistration() + public void Stop_KeepsTheRegistrationAndDispatchAlive() { var spawner = Place(); 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] @@ -158,7 +165,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 diff --git a/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs b/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs index 92c15ad..3f4d563 100644 --- a/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs +++ b/Projects/ModernSpawner.Tests/Core/SkillTriggerTests.cs @@ -11,13 +11,17 @@ 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.AddTriggerDefinition(definition); spawner.TriggerActivated = true; @@ -40,9 +44,10 @@ public void PlayerSkillUse_InRange_FiresTrigger() var player = PlacePlayer(new Point3D(1503, 1500, 0)); try { - Assert.Equal(0, spawner.PendingCycleCount); + Assert.Empty(spawner.Spawned); SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.Equal(1, spawner.PendingCycleCount); + 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.Equal(0, spawner.PendingCycleCount); + Assert.Empty(spawner.Spawned); } finally { @@ -76,9 +81,9 @@ public void FailureOnlyTrigger_IgnoresSuccess() try { SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.Equal(0, spawner.PendingCycleCount); + Assert.Empty(spawner.Spawned); SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, -0.1); - Assert.Equal(1, spawner.PendingCycleCount); + Assert.Single(spawner.Spawned); } finally { @@ -96,7 +101,7 @@ public void CreatureSkillUse_DoesNotFire() try { SkillCheck.Mobile_SkillCheckDirectTarget(rabbit, SkillName.Mining, null, 1.0); - Assert.Equal(0, spawner.PendingCycleCount); + Assert.Empty(spawner.Spawned); } finally { @@ -113,14 +118,16 @@ public void Cooldown_SuppressesSecondFiringUntilElapsed() try { SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.Equal(1, spawner.PendingCycleCount); + 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.Equal(0, spawner.PendingCycleCount); + Assert.Single(spawner.Spawned); ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(6)); SkillCheck.Mobile_SkillCheckDirectTarget(player, SkillName.Mining, null, 1.0); - Assert.Equal(1, spawner.PendingCycleCount); + 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..a781397 --- /dev/null +++ b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs @@ -0,0 +1,1272 @@ +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(); + } + } + + #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(); + } + } + + #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(); + } + } + + #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); + + // 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(); + } + } + + #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); + + loaded.OnWorldLoaded(); + + Assert.Empty(loaded.Spawned); + 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(); + } + } + + #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); + + // Both parked: the tick arms at the earliest deadline instead of spawning. + rabbit.NextEligible = Core.Now + TimeSpan.FromHours(1); + bird.NextEligible = Core.Now + TimeSpan.FromHours(2); + DeleteSpawned(spawner); + + spawner.OnTick(); + Assert.Empty(spawner.Spawned); + } + 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 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(); + } + } + + #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(); + } + } + + #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 const 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 const 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; + } + } +} diff --git a/Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs b/Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs index 2f7e5e3..b549212 100644 --- a/Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs +++ b/Projects/ModernSpawner.Tests/Triggers/SpeechTriggerRegexTests.cs @@ -54,6 +54,7 @@ public void InvalidPattern_RegistersWithoutThrowingAndNeverMatches() TriggerSystem.Instance.OnSpeech(player, "anything at all", player.Location, player.Map, spawner); Assert.Equal(0, spawner.PendingCycleCount); + Assert.Empty(spawner.Spawned); } finally { @@ -71,10 +72,13 @@ public void ValidPattern_StillMatches() try { TriggerSystem.Instance.OnSpeech(player, "nothing here", player.Location, player.Map, spawner); - Assert.Equal(0, spawner.PendingCycleCount); + 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.Equal(1, spawner.PendingCycleCount); + Assert.Single(spawner.Spawned); + Assert.Equal(0, spawner.PendingCycleCount); } finally { diff --git a/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs new file mode 100644 index 0000000..b9ef5d5 --- /dev/null +++ b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs @@ -0,0 +1,829 @@ +using System; +using System.Collections.Generic; +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 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 drain either spends it or + // drops it. + private bool _runNowRequested; + private Mobile _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() + { + // T0 + if (Deleted || !Running) + { + return; + } + + // T1: base Group is "all dead, then respawn", and the respawn is one bulk operation. + if (Group) + { + Defrag(); + + if (Spawned.Count > 0 || !GateOpen || (_eventCount > 0 && PendingCycleCount == 0)) + { + return; + } + + if (_eventCount > 0) + { + PopOldestSlot(); + } + + // Respawn arms the timer itself. + Respawn(); + return; + } + + // T2, T3, T4 + if (!GateOpen || IsFull || (_eventCount > 0 && PendingCycleCount == 0)) + { + return; + } + + var now = Core.Now; + + // T5 + if (!HasDueEntry(now)) + { + ArmAtEarliestDeadline(now); + return; + } + + // T6 + var slot = _eventCount > 0 ? PopOldestSlot() : null; + RunCycle(slot, false); + 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. + /// + /// The trigger that matched. + /// The event being dispatched. + /// True when the event was accepted and bought a cycle. + internal bool RequestCycle(ITrigger trigger, in TriggerContext context) + { + if (trigger == null || Deleted) + { + return false; + } + + // The script / command entry point is an event source in its own right, so it does not belong + // to a registration and is not gated by one. + if (!ReferenceEquals(trigger, ExternalTrigger.Instance) && + (!_triggerActivated || _registrationGeneration == 0)) + { + 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; + if (state != null && now < state.CooldownUntil) + { + return false; + } + + if (now < _refractoryUntil) + { + return false; + } + + if (!WhenPasses(trigger, context.TriggeringMobile)) + { + return false; + } + + var wake = trigger.Wake; + var latched = _maxPendingCycles > 0; + + if (latched) + { + // E6: the queue is the only thing standing between this event and a cycle, and it is full. + if (PendingCycleCount >= _maxPendingCycles) + { + return false; + } + } + else if (!GateOpen || IsFull || !(Running || (wake && Entries.Count > 0))) + { + // MaxPendingCycles == 0 reproduces XmlSpawner: run now or drop, never latch. Nothing can + // run now, so the event is refused rather than quietly eaten. + 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 = mobile; + } + + // 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) + { + 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 + ); + } + + 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 slot stays where it is and the next drain or tick spends it. + if (_isRunningCycle) + { + return; + } + + if (PendingCycleCount > 0) + { + RunCycle(PopOldestSlot(), true); + } + else if (_runNowRequested) + { + var mobile = _runNowMobile; + ClearRunNow(); + RunCycleCore(mobile, true); + } + else + { + return; + } + + // A cycle can buy more cycles through its scripts; they go round the same loop, bounded. + if (PendingCycleCount > 0 || _runNowRequested) + { + TriggerSystem.Instance.RequestDrain(this); + } + } + + /// + /// 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, 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 + if (_eventCount == 0) + { + RunCycleCore(null, true); + } + else if (PendingCycleCount > 0) + { + DrainOne(); + } + + 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 (!_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. + private void RunCycle(PendingCycle slot, bool bypassDeadlines) => + RunCycleCore(slot == null ? null : World.FindMobile(slot.TriggeringMobile), bypassDeadlines); + + /// + /// Runs one cycle body with bound to it. Never re-enters: a + /// cycle raised from inside another cycle is dropped here, because its slot is still queued. + /// + /// The mobile the cycle belongs to, or null. + /// Whether the cycle ignores per-entry deadlines. + private void RunCycleCore(Mobile triggeringMobile, bool bypassDeadlines) + { + if (_isRunningCycle) + { + return; + } + + var previous = _cycleTriggeringMobile; + _cycleTriggeringMobile = triggeringMobile; + _isRunningCycle = true; + try + { + SpawnCore(bypassDeadlines); + } + finally + { + _isRunningCycle = false; + _cycleTriggeringMobile = previous; + } + } + + /// 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 = null; + } + + /// 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 at the earliest per-entry deadline, clamped to the spawner's max delay. Entries + /// that carry no deadline are always due and never hold the timer back; when no entry carries one + /// the spawner falls back to its own random delay. + /// + /// 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; + + if (entries != null) + { + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + if (entry.NextEligible == default || !IsEligible(entry, subgroup)) + { + continue; + } + + var delay = entry.NextEligible - now; + if (delay < TimeSpan.Zero) + { + delay = TimeSpan.Zero; + } + + if (!found || delay < earliest) + { + earliest = delay; + found = true; + } + } + } + + if (!found) + { + DoTimer(); + return; + } + + var maxDelay = MaxDelay; + DoTimer(earliest > maxDelay ? maxDelay : earliest); + } + + #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 7c873bf..1605772 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -458,20 +458,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; + } } } @@ -487,49 +513,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.AllEntries: - SpawnAllEntries(); + 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 SpawnAllEntries() + /// 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; @@ -537,7 +579,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; } @@ -553,14 +595,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; } @@ -571,13 +613,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); } /// @@ -673,10 +733,35 @@ internal void EnsureTriggersActive() // re-bind state by id before anything parses the list again. SyncTriggerStates(); - if (Running && _triggerActivated && _triggerDefs is { Count: > 0 }) + // 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; + } } /// @@ -879,8 +964,9 @@ internal bool EnqueuePendingCycle(Guid triggerId, Serial triggeringMobile) } /// - /// Test seam for the queue until RequestCycle lands (task 3): same bounded enqueue the - /// trigger path will use. + /// 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 . @@ -929,11 +1015,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() { @@ -942,10 +1027,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); } /// @@ -962,7 +1043,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); @@ -1002,7 +1086,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); } } } @@ -1230,186 +1319,39 @@ public override void GetSpawnerProperties(IPropertyList list) if (_triggerActivated) { - list.Add(1050039, $"{"trigger:"}\t{(PendingCycleCount > 0 ? "pending" : "waiting")}"); - } - - if (_useSmartPositioning) - { - list.Add(1050039, $"{"smart pos:"}\tyes"); - } - } - - /// - /// Triggers the spawner from an external source (trigger system). - /// - public void Trigger() - { - if (!_triggerActivated) - { - return; - } - - // Guid.Empty: an external caller is not one of this spawner's definitions. - EnqueuePendingCycle(Guid.Empty, Serial.Zero); - - if (!Running) - { - Start(); - } - else - { - // Force an immediate spawn - Spawn(); - } - } + list.Add(1050039, $"{"pending:"}\t{PendingCycleCount}"); - /// - /// Resets the trigger state: every queued cycle is dropped (M4). Registrations, cooldowns and - /// kill counters survive. - /// - public void ResetTrigger() - { - ClearPendingCycles(); - } - - /// - /// Called by a gate when its window opens, naming the gate by its position in - /// (-1 while unbound). - /// - /// - /// Interim: this still does what the old window-open bridge did, queue a cycle and spawn. D2's - /// G1-G4 rows replace it with the spawner's set of open gates, where an open edge only authorizes - /// the tick and a close edge leaves queued cycles alone. - /// - /// Position of the gate's definition, or -1. - public void OnGateOpened(int definitionIndex) - { - if (!_triggerActivated || !Running) - { - return; - } - - var definitions = _triggerDefs; - var id = definitionIndex >= 0 && definitions != null && definitionIndex < definitions.Count - ? definitions[definitionIndex].Id - : Guid.Empty; - - EnqueuePendingCycle(id, Serial.Zero); - - // Force an immediate spawn check when the window opens - Spawn(); - } - - /// - /// Called by a gate when its window closes, naming the gate by its position in - /// (-1 while unbound). - /// - /// - /// Interim: the queue is dropped, standing in for the removed _triggered flag. G3 itself does - /// not clear pending - task 3 replaces this with the gate set, which closes the window without - /// discarding cycles that were already bought. - /// - /// Position of the gate's definition, or -1. - public void OnGateClosed(int definitionIndex) - { - if (!_triggerActivated) - { - return; - } - - ClearPendingCycles(); - } - - /// - /// 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. - /// - /// - /// Interim: this is the old bridge with the definition id, the triggering - /// mobile and the cooldown advance filled in, so it still queues a slot and spawns inline. D2's - /// acceptance order (E0-E6) - refractory, when:, mode:, wake:, queue bound - and - /// the deferred drain that makes the queued slot the thing that actually runs land with - /// . - /// - /// The trigger that matched, or null for an external source. - /// The event being dispatched. - internal void RequestCycle(ITrigger trigger, in TriggerContext context) - { - if (!_triggerActivated) - { - return; - } - - var id = trigger?.Id ?? Guid.Empty; - - var mobile = context.TriggeringMobile; - var serial = mobile == null ? Serial.Zero : mobile.Serial; - - EnqueuePendingCycle(id, serial); - - // Accepting the event is what advances the cooldown; Evaluate only ever compared against it. - if (trigger != null) - { - var state = trigger.State; - if (state != null && trigger.Cooldown > TimeSpan.Zero) + if (GateCount > 0) { - state.CooldownUntil = Core.Now + trigger.Cooldown; + list.Add(1050039, $"{"gate:"}\t{(GateOpen ? "open" : "closed")}"); } } - TriggerSystem.Instance.RequestDrain(this); - - if (!Running) - { - Start(); - } - else + if (_useSmartPositioning) { - Spawn(); + list.Add(1050039, $"{"smart pos:"}\tyes"); } } - /// - /// Runs one queued cycle, called by the trigger system once the outermost dispatch has returned. - /// - /// - /// Interim: a no-op. still spawns inline, so there is nothing left for the - /// drain to do; D2's D1 re-validation (deleted, stopped, deactivated, gate closed, full) and the - /// RunCycle that consumes the queued slot land with the spawner side of the state machine. - /// - internal void DrainOne() - { - } - [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() - { - // L1: a save written before MaxPendingCycles was lowered can carry more slots than the bound - // now allows, and a deactivated spawner holds none at all. - if (!_triggerActivated) - { - ClearPendingCycles(); - } - else - { - TrimPendingCycles(); - } - - } + private void AfterWorldLoad() => OnWorldLoaded(); /// /// Copies the modern fields the dupe contract cannot reach. The base override copies the entries; @@ -1445,11 +1387,25 @@ public override void OnAfterDuped(Item newItem) /// public override void OnDelete() { - // 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); + // 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(); - base.OnDelete(); + 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); + } } /// @@ -1473,14 +1429,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; @@ -1509,7 +1465,9 @@ public override void OnSpeech(SpeechEventArgs e) /// 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; } diff --git a/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs b/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs index 238ac11..3f1543e 100644 --- a/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs +++ b/Projects/ModernSpawner/Core/ModernSpawnerEntry.cs @@ -147,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/Triggers/KillTrigger.cs b/Projects/ModernSpawner/Triggers/KillTrigger.cs index 82d50c7..72e49a1 100644 --- a/Projects/ModernSpawner/Triggers/KillTrigger.cs +++ b/Projects/ModernSpawner/Triggers/KillTrigger.cs @@ -136,9 +136,12 @@ public override bool Evaluate(in TriggerContext context) /// accepted and is set. /// /// - /// Interim: a kill advances the counter whether or not the spawner accepts the resulting event, and - /// D2's acceptance path (ModernSpawner.RequestCycle) is where that advance belongs. Until - /// that lands, the kill dispatch calls this so multi-kill thresholds keep working. + /// 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) diff --git a/Projects/ModernSpawner/Triggers/TriggerSystem.cs b/Projects/ModernSpawner/Triggers/TriggerSystem.cs index fd05412..118bf33 100644 --- a/Projects/ModernSpawner/Triggers/TriggerSystem.cs +++ b/Projects/ModernSpawner/Triggers/TriggerSystem.cs @@ -149,8 +149,10 @@ public void ActivateTriggers(ModernSpawner spawner) } // Register before activating: a gate that opens during Activate runs spawner code, and that code - // must see a consistent registry. + // 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); @@ -200,6 +202,7 @@ public void DeactivateTriggers(ModernSpawner 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); } @@ -278,8 +281,46 @@ public void RequestDrain(ModernSpawner spawner) spawner.DrainRequested = true; _drainList.Add(spawner); + + // 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) + { + DrainAll(); + } + } + + /// + /// 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 (spawner == null) + { + return; + } + + spawner.DrainRequested = false; + spawner.DrainsThisRound = 0; + + for (var i = 0; i < _drainList.Count; i++) + { + if (_drainList[i] == spawner) + { + // Nulled rather than removed: DrainAll may be walking this list by index right now. + _drainList[i] = null; + } + } } + /// + /// 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. @@ -298,6 +339,13 @@ private void DrainAll() for (var i = 0; i < _drainList.Count; i++) { var spawner = _drainList[i]; + + // Null means the entry was cancelled after it was queued. + if (spawner == null) + { + continue; + } + spawner.DrainRequested = false; if (spawner.Deleted) @@ -312,7 +360,16 @@ private void DrainAll() { for (var i = 0; i < _drainList.Count; i++) { - _drainList[i].DrainRequested = false; + var spawner = _drainList[i]; + if (spawner == null) + { + continue; + } + + spawner.DrainRequested = false; + + // The recursion budget is per outer dispatch, so it resets with the list. + spawner.DrainsThisRound = 0; } _drainList.Clear(); @@ -509,19 +566,13 @@ public void OnEntityKilled(ModernSpawner spawner, IEntity killed, Mobile killer) for (var i = 0; i < triggers.Count; i++) { var trigger = triggers[i]; - var accepted = trigger.Evaluate(in context); - // Interim: a kill that passes the trigger's filters advances its counter whether or not - // the resulting event is accepted, and Evaluate is pure, so the advance happens here - // until the spawner's acceptance path owns it. - if (trigger.CountsKill(in context)) + // 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, in context)) { - trigger.AdvanceKillCount(accepted); - } - - if (accepted) - { - spawner.RequestCycle(trigger, in context); break; } } From feb2504fe46254a108461273804ef9cd0ff1727f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:37:29 -0700 Subject: [PATCH 08/15] fix(triggers): gate-open drain budget; one-shot run-now; group semantics on every cycle source; drain on cycle exit Fix round 1 on the D2 state machine. Defects - OnGateOpened called DrainOne directly, and the per-dispatch recursion budget is only reset when the drain list is exhausted, so every gate opening leaked one drain: after ten E2 -> G1 sequences the eleventh opening logged the recursion warning and stopped draining. G1 now goes through RequestDrain, which flushes at depth 0 and hands the budget back. - The Max == 0 run-now request could latch. DrainOne's "a cycle is already in flight" early return left the flag and the mobile set for an arbitrary later drain to spend; it now drops the request (it is one-shot by definition), as does the budget-exhausted return, and the mobile is held as a Serial rather than as a reference that could root a deleted mobile. - Event and gate cycles ignored base Group. T1 now applies to every cycle source: RunCycleCore does nothing while the pack is alive (the slot is kept) and runs the one bulk Respawn when it is dead, so a tick, an event drain and a window opening all behave the same on a group spawner. Rulings - A cycle's follow-ups are drained when the cycle exits, through the same bounded drain list, so a tick-initiated cycle no longer defers what its scripts bought to the next tick. - G1's window-open cycle honours per-entry deadlines; the bypass belongs to mode:now event drains only. - A kill below the threshold counts while the trigger is on cooldown: the cooldown gates the trigger firing, not the kills that build up to it, so KillTrigger.CountsKill no longer compares it. - OnStarted no longer re-registers: registration follows TriggerActivated and the definition list, never Running (A1). Minors: OnTick reads IsAuthorizedForTick instead of two inlined copies; RunCycleCore reports whether it ran so a caller keeps its slot; OnGateClosed gets OnGateOpened's Deleted guard; RequestCycle documents that non-kill triggers arrive pre-evaluated by their dispatcher. Tests: seven new facts - the gate-open budget, the one-shot run-now, group semantics on an event cycle (populated and dead pack), kill counting under cooldown, when: accept/reject, and the tick-initiated follow-up drain. 546 pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../Core/TriggerStateMachineTests.cs | 287 +++++++++++++++++- .../Core/ModernSpawner.Triggers.cs | 159 +++++++--- Projects/ModernSpawner/Core/ModernSpawner.cs | 9 +- .../ModernSpawner/Triggers/KillTrigger.cs | 16 +- 4 files changed, 414 insertions(+), 57 deletions(-) diff --git a/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs index a781397..31d7c04 100644 --- a/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs +++ b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs @@ -165,6 +165,40 @@ public void G2_G3_G4_OverlappingGates_TrackTheOpenSetById() } } + [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 @@ -462,6 +496,36 @@ public void Max0_LegacyDrop_NoCooldownAdvance() } } + [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 @@ -939,6 +1003,78 @@ public void T1_Group_AllDead_OneBulkRespawnWithScriptsOnce() } } + [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(); + } + } + #endregion #region T5 - per-entry deadlines @@ -1080,6 +1216,84 @@ public void Kill_ThresholdCountsThroughTheAcceptancePath() } } + [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 a refusal after the + // evaluation moves nothing - so the threshold stays reached for the next kill. + spawner.NotifySpawnedDeath(rabbit, null); + Assert.Equal(2, state.KillCount); + Assert.Equal(spawnedBefore, spawner.Spawned.Count); + + ModernSpawnerTestServer.AdvanceClock(TimeSpan.FromSeconds(61)); + + spawner.NotifySpawnedDeath(rabbit, null); + Assert.Equal(0, state.KillCount); + Assert.Equal(spawnedBefore + 1, spawner.Spawned.Count); + + 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(); + } + } + #endregion #region §5 - reentrancy, the drain list and the recursion bound @@ -1146,6 +1360,32 @@ public void Script_ReentrantEvent_IsQueuedAndBoundedByTheRecursionLimit() } } + [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); + } + finally + { + ExternalTriggerProbeRule.Reset(); + DeleteSpawned(spawner); + spawner.Delete(); + } + } + #endregion /// @@ -1155,7 +1395,7 @@ public void Script_ReentrantEvent_IsQueuedAndBoundedByTheRecursionLimit() private sealed class DispatchProbeRule : IPositioningRule { /// The rule name entries reference. - public const string Name = "d2_dispatch_probe"; + public static readonly string Name = "d2_dispatch_probe"; private static ModernSpawner _watched; @@ -1215,7 +1455,7 @@ public Point3D GetPosition(PositioningContext context) private sealed class ReentrantProbeRule : IPositioningRule { /// The rule name entries reference. - public const string Name = "d2_reentrant_probe"; + public static readonly string Name = "d2_reentrant_probe"; private static ModernSpawner _watched; private static Mobile _mover; @@ -1269,4 +1509,47 @@ public Point3D GetPosition(PositioningContext context) 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/Core/ModernSpawner.Triggers.cs b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs index b9ef5d5..79f9695 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs @@ -54,10 +54,11 @@ public partial class ModernSpawner 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 drain either spends it or - // drops it. + // 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 Mobile _runNowMobile; + 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. @@ -131,44 +132,52 @@ public override void OnTick() return; } - // T1: base Group is "all dead, then respawn", and the respawn is one bulk operation. - if (Group) + 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 || !GateOpen || (_eventCount > 0 && PendingCycleCount == 0)) + if (Spawned.Count > 0) { return; } - - if (_eventCount > 0) - { - PopOldestSlot(); - } - - // Respawn arms the timer itself. - Respawn(); - return; } // T2, T3, T4 - if (!GateOpen || IsFull || (_eventCount > 0 && PendingCycleCount == 0)) + if (!IsAuthorizedForTick) { return; } var now = Core.Now; - // T5 - if (!HasDueEntry(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 + // 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. var slot = _eventCount > 0 ? PopOldestSlot() : null; - RunCycle(slot, false); + 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); } @@ -183,6 +192,12 @@ public override void OnTick() /// 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 event being dispatched. @@ -293,7 +308,7 @@ internal bool RequestCycle(ITrigger trigger, in TriggerContext context) else { _runNowRequested = true; - _runNowMobile = mobile; + _runNowMobile = serial; } // E1: run it as soon as the dispatch that raised the event returns. E2 (gate closed or full) @@ -315,6 +330,10 @@ 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++; @@ -348,32 +367,34 @@ internal void DrainOne() return; } - // Queued, never nested: the slot stays where it is and the next drain or tick spends it. + // 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) { - RunCycle(PopOldestSlot(), true); + 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(mobile, true); - } - else - { - return; + RunCycleCore(ResolveMobile(mobile), true); } - // A cycle can buy more cycles through its scripts; they go round the same loop, bounded. - if (PendingCycleCount > 0 || _runNowRequested) - { - TriggerSystem.Instance.RequestDrain(this); - } + // 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. } /// @@ -425,14 +446,18 @@ public void OnGateOpened(int definitionIndex) return; } - // G1 + // 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) { - RunCycleCore(null, true); + RunCycleCore(null, false); } else if (PendingCycleCount > 0) { - DrainOne(); + // 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); @@ -446,7 +471,7 @@ public void OnGateOpened(int definitionIndex) /// Position of the gate's definition, or -1. public void OnGateClosed(int definitionIndex) { - if (!_triggerActivated) + if (Deleted || !_triggerActivated) { return; } @@ -564,20 +589,40 @@ internal void OnWorldLoaded() /// /// The queued cycle, or null for a cycle no event named a mobile for. /// Whether the cycle ignores per-entry deadlines. - private void RunCycle(PendingCycle slot, bool bypassDeadlines) => - RunCycleCore(slot == null ? null : World.FindMobile(slot.TriggeringMobile), bypassDeadlines); + /// 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. Never re-enters: a - /// cycle raised from inside another cycle is dropped here, because its slot is still queued. + /// 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. - private void RunCycleCore(Mobile triggeringMobile, bool bypassDeadlines) + /// True when the cycle ran. + private bool RunCycleCore(Mobile triggeringMobile, bool bypassDeadlines) { if (_isRunningCycle) { - return; + return false; + } + + var group = Group; + if (group) + { + Defrag(); + + if (Spawned.Count > 0) + { + return false; + } } var previous = _cycleTriggeringMobile; @@ -585,15 +630,39 @@ private void RunCycleCore(Mobile triggeringMobile, bool bypassDeadlines) _isRunningCycle = true; try { - SpawnCore(bypassDeadlines); + 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() @@ -612,7 +681,7 @@ private PendingCycle PopOldestSlot() private void ClearRunNow() { _runNowRequested = false; - _runNowMobile = null; + _runNowMobile = Serial.Zero; } /// Rolls and applies the spawner-wide lockout after an accepted event. diff --git a/Projects/ModernSpawner/Core/ModernSpawner.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs index 1605772..c70d393 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -1002,11 +1002,14 @@ private void TrimPendingCycles() } } - /// + /// + /// 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() { - EnsureTriggersActive(); - var activateScript = OnActivateScript; if (activateScript?.IsValid == true) { diff --git a/Projects/ModernSpawner/Triggers/KillTrigger.cs b/Projects/ModernSpawner/Triggers/KillTrigger.cs index 72e49a1..3927397 100644 --- a/Projects/ModernSpawner/Triggers/KillTrigger.cs +++ b/Projects/ModernSpawner/Triggers/KillTrigger.cs @@ -64,6 +64,12 @@ public KillTrigger(int requiredKills, bool requireAllDead = false) : this() /// 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) @@ -73,12 +79,6 @@ public bool CountsKill(in TriggerContext context) return false; } - // Check cooldown - if (!CooldownElapsed()) - { - return false; - } - // Check type filter if (!string.IsNullOrEmpty(FilterType)) { @@ -106,7 +106,9 @@ public bool CountsKill(in TriggerContext context) /// /// 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. + /// 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) { From d53f6a65ff39919e36fd8df8538302484e284173 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:09:21 -0700 Subject: [PATCH 09/15] feat(migration): XmlSpawner refractory, SpawnOnTrigger, conjunctive triggers, group and TOD mapping onto the D2 model Both XmlSpawner ingestion paths (XmlSpawnerMigrator and XmlSpawnerImporter) now map: MinRefractory/MaxRefractory to RefractoryMin/Max (minutes); SpawnOnTrigger=False to MaxPendingCycles=1 plus mode:tick on every emitted event trigger, absent/True to MaxPendingCycles=0 (XmlSpawner run-now-or-drop parity); ProximityRange+SpeechTrigger(+ PlayerPropertyName) to one conjunctive speech trigger with a when: expression (new shared XmlSpawnerPropertyExpression translator) instead of two independent triggers; IsGroup to base Group only, no longer forcing SpawnCycleMode.AllEntries; TODStart/ TODEnd/TODMode to a wall_time_window or game_time_window gate plus a despawn-on-close report note; Duration to a report-only note (D10). Both paths report through a shared mechanism: MigrationReport for the migrator, an extended ImportResult.Notes for the importer. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../ModernSpawnerTriggerRegistrationTests.cs | 182 +++++++++++++ .../Core/XmlSpawnerImporterMappingTests.cs | 199 +++++++++++++++ .../XmlSpawnerPropertyExpressionTests.cs | 67 +++++ .../XmlSpawnerLegacyMappingTests.cs | 26 +- .../Migration/MigrationReport.cs | 32 +++ .../Migration/XmlSpawnerMigrator.cs | 210 +++++++++++++-- .../Migration/XmlSpawnerPropertyExpression.cs | 239 ++++++++++++++++++ .../Serialization/XmlSpawnerImporter.cs | 205 ++++++++++++--- 8 files changed, 1078 insertions(+), 82 deletions(-) create mode 100644 Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs create mode 100644 Projects/ModernSpawner.Tests/Migration/XmlSpawnerPropertyExpressionTests.cs create mode 100644 Projects/ModernSpawner/Migration/MigrationReport.cs create mode 100644 Projects/ModernSpawner/Migration/XmlSpawnerPropertyExpression.cs diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs index 0e393e1..3aedc1c 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; @@ -239,4 +240,185 @@ public void Migrator_RejectsMalformedSkillTriggerAttribute(string xml) 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_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 + { + 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(); + } + } + + [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/XmlSpawnerImporterMappingTests.cs b/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs new file mode 100644 index 0000000..efef9bd --- /dev/null +++ b/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs @@ -0,0 +1,199 @@ +using System; +using System.IO; +using Server.Engines.ModernSpawner.Serialization; +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(); + } + } + + [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/Serialization/XmlSpawnerLegacyMappingTests.cs b/Projects/ModernSpawner.Tests/Serialization/XmlSpawnerLegacyMappingTests.cs index 67b9d8b..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.AllEntries)] - 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.AllEntries, - XmlSpawnerImporter.MapLegacyCycleMode(isGroup: true, sequentialSpawn: 3)); + Assert.Equal(expected, XmlSpawnerImporter.MapLegacyCycleMode(sequentialSpawn)); } } 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 5a44b85..a8be8fa 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: {success}, Failed: {failed}"); - Logger.Information("Imported {Success} XmlSpawners, {Failed} failures from {Path}", success, failed, 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); + } + } + + 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,92 @@ 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.AddTriggerDefinition($"proximity:{proximityRange}:true:false:5:0"); + spawner.RefractoryMin = TimeSpan.FromMinutes(minRefractory); + spawner.RefractoryMax = TimeSpan.FromMinutes(Math.Max(maxRefractory, minRefractory)); } + // 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.AddTriggerDefinition($"speech:{encoded}:true:false:10:true:5"); + var definition = $"speech:{encoded}:true:false:{range}:true:5"; + definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + + spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + spawner.TriggerActivated = true; + } + else if (proximityRange >= 0) + { + var definition = $"proximity:{proximityRange}:true:false:5:0"; + definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + + spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + 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 definition = $"proximity:{DefaultTriggerRange}:true:false:5:0"; + definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + + spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + spawner.TriggerActivated = true; } var skillTrigger = GetAttribute(node, "SkillTrigger", null); if (!string.IsNullOrWhiteSpace(skillTrigger)) { - var definition = MapSkillTrigger(skillTrigger, proximityRange < 0 ? 10 : proximityRange); + var definition = MapSkillTrigger(skillTrigger, proximityRange < 0 ? DefaultTriggerRange : proximityRange); if (definition != null) { - spawner.AddTriggerDefinition(definition); + spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); 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 +524,68 @@ 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; + } + + /// + /// Appends the shared mode:tick token when is false + /// (ruling §13.2): with no queue to defer into, the token is meaningless at MaxPendingCycles == + /// 0, which is exactly the case that arises when is true. + /// + private static string ApplyDeferredMode(string definition, bool spawnOnTrigger) => + spawnOnTrigger ? definition : definition + ":mode:tick"; + + /// + /// Translates (when present) into a when: suffix on + /// , or adds a report line and leaves it untouched when the property + /// test cannot be represented in the target expression grammar. + /// + private static string AppendPlayerPropertyWhen(string definition, string playerPropertyName, List notes) + { + 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/Serialization/XmlSpawnerImporter.cs b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs index d91123a..452fa37 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,51 @@ 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.AddTriggerDefinition($"proximity:{proximityRange}:true"); + var trigRange = proximityRange >= 0 ? proximityRange : DefaultTriggerRange; + var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(speechTrigger)); + var definition = $"speech:{encoded}:true:false:{trigRange}:true:5"; + definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + + spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); spawner.TriggerActivated = true; } + else if (proximityRange >= 0) + { + var definition = $"proximity:{proximityRange}:true:false:5:0"; + definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); - // Add time of day trigger if specified - if (todMode > 0 && (todStart > 0 || todEnd > 0)) + spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + spawner.TriggerActivated = true; + } + else if (!string.IsNullOrEmpty(playerPropertyName)) { - var startHour = (int)todStart; - var endHour = (int)todEnd; - spawner.AddTriggerDefinition($"game_time_window:{startHour}:{endHour}:false:false"); + var definition = $"proximity:{DefaultTriggerRange}:true:false:5:0"; + definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + + spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); 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 +310,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.AllEntries; - } - - 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 +541,62 @@ private static string GetText(XmlElement node, string defaultValue) { return node?.InnerText ?? defaultValue; } + + /// + /// Appends the shared mode:tick token when is false + /// (ruling §13.2): with no queue to defer into, the token is meaningless at MaxPendingCycles == + /// 0, which is exactly the case that arises when is true. + /// + private static string ApplyDeferredMode(string definition, bool spawnOnTrigger) => + spawnOnTrigger ? definition : definition + ":mode:tick"; + + /// + /// Translates (when present) into a when: suffix on + /// , or adds a report line and leaves it untouched when the property + /// test cannot be represented in the target expression grammar. + /// + private static string AppendPlayerPropertyWhen(string definition, string playerPropertyName, List notes) + { + 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; + } } From 30916cacd517b609ab5abf9b8c90d3c5cb6586cf Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:21:28 -0700 Subject: [PATCH 10/15] fix(migration): mode:tick precedes when: in emitted definitions TriggerTokens.Strip documents when: as consuming everything after it, so appending mode:tick after a when: suffix swallowed the token into the expression source and left it uncompilable - the migrated trigger never deferred and its when: never passed. Both ingestion paths now build every event trigger definition through one BuildEventDefinition helper that emits positional[:mode:tick][:when:], the only writer at all six call sites, so the ordering cannot drift again. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../ModernSpawnerTriggerRegistrationTests.cs | 68 +++++++++++++++++++ .../Core/XmlSpawnerImporterMappingTests.cs | 67 ++++++++++++++++++ .../Migration/XmlSpawnerMigrator.cs | 46 ++++++------- .../Serialization/XmlSpawnerImporter.cs | 40 ++++++----- 4 files changed, 176 insertions(+), 45 deletions(-) diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs index 3aedc1c..bf50feb 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs @@ -369,6 +369,74 @@ public void Migrator_UnrepresentablePlayerProperty_EmitsTriggerWithoutWhenAndAdd } } + // 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() { diff --git a/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs b/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs index efef9bd..9675698 100644 --- a/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs +++ b/Projects/ModernSpawner.Tests/Core/XmlSpawnerImporterMappingTests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using Server.Engines.ModernSpawner.Serialization; +using Server.Engines.ModernSpawner.Triggers; using Xunit; namespace Server.Engines.ModernSpawner.Tests; @@ -149,6 +150,72 @@ public void PlayerPropertyName_WithoutSpeech_AttachesWhenToProximity() } } + // 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() { diff --git a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs index a8be8fa..2ed73bc 100644 --- a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs +++ b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs @@ -246,38 +246,35 @@ internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node, List not { var range = proximityRange >= 0 ? proximityRange : DefaultTriggerRange; var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(speechTrigger)); - var definition = $"speech:{encoded}:true:false:{range}:true:5"; - definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + var positional = $"speech:{encoded}:true:false:{range}:true:5"; - spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes)); spawner.TriggerActivated = true; } else if (proximityRange >= 0) { - var definition = $"proximity:{proximityRange}:true:false:5:0"; - definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + var positional = $"proximity:{proximityRange}:true:false:5:0"; - spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + 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 definition = $"proximity:{DefaultTriggerRange}:true:false:5:0"; - definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + var positional = $"proximity:{DefaultTriggerRange}:true:false:5:0"; - spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + 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 ? DefaultTriggerRange : proximityRange); - if (definition != null) + var positional = MapSkillTrigger(skillTrigger, proximityRange < 0 ? DefaultTriggerRange : proximityRange); + if (positional != null) { - spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, null, notes)); spawner.TriggerActivated = true; } } @@ -532,20 +529,21 @@ private static double GetDoubleAttribute(XmlNode node, string name, double defau } /// - /// Appends the shared mode:tick token when is false - /// (ruling §13.2): with no queue to defer into, the token is meaningless at MaxPendingCycles == - /// 0, which is exactly the case that arises when is true. + /// 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. /// - private static string ApplyDeferredMode(string definition, bool spawnOnTrigger) => - spawnOnTrigger ? definition : definition + ":mode:tick"; - - /// - /// Translates (when present) into a when: suffix on - /// , or adds a report line and leaves it untouched when the property - /// test cannot be represented in the target expression grammar. - /// - private static string AppendPlayerPropertyWhen(string definition, string playerPropertyName, List notes) + /// 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; diff --git a/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs index 452fa37..5749b6b 100644 --- a/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs +++ b/Projects/ModernSpawner/Serialization/XmlSpawnerImporter.cs @@ -261,26 +261,23 @@ private static ModernSpawner ImportXmlSpawnerPoint(XmlElement point, List= 0 ? proximityRange : DefaultTriggerRange; var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(speechTrigger)); - var definition = $"speech:{encoded}:true:false:{trigRange}:true:5"; - definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + var positional = $"speech:{encoded}:true:false:{trigRange}:true:5"; - spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes)); spawner.TriggerActivated = true; } else if (proximityRange >= 0) { - var definition = $"proximity:{proximityRange}:true:false:5:0"; - definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + var positional = $"proximity:{proximityRange}:true:false:5:0"; - spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes)); spawner.TriggerActivated = true; } else if (!string.IsNullOrEmpty(playerPropertyName)) { - var definition = $"proximity:{DefaultTriggerRange}:true:false:5:0"; - definition = AppendPlayerPropertyWhen(definition, playerPropertyName, notes); + var positional = $"proximity:{DefaultTriggerRange}:true:false:5:0"; - spawner.AddTriggerDefinition(ApplyDeferredMode(definition, spawnOnTrigger)); + spawner.AddTriggerDefinition(BuildEventDefinition(positional, spawnOnTrigger, playerPropertyName, notes)); spawner.TriggerActivated = true; } @@ -543,20 +540,21 @@ private static string GetText(XmlElement node, string defaultValue) } /// - /// Appends the shared mode:tick token when is false - /// (ruling §13.2): with no queue to defer into, the token is meaningless at MaxPendingCycles == - /// 0, which is exactly the case that arises when is true. + /// 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. /// - private static string ApplyDeferredMode(string definition, bool spawnOnTrigger) => - spawnOnTrigger ? definition : definition + ":mode:tick"; - - /// - /// Translates (when present) into a when: suffix on - /// , or adds a report line and leaves it untouched when the property - /// test cannot be represented in the target expression grammar. - /// - private static string AppendPlayerPropertyWhen(string definition, string playerPropertyName, List notes) + /// 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; From f209c48fa28258d7f4951f7ac353e08dc1f11599 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:43:26 -0700 Subject: [PATCH 11/15] perf(triggers): dispatch benchmarks and a 12k trigger perf harness The D2 performance conditions (design section 9) need two measurements: BenchmarkDotNet micro-benchmarks over the dispatch shapes, and a 12k-spawner movement and tick measurement. The second one was specified as a lap walked on a live shard, which is neither reproducible nor runnable without a shard, so it lands as a world-backed harness in the test project instead. Micro-benchmarks (mock types, no ModernUO reference, as the project requires): one TriggerSet dictionary is 2.24x cheaper per dispatch than probing one dictionary per trigger class at N=12,000, and neither allocates. The bounded queue plus the reused drain list is 1.51x-2.05x faster than an unbounded queue with a fresh list per round at pending depths 1/8/64, allocates 1.46x less, and keeps Gen2 at zero for depths 1 and 8. The harness seeds 12,000 spawners on Felucca through the grid-seeding helper now shared with the PerfSeed command, walks a placed player along a fixed 2,000-step lap dispatching OnMovement to every spawner inside Core.GlobalMaxUpdateRange of each step, and ticks the whole population with the gate closed and again with it open. It is opt-in through MODERNSPAWNER_PERF=1, so the default suite is unaffected, and it writes perf-results.json beside the test binaries so two trees can be diffed. Release numbers on the branch: 130.1 ns and 0 bytes per movement dispatch in the steady state, 50.7-51.7 ns and 0 bytes per tick. SpawnerMetrics gains a MeasureTick bucket, opened at the top of OnTick above T0 so the parked rows are measured too - the condition is about what a tick costs when it does nothing. Like every other bucket it is a no-op branch while the counters are disabled, so OnTick is unchanged in production. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .gitignore | 2 + Projects/ModernSpawner.Benchmarks/Program.cs | 17 + .../TriggerDispatchBenchmarks.cs | 456 ++++++++++++++++ .../Perf/TriggerPerfHarness.cs | 506 ++++++++++++++++++ .../Core/ModernSpawner.Triggers.cs | 5 + Projects/ModernSpawner/Perf/SpawnerMetrics.cs | 18 + .../ModernSpawner/Perf/SpawnerPerfCommands.cs | 104 +++- 7 files changed, 1093 insertions(+), 15 deletions(-) create mode 100644 Projects/ModernSpawner.Benchmarks/TriggerDispatchBenchmarks.cs create mode 100644 Projects/ModernSpawner.Tests/Perf/TriggerPerfHarness.cs 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/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/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/Core/ModernSpawner.Triggers.cs b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs index 79f9695..9bd9875 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs @@ -1,5 +1,6 @@ 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; @@ -126,6 +127,10 @@ internal void ClearRegistration() /// 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) { 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 5193008..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.AddTriggerDefinition("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 — From b3410af1cd9b6ddb3149a2a9fda9321cc446bcb8 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:03:56 -0700 Subject: [PATCH 12/15] docs: D2 trigger state machine as built Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- CLAUDE.md | 32 +++-- dev-docs/architecture.md | 208 ++++++++++++++++++++--------- dev-docs/modernuo-prerequisites.md | 1 + dev-docs/product-spec.md | 12 +- dev-docs/xmlspawner-migration.md | 17 +-- 5 files changed, 181 insertions(+), 89 deletions(-) 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/dev-docs/architecture.md b/dev-docs/architecture.md index 7d1db80..91d7825 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -66,37 +66,36 @@ 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 (still a hardcoded real-minutes-per-game-hour constant, not derived from `Clock.SecondsPerUOMinute` — unchanged by D2) | +| 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, 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). ### 2.3 Scripting (`Scripting/`) @@ -272,45 +271,123 @@ 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). `RequireAllDead` + is evaluated after removal against the entry's remaining live count. - **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's real-time-per-game-hour constant is still a hardcoded 12 minutes, not derived from + `Clock.SecondsPerUOMinute` — a pre-existing gap, unchanged by D2. Map change re-registers a spawner's + triggers (gate hydration, skill candidacy). +- **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, ~130–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) @@ -405,12 +482,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..1872e19 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*TriggerName` | property triggers | **unsupported** (PropertyTrigger removed by design) → warn and drop, include the expression in the report | | `InContainer`, `Container*` | container spawning | unsupported → warn | | `ConfigFile`, `TickReset` | — | drop silently | From c0293141ffb5d2ce9890177d4d0bc60737503c95 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:29:50 -0700 Subject: [PATCH 13/15] fix(triggers): game-clock constant; skill triggers on stopped spawners; generation-checked requests; deadline arming; dispatch continues past a rejected trigger The single fix wave from the whole-branch and transition-table conformance reviews. Correctness - GameTimeWindowTrigger took a game hour to be 12 real minutes. The engine's own clock says 5 real seconds per UO minute, so a game hour is 5 real minutes: every window edge was 2.4x late. The constant now comes from Clock.SecondsPerUOMinute instead of being restated. - SkillTrigger.Evaluate rejected a stopped spawner, so A2/E4/E5 held for proximity and speech but not for skill. Only deletion takes a trigger out. - A cycle request now carries the generation of the registration it was raised from, and is dropped when that is not the spawner's current one. External Trigger() carries the current generation, which is how a script or command is honoured on a spawner whose triggers are deactivated - so the request path no longer tests TriggerActivated at all. - ToDto lost the base Group flag, so an exported group spawner came back as a plain one. - Movement, speech and skill dispatch stopped at the first trigger whose Evaluate matched, even when the spawner refused the request. A refused trigger no longer ends the dispatch: the next one gets its turn, as the kill loop already worked. - RequireAllDead could never be satisfied by the kill that clears the pack, because the spawner is notified before the base death path removes the dying spawn. "All dead" is now read excluding it. - A counted kill advances its counter even when the cooldown, the refractory or the queue refuses the cycle it would have bought - exactly once per dispatch - and KillTrigger.CountsKill drops a lifted bool? that let a type with no FullName through the filter by accident. Timers - ArmAtEarliestDeadline arms at the earliest future entry deadline with no MaxDelay clamp (a per-entry delay is allowed to be longer than the spawner's) and a floor of one slice; an entry with no deadline of its own contributes the spawner's random delay rather than being ignored. T6 also spends a queued slot on a spawner with no event definitions, which is the only way an external Trigger() slot is ever popped. - A4 re-arms a running spawner that was parked behind its triggers. - G1 does not arm when the group cycle refused: a live pack parks until removal, as T1 does. - The drain cap arms for the next tick when it leaves a slot behind. Logging and cost - A when: that does not compile is reported once, at parse time, and the trigger runs unconditioned rather than silently never firing. - ExpressionEngine's Console.WriteLine becomes the file's logger, once per compiled expression: a when: is evaluated per movement event. - The per-map skill candidate list carries each spawner's TriggerSet, so an attempt costs one dictionary lookup for the map and none per candidate. - The XmlSpawner migrator notes an inverted MinRefractory/MaxRefractory range in the migration report instead of clamping it silently. Tests: skill on stopped spawners (wake and non-wake), a request through a retired registration, a real two-definition reorder, a map move that re-registers and re-hydrates a game-time gate, the after-script once on a bulk respawn, G1 parking without arming, the drain cap's next tick, dispatch continuing past a refused trigger, RequireAllDead on both sides, the DTO Group round trip, dupe negative asserts, the inverted refractory note, and NextSpawn assertions on the T5/T6 and A4 rows. 598 pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../Core/ModernSpawnerLifecycleTests.cs | 18 + .../ModernSpawnerTriggerRegistrationTests.cs | 20 + .../Core/TriggerStateMachineTests.cs | 360 +++++++++++++++++- .../ModernSpawner/Core/ModernSpawner.Dto.cs | 3 + .../Core/ModernSpawner.Triggers.cs | 141 ++++--- Projects/ModernSpawner/Core/ModernSpawner.cs | 15 + .../Migration/XmlSpawnerMigrator.cs | 10 + .../Scripting/Expressions/ExpressionEngine.cs | 37 +- .../Triggers/GameTimeWindowTrigger.cs | 9 +- .../ModernSpawner/Triggers/KillTrigger.cs | 23 +- .../ModernSpawner/Triggers/SkillTrigger.cs | 4 +- .../ModernSpawner/Triggers/TriggerBase.cs | 29 +- .../ModernSpawner/Triggers/TriggerSystem.cs | 84 +++- 13 files changed, 680 insertions(+), 73 deletions(-) diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs index e103c8e..e2e0dbc 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerLifecycleTests.cs @@ -151,6 +151,12 @@ public void Dupe_ClonesModernFields() spawner.TriggerActivated = true; 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); @@ -164,6 +170,15 @@ public void Dupe_ClonesModernFields() // 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); @@ -192,6 +207,8 @@ public void Dto_RoundTrip_CarriesEntriesTriggersAndCycleState() var spawner = Place("Rabbit"); spawner.ModernEntries[0].LootTemplate = "goblin"; spawner.CycleMode = SpawnCycleMode.Sequential; + // 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()]); @@ -200,6 +217,7 @@ public void Dto_RoundTrip_CarriesEntriesTriggersAndCycleState() Assert.Equal("goblin", loaded.ModernEntries[0].LootTemplate); Assert.Equal(SpawnCycleMode.Sequential, loaded.CycleMode); + Assert.True(loaded.Group); Assert.Equal("proximity:8:true", Assert.Single(loaded.TriggerDefinitions).Text); DeleteSpawned(loaded); diff --git a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs index bf50feb..e42f261 100644 --- a/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs +++ b/Projects/ModernSpawner.Tests/Core/ModernSpawnerTriggerRegistrationTests.cs @@ -262,6 +262,26 @@ public void Migrator_MapsMinMaxRefractoryAttributes_AsMinutes() } } + [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() { diff --git a/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs index 31d7c04..eff3e58 100644 --- a/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs +++ b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs @@ -397,6 +397,59 @@ public void E4_WakeTrigger_StartsTheStoppedSpawnerAndRunsTheCycle() } } + [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 @@ -701,6 +754,10 @@ public void A4_Deactivate_ClearsPendingAndGates() 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); @@ -738,6 +795,126 @@ public void A3_Activate_HydratesGatesSilently() } } + [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 @@ -767,9 +944,15 @@ public void L1_Restart_NoSpawnDuringLoad() // 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... @@ -1075,6 +1258,67 @@ public void T1_Group_EventOnDeadPack_RunsOneBulkRespawnWithScriptsOnce() } } + [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 @@ -1104,13 +1348,18 @@ public void T5_EntryDeadlines_OnlyDueSelectable() Assert.Empty(rabbit.Spawned); Assert.Single(bird.Spawned); - // Both parked: the tick arms at the earliest deadline instead of spawning. + // 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 { @@ -1168,6 +1417,9 @@ public void T5_SuccessfulSpawn_SetsTheEntryDeadline() 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); @@ -1238,14 +1490,15 @@ public void Kill_BelowThresholdKillCountsWhileTheTriggerIsOnCooldown() Assert.Equal(2, state.KillCount); Assert.Equal(spawnedBefore, spawner.Spawned.Count); - // The kill that reaches the threshold is refused by the cooldown, and a refusal after the - // evaluation moves nothing - so the threshold stays reached for the next kill. + // 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(2, state.KillCount); + 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); @@ -1259,6 +1512,57 @@ public void Kill_BelowThresholdKillCountsWhileTheTriggerIsOnCooldown() } } + [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 @@ -1294,6 +1598,40 @@ public void When_FailingConditionRejectsTheEventAndLeavesTheCooldown() } } + [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 @@ -1377,6 +1715,20 @@ public void Script_TickInitiatedCycle_DrainsFollowUpsInTheSameTick() // 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 { diff --git a/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs b/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs index 0ec60fb..59fce5d 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.Dto.cs @@ -24,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, diff --git a/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs index 9bd9875..133cf1b 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.Triggers.cs @@ -27,6 +27,12 @@ public partial class ModernSpawner /// 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. @@ -167,8 +173,10 @@ public override void OnTick() } // 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. - var slot = _eventCount > 0 ? PopOldestSlot() : null; + // 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 @@ -205,19 +213,25 @@ public override void OnTick() /// /// /// 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, in TriggerContext context) + internal bool RequestCycle(ITrigger trigger, int generation, in TriggerContext context) { if (trigger == null || Deleted) { return false; } - // The script / command entry point is an event source in its own right, so it does not belong - // to a registration and is not gated by one. - if (!ReferenceEquals(trigger, ExternalTrigger.Instance) && - (!_triggerActivated || _registrationGeneration == 0)) + // 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; } @@ -233,38 +247,34 @@ internal bool RequestCycle(ITrigger trigger, in TriggerContext context) } var now = Core.Now; - var state = trigger.State; - if (state != null && now < state.CooldownUntil) - { - return false; - } - - if (now < _refractoryUntil) - { - return false; - } - - if (!WhenPasses(trigger, context.TriggeringMobile)) - { - return false; - } - var wake = trigger.Wake; var latched = _maxPendingCycles > 0; - if (latched) + // 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) { - // E6: the queue is the only thing standing between this event and a cycle, and it is full. - if (PendingCycleCount >= _maxPendingCycles) - { - return false; - } + 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)); } - else if (!GateOpen || IsFull || !(Running || (wake && Entries.Count > 0))) + + if (refused) { - // MaxPendingCycles == 0 reproduces XmlSpawner: run now or drop, never latch. Nothing can - // run now, so the event is refused rather than quietly eaten. + // 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; } @@ -349,6 +359,13 @@ internal void DrainOne() ); } + // "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; } @@ -410,7 +427,7 @@ internal void DrainOne() public void Trigger() { var context = TriggerContext.ForProximity(this, null); - RequestCycle(ExternalTrigger.Instance, in context); + RequestCycle(ExternalTrigger.Instance, _registrationGeneration, in context); } /// @@ -455,7 +472,12 @@ public void OnGateOpened(int definitionIndex) // deadlines; only a mode:now event drain bypasses them. if (_eventCount == 0) { - RunCycleCore(null, false); + 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) { @@ -830,10 +852,16 @@ private bool HasDueEntry(DateTime now) } /// - /// Arms the timer at the earliest per-entry deadline, clamped to the spawner's max delay. Entries - /// that carry no deadline are always due and never hold the timer back; when no entry carries one - /// the spawner falls back to its own random delay. + /// 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) { @@ -841,21 +869,40 @@ private void ArmAtEarliestDeadline(DateTime now) 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 (entry.NextEligible == default || !IsEligible(entry, subgroup)) + if (!IsEligible(entry, subgroup)) { continue; } - var delay = entry.NextEligible - now; - if (delay < TimeSpan.Zero) + TimeSpan delay; + if (entry.NextEligible == default) { - delay = TimeSpan.Zero; + // 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) @@ -868,14 +915,22 @@ private void ArmAtEarliestDeadline(DateTime now) 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; } - var maxDelay = MaxDelay; - DoTimer(earliest > maxDelay ? maxDelay : earliest); + 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 /// diff --git a/Projects/ModernSpawner/Core/ModernSpawner.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs index c70d393..bade4cc 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -739,6 +739,10 @@ internal void EnsureTriggersActive() ClearPendingCycles(); ClearRunNow(); TriggerSystem.Instance.CancelDrain(this); + + // 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. + DoTimer(); } // Gates are never persisted; every registration recomputes them from the clock. @@ -1418,6 +1422,17 @@ public override void OnMapChange() { base.OnMapChange(); + // 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); diff --git a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs index 2ed73bc..27c30f3 100644 --- a/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs +++ b/Projects/ModernSpawner/Migration/XmlSpawnerMigrator.cs @@ -227,6 +227,16 @@ internal static ModernSpawner ParseXmlSpawnerNode(XmlNode node, List not { 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 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/Triggers/GameTimeWindowTrigger.cs b/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs index 3fd9810..00d8f0e 100644 --- a/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs +++ b/Projects/ModernSpawner/Triggers/GameTimeWindowTrigger.cs @@ -13,9 +13,12 @@ namespace Server.Engines.ModernSpawner.Triggers; /// 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); /// The largest value may take: the exclusive end of a whole day. public const int EndOfDay = 24; diff --git a/Projects/ModernSpawner/Triggers/KillTrigger.cs b/Projects/ModernSpawner/Triggers/KillTrigger.cs index 3927397..9d722d5 100644 --- a/Projects/ModernSpawner/Triggers/KillTrigger.cs +++ b/Projects/ModernSpawner/Triggers/KillTrigger.cs @@ -83,8 +83,13 @@ public bool CountsKill(in 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; } @@ -122,7 +127,21 @@ public override bool Evaluate(in TriggerContext context) if (RequireAllDead) { var spawner = Spawner; - if (spawner == null || spawner.Spawned.Count > 0) + if (spawner == null) + { + return false; + } + + // 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)) + { + live--; + } + + if (live > 0) { return false; } diff --git a/Projects/ModernSpawner/Triggers/SkillTrigger.cs b/Projects/ModernSpawner/Triggers/SkillTrigger.cs index 728aed0..b139652 100644 --- a/Projects/ModernSpawner/Triggers/SkillTrigger.cs +++ b/Projects/ModernSpawner/Triggers/SkillTrigger.cs @@ -109,8 +109,10 @@ TimeSpan cooldown /// public override bool Evaluate(in TriggerContext context) { + // 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 || !spawner.Running) + if (spawner == null || spawner.Deleted) { return false; } diff --git a/Projects/ModernSpawner/Triggers/TriggerBase.cs b/Projects/ModernSpawner/Triggers/TriggerBase.cs index 6b84183..a70d1af 100644 --- a/Projects/ModernSpawner/Triggers/TriggerBase.cs +++ b/Projects/ModernSpawner/Triggers/TriggerBase.cs @@ -1,5 +1,6 @@ using System; using Server.Engines.ModernSpawner.Scripting.Expressions; +using Server.Logging; using Server.Text; namespace Server.Engines.ModernSpawner.Triggers; @@ -10,6 +11,8 @@ namespace Server.Engines.ModernSpawner.Triggers; /// public abstract class TriggerBase : ITrigger { + private static readonly ILogger Logger = LogFactory.GetLogger(typeof(TriggerBase)); + /// public abstract string TriggerType { get; } @@ -70,7 +73,31 @@ protected void ApplyTokens(bool wake, CycleMode mode, string when) Wake = wake; Mode = mode; WhenSource = string.IsNullOrEmpty(when) ? null : when; - When = WhenSource == null ? null : ExpressionEngine.Instance.Compile(WhenSource); + + if (WhenSource == null) + { + When = null; + return; + } + + var compiled = ExpressionEngine.Instance.Compile(WhenSource); + if (compiled?.IsValid == true) + { + 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; } /// diff --git a/Projects/ModernSpawner/Triggers/TriggerSystem.cs b/Projects/ModernSpawner/Triggers/TriggerSystem.cs index 118bf33..4195993 100644 --- a/Projects/ModernSpawner/Triggers/TriggerSystem.cs +++ b/Projects/ModernSpawner/Triggers/TriggerSystem.cs @@ -30,8 +30,10 @@ public class TriggerSystem : ITriggerSystem private readonly Dictionary _sets = new(); // Skill attempts are dispatched server-wide, so skill triggers keep a candidate list per map rather - // than making every attempt scan the whole registry. Maintained on registration and map change. - private readonly Dictionary> _skillCandidates = new(); + // 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. @@ -221,9 +223,9 @@ private void FileSkillCandidate(ModernSpawner spawner, TriggerSet set) _skillCandidates[map] = candidates; } - if (!candidates.Contains(spawner)) + if (IndexOfCandidate(candidates, spawner) < 0) { - candidates.Add(spawner); + candidates.Add(new SkillCandidate(spawner, set)); } set.SkillMap = map; @@ -237,7 +239,12 @@ private void UnfileSkillCandidate(ModernSpawner spawner, TriggerSet set) return; } - candidates.Remove(spawner); + var index = IndexOfCandidate(candidates, spawner); + if (index >= 0) + { + candidates.RemoveAt(index); + } + if (candidates.Count == 0) { _skillCandidates.Remove(map); @@ -246,6 +253,23 @@ private void UnfileSkillCandidate(ModernSpawner spawner, TriggerSet set) set.SkillMap = null; } + /// 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) + { + for (var i = 0; i < candidates.Count; i++) + { + if (candidates[i].Spawner == spawner) + { + return i; + } + } + + return -1; + } + /// /// 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. @@ -408,7 +432,7 @@ public void OnSkillUse(Mobile mobile, Skill skill, bool success) // 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 pool = STArrayPool.Shared; var snapshot = pool.Rent(candidates.Count); var taken = candidates.Count; @@ -422,11 +446,13 @@ public void OnSkillUse(Mobile mobile, Skill skill, bool success) for (var s = 0; s < taken; s++) { - var spawner = snapshot[s]; + var spawner = snapshot[s].Spawner; + var set = snapshot[s].Set; // The snapshot can name a spawner an earlier iteration of this dispatch deleted or - // unregistered. - if (spawner.Deleted || spawner.Map != mobile.Map || !_sets.TryGetValue(spawner, out var set)) + // 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; } @@ -456,9 +482,10 @@ public void OnSkillUse(Mobile mobile, Skill skill, bool success) for (var i = firstMatch; i < triggers.Count; i++) { var trigger = triggers[i]; - if (trigger.MatchesSkill(skillName) && trigger.Evaluate(in context)) + if (trigger.MatchesSkill(skillName) && + trigger.Evaluate(in context) && + spawner.RequestCycle(trigger, set.Generation, in context)) { - spawner.RequestCycle(trigger, in context); break; // Only one cycle per spawner per skill use } } @@ -495,10 +522,12 @@ public void OnMobileProximity(Mobile mobile, Point3D location, Map map, ModernSp for (var i = 0; i < triggers.Count; i++) { + // 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)) + if (trigger.Evaluate(in context) && spawner.RequestCycle(trigger, set.Generation, in context)) { - spawner.RequestCycle(trigger, in context); break; // Only one cycle per spawner per proximity event } } @@ -530,10 +559,10 @@ public void OnSpeech(Mobile speaker, string text, Point3D location, Map map, Mod for (var i = 0; i < triggers.Count; i++) { + // As for proximity, a refused trigger does not end the dispatch. var trigger = triggers[i]; - if (trigger.Evaluate(in context)) + if (trigger.Evaluate(in context) && spawner.RequestCycle(trigger, set.Generation, in context)) { - spawner.RequestCycle(trigger, in context); break; // Only one cycle per spawner per speech event } } @@ -544,6 +573,29 @@ public void OnSpeech(Mobile speaker, string text, Point3D location, Map map, Mod } } + /// + /// 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. + /// + private readonly struct SkillCandidate + { + /// The candidate spawner. + public ModernSpawner Spawner { get; } + + /// The registration it was filed under. + public TriggerSet Set { get; } + + /// Files a spawner with the set it was registered with. + /// The candidate spawner. + /// Its registered set. + public SkillCandidate(ModernSpawner spawner, TriggerSet set) + { + Spawner = spawner; + Set = set; + } + } + /// public void OnEntityKilled(ModernSpawner spawner, IEntity killed, Mobile killer) { @@ -571,7 +623,7 @@ public void OnEntityKilled(ModernSpawner spawner, IEntity killed, Mobile killer) // 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, in context)) + if (trigger.CountsKill(in context) && spawner.RequestCycle(trigger, set.Generation, in context)) { break; } From 9e12f9886eb3a91200b8be3acb2f19afb4a7cf21 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:32:16 -0700 Subject: [PATCH 14/15] =?UTF-8?q?docs:=20architecture=20=C2=A72-3=20and=20?= =?UTF-8?q?=C2=A77=20as=20built=20after=20D2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §2.1: the spawner carries 22 serialized fields in v1, not 17 - _notes moved to 10, cycle state to 11-15, and 16-21 are the D2 runtime (queue, bound, refractory range and deadline, per-definition state); the entry carries 12 with NextEligible. The spawn flow paragraph describes the T0-T6 tick with Spawn() as the manual M1 path and names SpawnAllEntries rather than the renamed SpawnGroupMode, and records the per-entry deadline the cycle writes. - §2.2: the game-time window derives its constant from Clock.SecondsPerUOMinute (the table row and the §5 Clock bullet both said it was hardcoded), and the dispatch paragraph records the generation stamp a request carries and that a refused trigger no longer ends the dispatch. - §2.6: the DTO carries trigger definitions as { id, text } plus maxPendingCycles and the refractory range and the base Group flag, and never carries runtime state; the own-JSON format drops all three D2 fields and mints fresh definition ids on import. - §3: TriggerContext is a readonly record struct, so no dispatch allocates; the repo has seven schema files; the one remaining per-event allocation is the ScriptContext a when: condition needs, bounded by its trigger's cooldown. - §5: RequireAllDead is evaluated against the spawner's live count with the dying spawn excluded, because the notification precedes removal; a map change re-registers so a gate recomputes against the new map's clock; the movement-dispatch figure reads 131-133 ns to match the measured numbers. - §7: the target export shape lists the trigger { id, text } objects and the D2 fields alongside them. - Line 4 no longer pins commit hashes. - ModernSpawner.EnsureTriggersActive's XML doc no longer lists "start" among its callers: registration has been independent of Running since A1/A2. - xmlspawner-migration.md keeps PlayerTriggerName in the unsupported property trigger row, with the reason. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- Projects/ModernSpawner/Core/ModernSpawner.cs | 13 +-- dev-docs/architecture.md | 90 ++++++++++++-------- dev-docs/xmlspawner-migration.md | 2 +- 3 files changed, 64 insertions(+), 41 deletions(-) diff --git a/Projects/ModernSpawner/Core/ModernSpawner.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs index bade4cc..166f3c0 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -719,11 +719,14 @@ private void MaybeAutoResetSequence() /// 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. 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. + /// 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() { diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 91d7825..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,14 +56,19 @@ 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/`) @@ -90,12 +96,16 @@ spawner (`OnAfterDuped`, `ModernSpawnerDto.ToSpawner`, both JSON importer entry | 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 | -| game_time_window | one transition timer; `timeofday` is retired as a trigger class and parses only as an alias onto this one | yes (still a hardcoded real-minutes-per-game-hour constant, not derived from `Clock.SecondsPerUOMinute` — unchanged by D2) | +| 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 | -Dispatch never calls `Spawn()` directly: 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 actual cycle once it returns (§5). +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/`) @@ -133,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 | @@ -150,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) @@ -359,8 +375,9 @@ differences from the original plan noted inline. - **Kill.** `CreatureEvents.CreatureDeathEvent` fires *after* `Mobile.OnDeath`, which deletes non-player 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). `RequireAllDead` - is evaluated after removal against the entry's remaining live count. + 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 @@ -374,12 +391,13 @@ differences from the original plan noted inline. 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's real-time-per-game-hour constant is still a hardcoded 12 minutes, not derived from - `Clock.SecondsPerUOMinute` — a pre-existing gap, unchanged by D2. Map change re-registers a spawner's - triggers (gate hydration, skill candidacy). + 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, ~130–133 ns/call and 0 B/call after; ticking the + 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: @@ -426,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. diff --git a/dev-docs/xmlspawner-migration.md b/dev-docs/xmlspawner-migration.md index 1872e19..42eb901 100644 --- a/dev-docs/xmlspawner-migration.md +++ b/dev-docs/xmlspawner-migration.md @@ -93,7 +93,7 @@ older RunUO XmlSpawner2 exports may differ and are reported, not silently accept | `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) | | `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*TriggerName` | property triggers | **unsupported** (PropertyTrigger removed by design) → warn and drop, include the expression in the report | +| `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 | From 514213220b9c41b4c32c3fd5f083ae6fdc7cebbc Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:42:10 -0700 Subject: [PATCH 15/15] fix(triggers): A4 re-arm only on deactivation; no lifted bool in when: parsing; L1 asserts a live timer Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr --- .../Core/TriggerStateMachineTests.cs | 2 +- Projects/ModernSpawner/Core/ModernSpawner.cs | 12 ++++++++---- Projects/ModernSpawner/Triggers/TriggerBase.cs | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs index eff3e58..d17553f 100644 --- a/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs +++ b/Projects/ModernSpawner.Tests/Core/TriggerStateMachineTests.cs @@ -947,7 +947,7 @@ public void L1_Restart_NoSpawnDuringLoad() // 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); + Assert.True(loaded.NextSpawn > TimeSpan.Zero); loaded.OnWorldLoaded(); diff --git a/Projects/ModernSpawner/Core/ModernSpawner.cs b/Projects/ModernSpawner/Core/ModernSpawner.cs index 166f3c0..5627fef 100644 --- a/Projects/ModernSpawner/Core/ModernSpawner.cs +++ b/Projects/ModernSpawner/Core/ModernSpawner.cs @@ -102,6 +102,14 @@ 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(); + } } } @@ -742,10 +750,6 @@ internal void EnsureTriggersActive() ClearPendingCycles(); ClearRunNow(); TriggerSystem.Instance.CancelDrain(this); - - // 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. - DoTimer(); } // Gates are never persisted; every registration recomputes them from the clock. diff --git a/Projects/ModernSpawner/Triggers/TriggerBase.cs b/Projects/ModernSpawner/Triggers/TriggerBase.cs index a70d1af..31bad21 100644 --- a/Projects/ModernSpawner/Triggers/TriggerBase.cs +++ b/Projects/ModernSpawner/Triggers/TriggerBase.cs @@ -81,7 +81,7 @@ protected void ApplyTokens(bool wake, CycleMode mode, string when) } var compiled = ExpressionEngine.Instance.Compile(WhenSource); - if (compiled?.IsValid == true) + if (compiled != null && compiled.IsValid) { When = compiled; return;