diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 5e4230b7c..bca909a3e 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -109,6 +109,8 @@ internal static class TestServerInitializer World.Load(); World.ExitSerializationThreads(); DecayScheduler.Configure(); + // Without npc-speeds.json every BaseCreature constructor throws. + Server.Mobiles.NPCSpeeds.Configure(); Server.Engines.Spawners.SpawnerJsonSerializer.Configure(); if (TileDataLoaded) diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin new file mode 100644 index 000000000..29db020bc Binary files /dev/null and b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/proximity.v12-v1-v0.bin differ diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/region.v12-v1-v0.bin b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/region.v12-v1-v0.bin new file mode 100644 index 000000000..4e5fa2145 Binary files /dev/null and b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/region.v12-v1-v0.bin differ diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin new file mode 100644 index 000000000..a8653ab9b Binary files /dev/null and b/Projects/UOContent.Tests/Tests/Engines/Spawners/Fixtures/spawner.v12-v1.bin differ diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs index 5f8e80f92..689420c93 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDiscoveryValidationTests.cs @@ -11,12 +11,16 @@ public class SpawnerDiscoveryValidationTests [JsonDiscoverableType("dup")] private sealed record DupA : SpawnerDto { + public override IReadOnlyList EntryView => null; + protected override BaseSpawner CreateEmpty() => new Spawner(); } [JsonDiscoverableType("dup")] private sealed record DupB : SpawnerDto { + public override IReadOnlyList EntryView => null; + protected override BaseSpawner CreateEmpty() => new Spawner(); } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs new file mode 100644 index 000000000..8a25c4b0f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners.Json; + +[Collection("Sequential UOContent Tests")] +public class SpawnerDtoEntryTests +{ + [Fact] + public void Export_WritesEntriesAtSameJsonPosition_WithDisabledOnlyWhenSet() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit", "Bird"); + spawner.Entries[1].Disabled = true; + + var json = SpawnerJsonSerializer.SerializeCompact(new List { spawner.ToDto() }); + + Assert.Contains("\"entries\": [\n { \"name\": \"Rabbit\"", json); + Assert.Contains("{ \"name\": \"Bird\"", json); + Assert.Equal(1, CountOccurrences(json, "\"disabled\": true")); + + spawner.Delete(); + } + + [Fact] + public void Import_AdoptsDeserializedEntryObjects() + { + var json = """ + [{"$type":"Spawner","guid":"11111111-1111-1111-1111-111111111111","location":[1500,1500,0],"map":"Felucca","count":1,"minDelay":"00:05:00","maxDelay":"00:10:00","homeRange":4,"entries":[{"name":"Rabbit","probability":100,"maxCount":1,"disabled":true}]}] + """; + var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); + var spawner = dtos![0].ToSpawner(); + + Assert.Single(spawner.Entries); + Assert.True(spawner.Entries[0].Disabled); + Assert.Same(dtos[0].EntryView[0], spawner.Entries[0]); + + spawner.Delete(); + } + + private static int CountOccurrences(string source, string value) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(value, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + + return count; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs new file mode 100644 index 000000000..767a3289d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs @@ -0,0 +1,243 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using Server; +using Server.Engines.Spawners; +using Server.Tests; +using Server.Text; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners; + +[Collection("Sequential UOContent Tests")] +public class SpawnerEntryOwnershipTests +{ + [Fact] + public void Disabled_DefaultsFalse_AndRoundTripsBinary() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + Assert.False(spawner.Entries[0].Disabled); + Assert.True(spawner.Entries[0].Enabled); + + spawner.Entries[1].Disabled = true; + + var bytes = SpawnerBlob.Write(spawner); + var loaded = SpawnerBlob.Read(bytes, (Serial)0x40001234); + + Assert.Equal(2, loaded.Entries.Count); + Assert.False(loaded.Entries[0].Disabled); + Assert.True(loaded.Entries[1].Disabled); + + spawner.Delete(); + loaded.Delete(); + } + + [Fact] + public void Disabled_IsOmittedFromJsonWhenFalse_AndWrittenWhenTrue() + { + var entry = new SpawnerEntry("Rabbit"); + var json = JsonSerializer.Serialize(entry, SpawnerJsonSerializer.Options); + Assert.DoesNotContain("disabled", json); + + entry.Disabled = true; + json = JsonSerializer.Serialize(entry, SpawnerJsonSerializer.Options); + Assert.Contains("\"disabled\": true", json); + } + + [Fact] + public void AddRemoveClear_OperateOnOwnerList() + { + var spawner = new Spawner(); + Assert.Empty(spawner.Entries); + + var a = spawner.AddEntry("Rabbit", 100, 2, false); + var b = spawner.AddEntry("Bird", 50, 1, false, "Hue 33", null); + Assert.Equal(2, spawner.Entries.Count); + Assert.Same(a, spawner.Entries[0]); + Assert.Equal("Hue 33", spawner.Entries[1].Properties); + + spawner.RemoveEntry(a); + Assert.Single(spawner.Entries); + Assert.Same(b, spawner.Entries[0]); + + spawner.RemoveAllEntries(); + Assert.Empty(spawner.Entries); + + spawner.Delete(); + } + + [Fact] + public void Start_WorksAfterStop_WhenOwnerHasEntries() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); + Assert.True(spawner.Running); + spawner.Stop(); + Assert.False(spawner.Running); + spawner.Start(); + Assert.True(spawner.Running); + spawner.Delete(); + } + + [Fact] + public void Dupe_ClonesEntriesIntoIndependentList() + { + var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + source.Entries[1].Disabled = true; + + var copy = new Spawner(); + source.Dupe(copy); + + Assert.Equal(2, copy.Entries.Count); + Assert.NotSame(source.Entries[0], copy.Entries[0]); + Assert.Equal("Bird", copy.Entries[1].SpawnedName); + Assert.True(copy.Entries[1].Disabled); + Assert.NotEqual(source.Guid, copy.Guid); + + source.AddEntry("Orc", 100, 1, false); + Assert.Equal(2, copy.Entries.Count); + + source.Delete(); + copy.Delete(); + } + + [Fact] + public void CopyEntriesTo_ReplacesTargetEntries() + { + var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); + var target = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Orc", "Troll"); + + source.CopyEntriesTo(target); + + Assert.Single(target.Entries); + Assert.Equal("Rabbit", target.Entries[0].SpawnedName); + Assert.NotSame(source.Entries[0], target.Entries[0]); + + source.Delete(); + target.Delete(); + } + + [Fact] + public void CopyEntriesTo_Self_IsNoOp() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + var first = spawner.Entries[0]; + var second = spawner.Entries[1]; + + spawner.CopyEntriesTo(spawner); + + Assert.Equal(2, spawner.Entries.Count); + Assert.Same(first, spawner.Entries[0]); + Assert.Same(second, spawner.Entries[1]); + + spawner.Delete(); + } + + [Fact] + public void RemoveEntry_ForeignEntry_IsIgnored() + { + var a = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); + var b = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Bird"); + + var foreign = b.Entries[0]; + var spawned = new Item(0x1f13); + foreign.AddToSpawned(spawned); + + a.RemoveEntry(foreign); + + Assert.Single(a.Entries); + Assert.Equal("Rabbit", a.Entries[0].SpawnedName); + Assert.Single(b.Entries); + Assert.Same(foreign, b.Entries[0]); + + Assert.False(spawned.Deleted); + Assert.Single(foreign.Spawned); + + spawned.Delete(); + a.Delete(); + b.Delete(); + } + + // Manual: remove the Skip, run, and read the numbers from the thrown assertion. + [Fact(Skip = "manual benchmark")] + public void Benchmark_SpawnPath_Manual() + { + const int iterations = 100_000; + const int warmup = 1_000; + + var report = new ValueStringBuilder(stackalloc char[512]); + report.Append('\n', 1); + + foreach (var entryCount in new[] { 1, 10, 50 }) + { + var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); + spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + + // SpawnedMaxCount = 0 keeps every entry full, so Spawn() does selection only. + for (var i = 0; i < entryCount; i++) + { + spawner.AddEntry("Rabbit", 100, 0, false); + } + + for (var i = 0; i < warmup; i++) + { + spawner.Spawn(); + } + + var sw = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + { + spawner.Spawn(); + } + + sw.Stop(); + + var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations; + report.Append( + $"Spawn() entries={entryCount,2}: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n" + ); + + spawner.Delete(); + } + + { + var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); + spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + + for (var i = 0; i < 10; i++) + { + spawner.AddEntry("Rabbit", 100, 1, false); + } + + spawner.Spawn(); + Assert.Single(spawner.Spawned); + var rabbit = spawner.Spawned.Keys.First(); + + for (var i = 0; i < warmup; i++) + { + spawner.Remove(rabbit); + } + + var sw = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + { + spawner.Remove(rabbit); + } + + sw.Stop(); + + var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations; + report.Append( + $"Remove(ISpawnable) entries=10: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n" + ); + + (rabbit as Mobile)?.Delete(); + spawner.Delete(); + } + + var summary = report.ToString(); + report.Dispose(); + + throw new Xunit.Sdk.XunitException(summary); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs new file mode 100644 index 000000000..d3f48792a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using System.Reflection; +using Server.Engines.Spawners; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners; + +[Collection("Sequential UOContent Tests")] +public class SpawnerFixtureCapture +{ + // Freezes the BaseSpawner v12 save layout as test input; refuses to run against newer code. + [SkippableFact] + public void CaptureLegacyBlobs() + { + Skip.If(Environment.GetEnvironmentVariable("MODERNUO_CAPTURE_SPAWNER_FIXTURES") != "1"); + + var version = (int)typeof(BaseSpawner) + .GetField("SerializationVersion", BindingFlags.NonPublic | BindingFlags.Static)! + .GetRawConstantValue()!; + Assert.True(version == 12, $"Fixtures must be captured with BaseSpawner v12; current version is {version}."); + + var dir = Path.Combine(AppContext.BaseDirectory, "Tests", "Engines", "Spawners", "Fixtures"); + Directory.CreateDirectory(dir); + + var spawner = new Spawner(2, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird") + { + Name = "FixtureSpawner" + }; + spawner.Entries[1].Properties = "Hue 33"; + File.WriteAllBytes(Path.Combine(dir, "spawner.v12-v1.bin"), SpawnerBlob.Write(spawner)); + + var proximity = new ProximitySpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, 5, "boo", true, "Rat", "Bird") + { + Name = "FixtureProximity" + }; + File.WriteAllBytes(Path.Combine(dir, "proximity.v12-v1-v0.bin"), SpawnerBlob.Write(proximity)); + + var region = new RegionSpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, "Orc", "Troll") + { + Name = "FixtureRegion" + }; + File.WriteAllBytes(Path.Combine(dir, "region.v12-v1-v0.bin"), SpawnerBlob.Write(region)); + + spawner.Delete(); + proximity.Delete(); + region.Delete(); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs new file mode 100644 index 000000000..4450e1dea --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerHookTests.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ModernUO.Serialization; +using Server; +using Server.Engines.Spawners; +using Server.Mobiles; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners; + +[SerializationGenerator(0)] +public partial class TestEntry : SpawnerEntry +{ + // Dirty tracking is resolved on the declared type, so a derived entry re-declares its owner. + [DirtyTrackingEntity] + private BaseSpawner Owner => Parent; + + [SerializableField(0)] + private string _tag; + + public TestEntry(BaseSpawner parent) : base(parent) + { + } + + public TestEntry(BaseSpawner parent, string name, int probability, int maxCount, string properties, string parameters) + : base(parent, name, probability, maxCount, properties, parameters) + { + } +} + +[SerializationGenerator(0)] +public partial class HookRecordingSpawner : Spawner +{ + [SerializedIgnoreDupe] + [SerializableField(0)] + private List _testEntries; + + public List Log { get; } = []; + public bool VetoNext { get; set; } + + public HookRecordingSpawner() + { + } + + public HookRecordingSpawner(Serial serial) : base(serial) + { + } + + public override IReadOnlyList Entries => _testEntries ?? (IReadOnlyList)Array.Empty(); + + protected override ReadOnlySpan EntrySpan => + ReadOnlySpan.CastUp(CollectionsMarshal.AsSpan(_testEntries)); + + protected override SpawnerEntry CreateEntry(string name, int probability, int maxCount, string properties, string parameters) => + new TestEntry(this, name, probability, maxCount, properties, parameters) { Tag = "made" }; + + protected override void AddEntryCore(SpawnerEntry entry) + { + TestEntries ??= []; + AddToTestEntries((TestEntry)entry); + } + + protected override bool RemoveEntryCore(SpawnerEntry entry) + { + if (entry is not TestEntry te || _testEntries?.Contains(te) != true) + { + return false; + } + + RemoveFromTestEntries(te); + return true; + } + + protected override void ClearEntriesCore() + { + if (_testEntries?.Count > 0) + { + ClearTestEntries(); + } + } + + protected override void AdoptEntries(IReadOnlyList entries) + { + ClearEntriesCore(); + for (var i = 0; i < entries.Count; i++) + { + var e = entries[i]; + TestEntry te; + if (e is TestEntry existing) + { + te = existing; + } + else + { + te = (TestEntry)CloneEntry(e); + TransferSpawned(e, te); + } + + te.SetParent(this); + AddEntryCore(te); + } + } + + /// Test hook: adopt entries built elsewhere, exercising the conversion path. + public void AdoptForTest(IReadOnlyList entries) => AdoptEntries(entries); + + /// Test hook: rebuild the Spawned registry without a full save round trip. + public void RebuildSpawnedForTest() => RebuildSpawned(); + + // Spawner's rebuild runs before _testEntries is read; rebuild again here. + [AfterDeserialization] + private void AfterDeserialization() => RebuildSpawned(); + + protected override SpawnerEntry CloneEntry(SpawnerEntry source) + { + var clone = (TestEntry)base.CloneEntry(source); + clone.Tag = (source as TestEntry)?.Tag ?? clone.Tag; + return clone; + } + + protected override void OnStarted() => Log.Add("started"); + protected override void OnStopped() => Log.Add("stopped"); + + protected override bool OnBeforeSpawn(SpawnerEntry entry) + { + Log.Add($"before:{entry.SpawnedName}"); + if (VetoNext) + { + VetoNext = false; + return false; + } + + return true; + } + + protected override void OnConfigureSpawned(SpawnerEntry entry, ISpawnable spawned) => Log.Add($"configure:{entry.SpawnedName}"); + + protected override Point3D GetSpawnPosition(SpawnerEntry entry, ISpawnable spawned, Map map) + { + Log.Add($"position:{entry.SpawnedName}"); + return Location; + } + + protected override void OnSpawned(SpawnerEntry entry, ISpawnable spawned) => Log.Add($"spawned:{entry.SpawnedName}"); + + protected override void OnSpawnedDeath(SpawnerEntry entry, ISpawnable spawned, Mobile killer) => + Log.Add($"death:{entry.SpawnedName}:{killer?.Name ?? "none"}"); +} + +[Collection("Sequential UOContent Tests")] +public class SpawnerHookTests +{ + private static HookRecordingSpawner Place() + { + var spawner = new HookRecordingSpawner(); + spawner.InitSpawn(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); + spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + return spawner; + } + + [Fact] + public void Hooks_FireInOrder_OnSpawnAndStartStop() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + spawner.Log.Clear(); + + spawner.Stop(); + spawner.Start(); + spawner.Spawn(); + + Assert.Equal( + ["stopped", "started", "before:Rabbit", "configure:Rabbit", "position:Rabbit", "spawned:Rabbit"], + spawner.Log + ); + Assert.Single(spawner.Spawned); + + spawner.Delete(); + } + + [Fact] + public void Hooks_FireForItemEntries() + { + var spawner = Place(); + spawner.AddEntry("Gold", 100, 1, false); + spawner.Log.Clear(); + + spawner.Spawn(); + + Assert.Equal( + ["before:Gold", "configure:Gold", "position:Gold", "spawned:Gold"], + spawner.Log + ); + Assert.IsAssignableFrom(Assert.Single(spawner.Spawned).Key); + + spawner.Delete(); + } + + [Fact] + public void NextSpawn_OnStoppedSpawner_FiresOnStarted() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + spawner.Stop(); + spawner.Log.Clear(); + + spawner.NextSpawn = TimeSpan.FromSeconds(5); + + Assert.True(spawner.Running); + Assert.Equal(["started"], spawner.Log); + + spawner.Delete(); + } + + [Fact] + public void OnBeforeSpawn_CanVeto() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + spawner.VetoNext = true; + + spawner.Spawn(); + + Assert.Empty(spawner.Spawned); + Assert.Contains("before:Rabbit", spawner.Log); + Assert.DoesNotContain("spawned:Rabbit", spawner.Log); + + spawner.Delete(); + } + + [Fact] + public void Death_NotifiesOwningSpawner_BeforeUnlink() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + spawner.Spawn(); + var rabbit = Assert.Single(spawner.Spawned).Key as BaseCreature; + Assert.NotNull(rabbit); + + var killer = new PlayerMobile { Name = "Hunter" }; + rabbit.LastKiller = killer; + rabbit.Kill(); + + Assert.Contains("death:Rabbit:Hunter", spawner.Log); + + killer.Delete(); + spawner.Delete(); + } + + [Fact] + public void OwnEntryType_SurvivesBinaryRoundTripAndDupe() + { + var spawner = Place(); + spawner.AddEntry("Rabbit", 100, 1, false); + ((TestEntry)spawner.Entries[0]).Tag = "kept"; + spawner.Spawn(); + Assert.Single(spawner.Spawned); + + var loaded = SpawnerBlob.Read(SpawnerBlob.Write(spawner), (Serial)0x40009999); + Assert.Equal("kept", ((TestEntry)loaded.Entries[0]).Tag); + + Assert.Single(loaded.Entries[0].Spawned); + Assert.Single(loaded.Spawned); + + var copy = new HookRecordingSpawner(); + spawner.Dupe(copy); + Assert.Equal("kept", ((TestEntry)copy.Entries[0]).Tag); + Assert.Empty(copy.Entries[0].Spawned); + + spawner.Delete(); + loaded.Delete(); + copy.Delete(); + } + + [Fact] + public void AdoptEntries_ConvertsForeignEntries_KeepingLiveSpawns() + { + var source = new Spawner(); + source.InitSpawn(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); + source.MoveToWorld(new Point3D(1502, 1502, 0), Map.Felucca); + source.AddEntry("Rabbit", 100, 1, false); + source.Spawn(); + var rabbit = Assert.Single(source.Spawned).Key; + + var adopter = Place(); + adopter.AdoptForTest(source.Entries); + + var adopted = Assert.Single(adopter.Entries); + Assert.IsType(adopted); + Assert.Equal("Rabbit", adopted.SpawnedName); + Assert.Same(rabbit, Assert.Single(adopted.Spawned)); + Assert.Empty(source.Entries[0].Spawned); + + adopter.RebuildSpawnedForTest(); + Assert.Same(rabbit, Assert.Single(adopter.Spawned).Key); + + source.Delete(); + Assert.False(rabbit.Deleted); + + adopter.Delete(); + Assert.True(rabbit.Deleted); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs new file mode 100644 index 000000000..870ae1161 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerSaveMigrationTests.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using Server; +using Server.Engines.Spawners; +using Server.Items; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Engines.Spawners; + +internal static class SpawnerBlob +{ + public static byte[] Write(Item item) + { + var writer = new BufferWriter(true); + item.Serialize(writer); + return writer.Buffer[..(int)writer.Position]; + } + + public static T Read(byte[] bytes, Serial serial) where T : Item + { + var item = (T)Activator.CreateInstance(typeof(T), serial)!; + var reader = new BufferReader(bytes); + item.Deserialize(reader); + return item; + } +} + +[Collection("Sequential UOContent Tests")] +public class SpawnerSaveMigrationTests +{ + private static byte[] Fixture(string name) => + File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Tests", "Engines", "Spawners", "Fixtures", name)); + + [Fact] + public void V12Spawner_EntriesAreAdoptedByOwner() + { + var loaded = SpawnerBlob.Read(Fixture("spawner.v12-v1.bin"), (Serial)0x40001001); + + Assert.Equal("FixtureSpawner", loaded.Name); + Assert.Equal(2, loaded.Entries.Count); + Assert.Equal("Rabbit", loaded.Entries[0].SpawnedName); + Assert.Equal("Hue 33", loaded.Entries[1].Properties); + Assert.False(loaded.Entries[0].Disabled); + Assert.NotNull(loaded.Spawned); + Assert.Empty(loaded.Spawned); + + loaded.Delete(); + } + + [Fact] + public void V12ProximityAndRegion_EntriesAreAdoptedThroughSubclasses() + { + var prox = SpawnerBlob.Read(Fixture("proximity.v12-v1-v0.bin"), (Serial)0x40001002); + Assert.Equal(2, prox.Entries.Count); + Assert.Equal("Rat", prox.Entries[0].SpawnedName); + Assert.Equal("Bird", prox.Entries[1].SpawnedName); + Assert.Equal(5, prox.TriggerRange); + Assert.True(prox.InstantFlag); + + var region = SpawnerBlob.Read(Fixture("region.v12-v1-v0.bin"), (Serial)0x40001003); + Assert.Equal(2, region.Entries.Count); + Assert.Equal("Orc", region.Entries[0].SpawnedName); + Assert.Equal("Troll", region.Entries[1].SpawnedName); + + prox.Delete(); + region.Delete(); + } + + [Fact] + public void NewFormat_RoundTripsByteIdentical_WithLiveSpawnReferences() + { + var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit"); + spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); + spawner.Spawn(); + Assert.Single(spawner.Spawned); + + var first = SpawnerBlob.Write(spawner); + var loaded = SpawnerBlob.Read(first, (Serial)0x40001004); + Assert.Single(loaded.Entries); + Assert.Single(loaded.Entries[0].Spawned); + Assert.Single(loaded.Spawned); + + var second = SpawnerBlob.Write(loaded); + Assert.Equal(first, second); + + loaded.Delete(); + spawner.Delete(); + } +} diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 1c73f6eb4..ed096038f 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -11,11 +11,16 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs index 5f7aaf1d0..aab965232 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Dto.cs @@ -42,13 +42,9 @@ public abstract partial class BaseSpawner _spawnPositionMode = dto.SpawnPositionMode; _maxSpawnAttempts = dto.MaxSpawnAttempts; - if (dto.Entries != null) + if (dto.EntryView != null) { - for (var i = 0; i < dto.Entries.Count; i++) - { - var entry = dto.Entries[i]; - AddEntry(entry.SpawnedName, entry.SpawnedProbability, entry.SpawnedMaxCount, false, entry.Properties, entry.Parameters); - } + AdoptEntries(dto.EntryView); } } diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs new file mode 100644 index 000000000..83e31c179 --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Entries.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using ModernUO.Serialization; + +namespace Server.Engines.Spawners; + +public abstract partial class BaseSpawner +{ + /// + /// The entries this spawner cycles through, owned by the concrete subclass so it can use its own + /// entry type. Cold read-only view; loops inside BaseSpawner use . + /// + [IgnoreDupe] + public abstract IReadOnlyList Entries { get; } + + /// Zero-cost span over the owner's list for hot loops (no interface dispatch, no allocation). + protected abstract ReadOnlySpan EntrySpan { get; } + + /// Creates an entry of the owner's entry type, parented to this spawner. Not added. + protected abstract SpawnerEntry CreateEntry( + string name, + int probability, + int maxCount, + string properties, + string parameters + ); + + protected abstract void AddEntryCore(SpawnerEntry entry); + + protected abstract bool RemoveEntryCore(SpawnerEntry entry); + + protected abstract void ClearEntriesCore(); + + /// + /// Takes ownership of entries built elsewhere (a legacy save, a DTO import). The owner stores + /// them, re-parents them, and converts foreign entry types if it must. Replaces the current list. + /// + /// + /// An implementer that converts a foreign entry into its own entry type must carry the live spawns + /// across with ; deliberately does not copy + /// them, so a conversion that only clones orphans every creature the adopted entry owns. + /// + protected abstract void AdoptEntries(IReadOnlyList entries); + + /// + /// Moves the live spawns of onto . Use when an owner converts + /// an adopted entry into its own entry type; deliberately does not copy spawns. + /// + protected static void TransferSpawned(SpawnerEntry source, SpawnerEntry target) + { + var spawned = source.Spawned; + for (var i = 0; i < spawned.Count; i++) + { + target.AddToSpawned(spawned[i]); + } + + source.ClearSpawned(); + } + + /// Deep-copies an entry into this spawner's entry type. Override to carry subtype fields. + protected virtual SpawnerEntry CloneEntry(SpawnerEntry source) + { + var entry = CreateEntry( + source.SpawnedName, + source.SpawnedProbability, + source.SpawnedMaxCount, + source.Properties, + source.Parameters + ); + entry.Disabled = source.Disabled; + return entry; + } + + public SpawnerEntry AddEntry( + string creaturename, + int probability = 100, + int amount = 1, + bool dotimer = true, + string properties = null, + string parameters = null + ) + { + var entry = CreateEntry(creaturename, probability, amount, properties, parameters); + AddEntryCore(entry); + if (dotimer) + { + DoTimer(TimeSpan.FromSeconds(1)); + } + + return entry; + } + + public void RemoveEntry(SpawnerEntry entry) + { + Defrag(); + + if (!RemoveEntryCore(entry)) + { + return; + } + + RemoveSpawn(entry); + + if (_running && !IsFull && _timer?.Running != true) + { + DoTimer(); + } + + InvalidateProperties(); + } + + /// + /// Deletes every live spawn and removes every entry. Named for the deletion: before entry ownership + /// moved to the owner, the generator emitted a ClearEntries() here that only emptied the list. + /// + public void RemoveAllEntries() + { + RemoveSpawns(); + ClearEntriesCore(); + InvalidateProperties(); + } + + /// Replaces 's entries with clones of this spawner's entries. + public void CopyEntriesTo(BaseSpawner target) + { + // A self-copy would clear the source. + if (ReferenceEquals(target, this)) + { + return; + } + + target.RemoveAllEntries(); + + var entries = EntrySpan; + for (var i = 0; i < entries.Length; i++) + { + target.AddEntryCore(target.CloneEntry(entries[i])); + } + + target.InvalidateProperties(); + } + + /// + /// Rebuilds the entity -> entry registry from the owner's entries and re-arms the timer. + /// The owner calls this from its own [AfterDeserialization] once its list is loaded; the base + /// hook runs before derived fields exist and must not touch entries. + /// + /// + /// calls this for its own entry list only. A subclass that owns a different + /// list (its own entry type, or an extra list) must call it again from its own + /// [AfterDeserialization]: the base class's runs before the derived fields have been read, + /// so the load would otherwise finish with an empty registry even though the + /// entries themselves carry their spawns. + /// + protected void RebuildSpawned() + { + Spawned = new Dictionary(); + + var entries = EntrySpan; + for (var i = 0; i < entries.Length; i++) + { + var entry = entries[i]; + var spawned = entry.Spawned; + for (var j = 0; j < spawned.Count; j++) + { + Spawned.TryAdd(spawned[j], entry); + } + } + + DoTimer(_end - Core.Now); + } +} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs new file mode 100644 index 000000000..ccc2a226d --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Hooks.cs @@ -0,0 +1,53 @@ +namespace Server.Engines.Spawners; + +public abstract partial class BaseSpawner +{ + /// + /// Called after the timer starts (Start(), Running = true, NextSpawn on a stopped spawner). + /// Not called for construction (InitSpawn) or deserialization; subclasses initialise + /// run state in their constructor and [AfterDeserialization]. + /// + protected virtual void OnStarted() + { + } + + /// Called after the timer stops (Stop(), Running = false), and only if it was running. + protected virtual void OnStopped() + { + } + + /// Veto point before an entry's entity is constructed. Return false to skip this attempt. + protected virtual bool OnBeforeSpawn(SpawnerEntry entry) => true; + + /// + /// Runs after property application and before positioning, so computed properties apply first. + /// The entity is not yet in , has no Spawner set, and is still on + /// the internal map. + /// + protected virtual void OnConfigureSpawned(SpawnerEntry entry, ISpawnable spawned) + { + } + + /// Entry-aware positioning. Default delegates to the entry-agnostic overload. + protected virtual Point3D GetSpawnPosition(SpawnerEntry entry, ISpawnable spawned, Map map) => + GetSpawnPosition(spawned, map); + + /// Runs after the entity is in the world and linked to this spawner. + protected virtual void OnSpawned(SpawnerEntry entry, ISpawnable spawned) + { + } + + /// A spawned creature died (before base death deletes it and unlinks the spawner). + protected virtual void OnSpawnedDeath(SpawnerEntry entry, ISpawnable spawned, Mobile killer) + { + } + + /// Entry point for BaseCreature.OnDeath. Resolves the entry and dispatches the hook. + public void NotifySpawnedDeath(ISpawnable spawned, Mobile killer) + { + if (spawned != null && Spawned != null && Spawned.TryGetValue(spawned, out var entry)) + { + OnSpawnedDeath(entry, spawned, killer); + } + } +} diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs index 9c5c4f002..35d9c2433 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs @@ -13,7 +13,7 @@ public abstract partial class BaseSpawner { _guid = content.Guid; _returnOnDeactivate = content.ReturnOnDeactivate; - _entries = content.Entries; + AdoptEntries(content.Entries ?? []); _walkingRange = content.WalkingRange; _wayPoint = content.WayPoint; _group = content.Group; @@ -41,7 +41,7 @@ public abstract partial class BaseSpawner { _guid = content.Guid; _returnOnDeactivate = content.ReturnOnDeactivate; - _entries = content.Entries; + AdoptEntries(content.Entries ?? []); _walkingRange = content.WalkingRange; _wayPoint = content.WayPoint; _group = content.Group; @@ -65,21 +65,44 @@ public abstract partial class BaseSpawner _maxSpawnAttempts = DefaultMaxSpawnAttempts; } + private void MigrateFrom(V12Content content) + { + _guid = content.Guid; + _returnOnDeactivate = content.ReturnOnDeactivate; + _walkingRange = content.WalkingRange; + _wayPoint = content.WayPoint; + _group = content.Group; + _minDelay = content.MinDelay ?? DefaultMinDelay; + _maxDelay = content.MaxDelay ?? DefaultMaxDelay; + _count = content.Count; + _team = content.Team ?? 0; + _running = content.Running; + _spawnLocationIsHome = content.SpawnLocationIsHome; + // End is unsaved when default; a running spawner re-arms immediately either way (DoTimer clamps). + _end = _running ? content.End ?? Core.Now : Core.Now; + _spawnPositionMode = content.SpawnPositionMode ?? SpawnPositionMode.Automatic; + _maxSpawnAttempts = content.MaxSpawnAttempts ?? DefaultMaxSpawnAttempts; + + AdoptEntries(content.Entries ?? []); + } + private void Deserialize(IGenericReader reader, int version) { _guid = reader.ReadGuid(); _returnOnDeactivate = reader.ReadBool(); var count = reader.ReadInt(); - _entries = new List(count); + var entries = new List(count); for (var i = 0; i < count; ++i) { var entry = new SpawnerEntry(this); entry.Deserialize(reader); - _entries.Add(entry); + entries.Add(entry); } + AdoptEntries(entries); + _walkingRange = reader.ReadInt(); _wayPoint = reader.ReadEntity(); _group = reader.ReadBool(); diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 750a08257..3a78f3b4a 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -40,7 +40,7 @@ public enum SpawnPositionMode : byte Abandoned = 3 } -[SerializationGenerator(12, false)] +[SerializationGenerator(13, false)] public abstract partial class BaseSpawner : Item, ISpawner { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseSpawner)); @@ -61,15 +61,11 @@ public abstract partial class BaseSpawner : Item, ISpawner [SerializedCommandProperty(AccessLevel.Developer)] private bool _returnOnDeactivate; - [SerializedIgnoreDupe] - [SerializableField(2, setter: "private")] - private List _entries; - private int _walkingRange = -1; private bool ShouldSerializeWayPoint() => _wayPoint != null; - [SerializableField(4)] + [SerializableField(3)] [SaveFlag(nameof(ShouldSerializeWayPoint))] [SerializedCommandProperty(AccessLevel.Developer)] private WayPoint _wayPoint; @@ -77,7 +73,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private bool ShouldSerializeGroup() => _group; [InvalidateProperties] - [SerializableField(5)] + [SerializableField(4)] [SaveFlag(nameof(ShouldSerializeGroup))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _group; @@ -87,7 +83,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private TimeSpan MinDelayDefault() => DefaultMinDelay; [InvalidateProperties] - [SerializableField(6)] + [SerializableField(5)] [SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _minDelay; @@ -97,7 +93,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private TimeSpan MaxDelayDefault() => DefaultMaxDelay; [InvalidateProperties] - [SerializableField(7)] + [SerializableField(6)] [SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _maxDelay; @@ -105,7 +101,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private bool ShouldSerializeTeam() => _team != 0; [InvalidateProperties] - [SerializableField(9)] + [SerializableField(8)] [SaveFlag(nameof(ShouldSerializeTeam))] [SerializedCommandProperty(AccessLevel.Developer)] private int _team; @@ -126,14 +122,14 @@ public abstract partial class BaseSpawner : Item, ISpawner private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome; [InvalidateProperties] - [SerializableField(11)] + [SerializableField(10)] [SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _spawnLocationIsHome; private bool ShouldSerializeEnd() => _end != default; - [SerializableField(12)] + [SerializableField(11)] [SaveFlag(nameof(ShouldSerializeEnd))] [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; @@ -144,7 +140,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private bool ShouldSerializeSpawnPositionMode() => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned; - [SerializableField(13)] + [SerializableField(12)] [SaveFlag(nameof(ShouldSerializeSpawnPositionMode))] [SerializedCommandProperty(AccessLevel.Developer)] private SpawnPositionMode _spawnPositionMode; @@ -158,7 +154,7 @@ public abstract partial class BaseSpawner : Item, ISpawner private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts; - [SerializableField(14)] + [SerializableField(13)] [SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private int _maxSpawnAttempts; @@ -300,7 +296,7 @@ public abstract partial class BaseSpawner : Item, ISpawner public Dictionary Spawned { get; private set; } [CommandProperty(AccessLevel.Developer)] - [SerializableProperty(3, nameof(_walkingRange))] + [SerializableProperty(2, nameof(_walkingRange))] public int WalkingRange { get => _walkingRange > 0 ? _walkingRange : HomeRange; @@ -312,7 +308,7 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableField(8, fieldChanged: nameof(OnCountChanged))] + [SerializableField(7, fieldChanged: nameof(OnCountChanged))] [SerializedCommandProperty(AccessLevel.Developer)] [InvalidateProperties] private int _count; @@ -329,7 +325,7 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableProperty(10)] + [SerializableProperty(9)] [CommandProperty(AccessLevel.Developer)] public bool Running { @@ -356,10 +352,10 @@ public abstract partial class BaseSpawner : Item, ISpawner get => _running && _timer?.Running == true ? End - Core.Now : TimeSpan.Zero; set { - if (!_running && Entries.Count > 0) + if (BeginStart()) { - _running = true; DoTimer(value); + OnStarted(); } } } @@ -641,20 +637,7 @@ public abstract partial class BaseSpawner : Item, ISpawner { newSpawner._guid = Guid.NewGuid(); newSpawner.Spawned = new Dictionary(); - newSpawner.Entries = []; - - for (var i = 0; i < Entries.Count; i++) - { - var entry = Entries[i]; - newSpawner.AddEntry( - entry.SpawnedName, - entry.SpawnedProbability, - entry.SpawnedMaxCount, - false, - entry.Properties, - entry.Parameters - ); - } + CopyEntriesTo(newSpawner); } } @@ -698,25 +681,6 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - public SpawnerEntry AddEntry( - string creaturename, - int probability = 100, - int amount = 1, - bool dotimer = true, - string properties = null, - string parameters = null - ) - { - var entry = new SpawnerEntry(this, creaturename, probability, amount, properties, parameters); - AddToEntries(entry); - if (dotimer) - { - DoTimer(TimeSpan.FromSeconds(1)); - } - - return entry; - } - public void InitSpawn(int amount, TimeSpan minDelay, TimeSpan maxDelay, int team = 0, Rectangle3D spawnBounds = default) { Visible = false; @@ -737,7 +701,7 @@ public abstract partial class BaseSpawner : Item, ISpawner HomeRange = 4; } - Entries = []; + ClearEntriesCore(); Spawned = new Dictionary(); DoTimer(TimeSpan.FromSeconds(1)); @@ -804,26 +768,42 @@ public abstract partial class BaseSpawner : Item, ISpawner public void Start() { - if (!_running && Entries.Count > 0) + if (BeginStart()) { - _running = true; DoTimer(); + OnStarted(); } } + /// Guards and flips . Callers arm the timer and fire . + private bool BeginStart() + { + if (_running || Entries.Count == 0) + { + return false; + } + + _running = true; + return true; + } + public void Stop() { + var wasRunning = _running; _timer?.Stop(); _running = false; + if (wasRunning) + { + OnStopped(); + } } public void Defrag() { - Entries ??= []; - - for (var i = 0; i < Entries.Count; ++i) + var entries = EntrySpan; + for (var i = 0; i < entries.Length; i++) { - Entries[i].Defrag(this); + entries[i].Defrag(this); } } @@ -867,16 +847,18 @@ public abstract partial class BaseSpawner : Item, ISpawner { Defrag(); - if (Entries.Count <= 0 || IsFull) + var entries = EntrySpan; + if (entries.Length <= 0 || IsFull) { return; } var probsum = 0; - foreach (var spawnerEntry in Entries) + for (var i = 0; i < entries.Length; i++) { - if (!spawnerEntry.IsFull) + var spawnerEntry = entries[i]; + if (!spawnerEntry.IsFull && !spawnerEntry.Disabled) { probsum += spawnerEntry.SpawnedProbability; } @@ -889,10 +871,10 @@ public abstract partial class BaseSpawner : Item, ISpawner var rand = Utility.RandomMinMax(1, probsum); - for (var i = 0; i < Entries.Count; i++) + for (var i = 0; i < entries.Length; i++) { - var entry = Entries[i]; - if (entry.IsFull) + var entry = entries[i]; + if (entry.IsFull || entry.Disabled) { continue; } @@ -1010,6 +992,11 @@ public abstract partial class BaseSpawner : Item, ISpawner return false; } + if (!OnBeforeSpawn(entry)) + { + return false; + } + try { IEntity entity = null; @@ -1095,6 +1082,11 @@ public abstract partial class BaseSpawner : Item, ISpawner } } + if (entity is ISpawnable configured) + { + OnConfigureSpawned(entry, configured); + } + if (entity is Mobile m) { Spawned.Add(m, entry); @@ -1102,7 +1094,7 @@ public abstract partial class BaseSpawner : Item, ISpawner // var spawnLocation = m is BaseVendor ? Location : GetSpawnPosition(m, map); - var spawnLocation = GetSpawnPosition(m, map); + var spawnLocation = GetSpawnPosition(entry, m, map); m.OnBeforeSpawn(spawnLocation, map); m.MoveToWorld(spawnLocation, map); @@ -1133,13 +1125,14 @@ public abstract partial class BaseSpawner : Item, ISpawner m.Spawner = this; m.OnAfterSpawn(); + OnSpawned(entry, m); } else if (entity is Item item) { Spawned.Add(item, entry); entry.AddToSpawned(item); - var loc = GetSpawnPosition(item, map); + var loc = GetSpawnPosition(entry, item, map); item.OnBeforeSpawn(loc, map); @@ -1147,6 +1140,7 @@ public abstract partial class BaseSpawner : Item, ISpawner item.Spawner = this; item.OnAfterSpawn(); + OnSpawned(entry, item); } else { @@ -1221,27 +1215,6 @@ public abstract partial class BaseSpawner : Item, ISpawner return entry.Spawned.Count; } - public void RemoveEntry(SpawnerEntry entry) - { - Defrag(); - - for (var i = entry.Spawned.Count - 1; i >= 0; i--) - { - var e = entry.Spawned[i]; - entry.Spawned.RemoveAt(i); - e?.Delete(); - } - - Entries.Remove(entry); - - if (_running && !IsFull && _timer?.Running != true) - { - DoTimer(); - } - - InvalidateProperties(); - } - public void RemoveSpawn(int index) // Entry { if (index >= 0 && index < Entries.Count) @@ -1269,9 +1242,10 @@ public abstract partial class BaseSpawner : Item, ISpawner { Defrag(); - for (var i = 0; i < Entries.Count; i++) + var entries = EntrySpan; + for (var i = 0; i < entries.Length; i++) { - var entry = Entries[i]; + var entry = entries[i]; for (var j = entry.Spawned.Count - 1; j >= 0; j--) { @@ -1327,18 +1301,6 @@ public abstract partial class BaseSpawner : Item, ISpawner 256 ); } - - Spawned = new Dictionary(); - - foreach (var entry in Entries) - { - foreach (var spawned in entry.Spawned) - { - Spawned.Add(spawned, entry); - } - } - - DoTimer(_end - Core.Now); } private class InternalTimer : Timer diff --git a/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs b/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs index 067a7f762..4efc99585 100644 --- a/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs +++ b/Projects/UOContent/Engines/Spawners/Commands/EditSpawnerCommand.cs @@ -105,8 +105,10 @@ public class EditSpawnCommand : BaseCommand public static void UpdateSpawner(BaseSpawner spawner, string name, string arguments, string properties, string find = null) { - foreach (var entry in spawner.Entries) + for (var i = 0; i < spawner.Entries.Count; i++) { + var entry = spawner.Entries[i]; + // TODO: Should cache spawn type on the entry if (!entry.SpawnedName.InsensitiveEquals(name)) { diff --git a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs index dc5ef63f4..e6bc92808 100644 --- a/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs +++ b/Projects/UOContent/Engines/Spawners/Json/SpawnerDto.cs @@ -74,11 +74,6 @@ public abstract record SpawnerDto [JsonPropertyOrder(9)] public int WalkingRange { get; init; } - [JsonPropertyName("entries")] - [JsonPropertyOrder(10)] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public List Entries { get; init; } - [JsonPropertyName("spawnLocationIsHome")] [JsonPropertyOrder(11)] public bool SpawnLocationIsHome { get; init; } @@ -97,6 +92,10 @@ public abstract record SpawnerDto [JsonPropertyOrder(8)] public int HomeRange { get; init; } = -1; + /// The entries carried by the concrete record, in its own entry type. Never serialized directly. + [JsonIgnore] + public abstract IReadOnlyList EntryView { get; } + /// Constructs the empty concrete spawner Item for this DTO. protected abstract BaseSpawner CreateEmpty(); @@ -125,6 +124,14 @@ public sealed record SpawnerDataDto : SpawnerDto [JsonPropertyOrder(8)] public Rectangle3D SpawnBounds { get; init; } + [JsonPropertyName("entries")] + [JsonPropertyOrder(10)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList EntryView => Entries; + protected override BaseSpawner CreateEmpty() => new Spawner(); public override BaseSpawner ToSpawner() @@ -154,6 +161,14 @@ public sealed record RegionSpawnerDto : SpawnerDto [JsonPropertyOrder(8)] public string Region { get; init; } + [JsonPropertyName("entries")] + [JsonPropertyOrder(10)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList EntryView => Entries; + protected override BaseSpawner CreateEmpty() => new RegionSpawner(); public override BaseSpawner ToSpawner() @@ -191,6 +206,14 @@ public sealed record ProximitySpawnerDto : SpawnerDto [JsonPropertyOrder(16)] public bool Instant { get; init; } + [JsonPropertyName("entries")] + [JsonPropertyOrder(10)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List Entries { get; init; } + + [JsonIgnore] + public override IReadOnlyList EntryView => Entries; + protected override BaseSpawner CreateEmpty() => new ProximitySpawner(); public override BaseSpawner ToSpawner() diff --git a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs index 219e91943..d71676815 100644 --- a/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/ProximitySpawner.Dto.cs @@ -31,7 +31,7 @@ public partial class ProximitySpawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs index 3d9614247..d6c343071 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.Dto.cs @@ -29,7 +29,7 @@ public partial class RegionSpawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs index a291a4854..4b9e9f495 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.Dto.cs @@ -31,7 +31,7 @@ public partial class Spawner MaxDelay = MaxDelay, Team = Team, WalkingRange = DtoWalkingRange, - Entries = Entries, + Entries = EntryList ?? [], SpawnLocationIsHome = SpawnLocationIsHome, SpawnPositionMode = DtoSpawnPositionMode, MaxSpawnAttempts = DtoMaxSpawnAttempts, diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index db25cab55..184558e79 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; using ModernUO.Serialization; namespace Server.Engines.Spawners; -[SerializationGenerator(1)] +[SerializationGenerator(2)] public partial class Spawner : BaseSpawner { /// @@ -33,6 +35,11 @@ public partial class Spawner : BaseSpawner } } + // Owned by the concrete class so subclasses can store their own entry type; null until the first entry. + [SerializedIgnoreDupe] + [SerializableField(2, getter: "protected", setter: "private")] + private List _entryList; + [Constructible(AccessLevel.Developer)] public Spawner() { @@ -63,8 +70,73 @@ public partial class Spawner : BaseSpawner protected override ReadOnlySpan GetAllSpawnBounds() => new(ref _spawnBounds); + public override IReadOnlyList Entries => _entryList ?? (IReadOnlyList)Array.Empty(); + + protected override ReadOnlySpan EntrySpan => CollectionsMarshal.AsSpan(_entryList); + + protected override SpawnerEntry CreateEntry( + string name, + int probability, + int maxCount, + string properties, + string parameters + ) => new(this, name, probability, maxCount, properties, parameters); + + protected override void AddEntryCore(SpawnerEntry entry) + { + EntryList ??= []; + AddToEntryList(entry); + } + + protected override bool RemoveEntryCore(SpawnerEntry entry) + { + if (_entryList?.Contains(entry) != true) + { + return false; + } + + RemoveFromEntryList(entry); + return true; + } + + protected override void ClearEntriesCore() + { + if (_entryList?.Count > 0) + { + ClearEntryList(); + } + } + + protected override void AdoptEntries(IReadOnlyList entries) + { + if (entries.Count == 0) + { + EntryList = null; + return; + } + + // Copy, never alias the caller's list. + var list = new List(entries); + for (var i = 0; i < list.Count; i++) + { + list[i].SetParent(this); + } + + EntryList = list; + } + private void MigrateFrom(V0Content content) { - // V0 had no fields in Spawner, new v1 field _useSpiralScan defaults to false + // v0 had no fields. } + + private void MigrateFrom(V1Content content) + { + _useSpiralScan = content.UseSpiralScan; + _spawnBounds = content.SpawnBounds ?? default; + // _entryList was already adopted by BaseSpawner.MigrateFrom(V12Content). + } + + [AfterDeserialization] + private void AfterDeserialization() => RebuildSpawned(); } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs index 787ef9ab4..ee5c44586 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerControllerGump.cs @@ -447,8 +447,9 @@ public class SpawnerControllerGump : DynamicGump private static bool SearchSpawnerCreatures(BaseSpawner spawner, string searchPattern) { - foreach (var entry in spawner.Entries) + for (var i = 0; i < spawner.Entries.Count; i++) { + var entry = spawner.Entries[i]; if (entry.SpawnedName?.InsensitiveContains(searchPattern) == true) { return true; @@ -486,17 +487,9 @@ public class SpawnerControllerGump : DynamicGump public static void CopyEntry(BaseSpawner spawner, BaseSpawner target) { - if (spawner.Entries?.Count > 0) + if (spawner.Entries.Count > 0) { - target.Entries?.Clear(); - - for (var i = 0; i < spawner.Entries.Count; i++) - { - var item = spawner.Entries[i]; - var targetEntry = target.AddEntry(item.SpawnedName, item.SpawnedProbability, item.SpawnedMaxCount); - targetEntry.Properties = item.Properties; - targetEntry.Parameters = item.Parameters; - } + spawner.CopyEntriesTo(target); } } diff --git a/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs b/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs index d364b3916..524da1b6f 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerEntry.cs @@ -5,7 +5,7 @@ using Server.Json; namespace Server.Engines.Spawners; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class SpawnerEntry { [DirtyTrackingEntity] @@ -36,6 +36,48 @@ public partial class SpawnerEntry [SerializableField(5)] private List _spawned; + private bool ShouldSerializeDisabled() => _disabled; + + /// + /// Locked entries are skipped by weighted selection; live spawns are untouched. + /// Stored inverted so the common (enabled) case writes nothing. + /// + [SaveFlag(nameof(ShouldSerializeDisabled))] + [SerializableField(6)] + [SerializedJsonPropertyName("disabled")] + private bool _disabled; + + [JsonIgnore] + public bool Enabled + { + get => !_disabled; + set => Disabled = !value; + } + + /// + /// The spawner that owns this entry. Derived entry types declare it as their + /// [DirtyTrackingEntity]; the generator only inspects the type it is generating. + /// + protected BaseSpawner Parent => _parent; + + /// Re-parents this entry. Public so out-of-tree owners can call it from AdoptEntries. + public void SetParent(BaseSpawner parent) + { + _parent = parent; + _spawned ??= []; + } + + private void MigrateFrom(V1Content content) + { + _spawnedName = content.SpawnedName; + _spawnedProbability = content.SpawnedProbability; + _spawnedMaxCount = content.SpawnedMaxCount; + _properties = content.Properties; + _parameters = content.Parameters; + _spawned = content.Spawned ?? []; + _disabled = false; + } + public SpawnerEntry(BaseSpawner parent) { _parent = parent; diff --git a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs index e22782228..e7edd6247 100644 --- a/Projects/UOContent/Engines/Spawners/SpawnerGump.cs +++ b/Projects/UOContent/Engines/Spawners/SpawnerGump.cs @@ -63,7 +63,18 @@ public class SpawnerGump : Gump ); // Unexpand } - AddButton(38, 22 * i + 21 + offset, 0xFA2, 0xFA4, GetButtonID(2, 1 + i * 2)); // Delete + AddButton(46, 22 * i + 21 + offset, 0xFA2, 0xFA4, GetButtonID(2, 1 + i * 2)); // Delete + + if (entry != null) + { + AddButton( + 22, + 22 * i + 21 + offset, + entry.Disabled ? 0xD2 : 0xD3, + entry.Disabled ? 0xD3 : 0xD2, + GetButtonID(3, i) + ); // Enabled toggle (checked = enabled) + } AddImageTiled(71, 22 * i + 20 + offset, 161, 23, 0xA40); // creature text box AddImageTiled(72, 22 * i + 21 + offset, 159, 21, 0xBBC); // creature text box @@ -106,7 +117,7 @@ public class SpawnerGump : Gump 22 * i + 21 + offset, 156, 21, - (flags & EntryFlags.InvalidType) != 0 ? 33 : 0, + (flags & EntryFlags.InvalidType) != 0 ? 33 : entry?.Disabled == true ? 0x3B2 : 0, textIndex, name ); @@ -160,8 +171,9 @@ public class SpawnerGump : Gump var totalSpawned = 0; var totalWeight = 0; - foreach (var spawnerEntry in _spawner.Entries) + for (var i = 0; i < _spawner.Entries.Count; i++) { + var spawnerEntry = _spawner.Entries[i]; totalSpawned += spawner.CountSpawns(spawnerEntry); totalWeight += spawnerEntry.SpawnedProbability; } @@ -419,12 +431,24 @@ public class SpawnerGump : Gump } } + CreateArray(info, state.Mobile, _spawner); + break; + } + case 3: // Enable/disable entry + { + var entryIndex = index + _page * 13; + if (entryIndex >= 0 && entryIndex < _spawner.Entries.Count) + { + var entry = _spawner.Entries[entryIndex]; + entry.Disabled = !entry.Disabled; + } + CreateArray(info, state.Mobile, _spawner); break; } } - if (_entry != null && _spawner.Entries?.Contains(_entry) == true) + if (_entry != null && HasEntry(_spawner, _entry)) { state.Mobile.SendGump(new SpawnerGump(_spawner, _entry, _page)); } @@ -433,4 +457,19 @@ public class SpawnerGump : Gump state.Mobile.SendGump(new SpawnerGump(_spawner, null, _page)); } } + + private static bool HasEntry(BaseSpawner spawner, SpawnerEntry entry) + { + var entries = spawner.Entries; + + for (var i = 0; i < entries.Count; i++) + { + if (entries[i] == entry) + { + return true; + } + } + + return false; + } } diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json new file mode 100644 index 000000000..eab4d5279 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v13.json @@ -0,0 +1,113 @@ +{ + "version": 13, + "type": "Server.Engines.Spawners.BaseSpawner", + "properties": [ + { + "name": "Guid", + "type": "System.Guid", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ReturnOnDeactivate", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "WalkingRange", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "WayPoint", + "type": "Server.Items.WayPoint", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Group", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MinDelay", + "type": "System.TimeSpan", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "MaxDelay", + "type": "System.TimeSpan", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "Count", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Team", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Running", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnLocationIsHome", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "End", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnPositionMode", + "type": "Server.Engines.Spawners.SpawnPositionMode", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "MaxSpawnAttempts", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json new file mode 100644 index 000000000..8127420e2 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v2.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "type": "Server.Engines.Spawners.Spawner", + "properties": [ + { + "name": "UseSpiralScan", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnBounds", + "type": "Server.Rectangle3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect3D" + ] + }, + { + "name": "EntryList", + "type": "System.Collections.Generic.List\u003CServer.Engines.Spawners.SpawnerEntry\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Engines.Spawners.SpawnerEntry", + "RawSerializableMigrationRule", + "DeserializationRequiresParent" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json new file mode 100644 index 000000000..481b3b243 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.SpawnerEntry.v2.json @@ -0,0 +1,65 @@ +{ + "version": 2, + "type": "Server.Engines.Spawners.SpawnerEntry", + "properties": [ + { + "name": "SpawnedName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnedProbability", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnedMaxCount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Properties", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Parameters", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Spawned", + "type": "System.Collections.Generic.List\u003CServer.ISpawnable\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.ISpawnable", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Disabled", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index b7e893f84..077e47174 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -3453,6 +3453,11 @@ namespace Server.Mobiles public override void OnDeath(Container c) { + if (Spawner is BaseSpawner spawner) + { + spawner.NotifySpawnedDeath(this, LastKiller); + } + if (IsBonded) { Effects.PlaySound(this, GetDeathSound());