diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 91c7db3de..74ca781ce 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -7,7 +7,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Projects/Server/Json/DynamicJson.cs b/Projects/Server/Json/DynamicJson.cs index 09cc85236..8cbc0b2ad 100644 --- a/Projects/Server/Json/DynamicJson.cs +++ b/Projects/Server/Json/DynamicJson.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -41,7 +42,11 @@ public class DynamicJson Data[key] = doc.RootElement.Clone(); } - public bool GetProperty(string key, JsonSerializerOptions options, out T t) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool GetProperty(string key, JsonSerializerOptions options, out T t) => + GetProperty(key, options, default, out t); + + public bool GetProperty(string key, JsonSerializerOptions options, T defaultT, out T t) { if (Data.TryGetValue(key, out var el)) { @@ -49,7 +54,7 @@ public class DynamicJson return true; } - t = default; + t = defaultT; return false; } diff --git a/Projects/Server/Maps/Map.cs b/Projects/Server/Maps/Map.cs index 58ac68582..11eab4a53 100644 --- a/Projects/Server/Maps/Map.cs +++ b/Projects/Server/Maps/Map.cs @@ -39,6 +39,20 @@ public enum MapRules FeluccaRules = None } +/// +/// Indicates why a spawn position check failed. Used by spawners to determine +/// if optimization (caching) should be enabled. +/// +[Flags] +public enum SpawnFailureReason +{ + None = 0, + InvalidMap = 1 << 0, // Internal map or out of bounds + RegionBlocked = 1 << 1, // Region doesn't allow spawning + TransientBlocker = 1 << 2, // Mobile or movable item blocking + NonTransientBlocker = 1 << 3 // Static, multi, impassable land, or non-movable item +} + public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsable { public const int SectorSize = 16; @@ -1103,17 +1117,38 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa /// Whether the spawned entity cannot walk (water-only) /// The valid spawn Z if found /// True if a valid spawn Z was found within the range + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool CanSpawnMobile(int x, int y, int minZ, int maxZ, bool canSwim, bool cantWalk, out int spawnZ) + => CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out spawnZ, out _); + + /// + /// Finds a valid spawn Z within the specified range by checking land, static, multi tiles, and world items. + /// Prefers the lowest valid surface (ground/floor over tables/platforms). + /// Also reports the reason for failure if no valid position is found. + /// + /// X coordinate + /// Y coordinate + /// Minimum Z (inclusive) + /// Maximum Z (inclusive) + /// Whether the spawned entity can swim (water surfaces valid) + /// Whether the spawned entity cannot walk (water-only) + /// The valid spawn Z if found + /// Indicates why spawn failed (if it did) + /// True if a valid spawn Z was found within the range + public bool CanSpawnMobile(int x, int y, int minZ, int maxZ, bool canSwim, bool cantWalk, out int spawnZ, out SpawnFailureReason failureReason) { spawnZ = 0; + failureReason = SpawnFailureReason.None; if (this == Internal || x < 0 || y < 0 || x >= Width || y >= Height) { + failureReason = SpawnFailureReason.InvalidMap; return false; } if (!Region.Find(new Point3D(x, y, minZ), this).AllowSpawn()) { + failureReason = SpawnFailureReason.RegionBlocked; return false; } @@ -1125,6 +1160,10 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa var openSlots = ulong.MaxValue; ulong surfaces = 0; + // Track what types of blockers we encounter (for failure reason) + var hasNonTransientBlocker = false; + var hasTransientBlocker = false; + // 1. Land tile var landTile = Tiles.GetLandTile(x, y); GetAverageZ(x, y, out var lowZ, out var avgZ, out _); @@ -1140,6 +1179,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa { // Impassable land blocks z in range (lowZ - 16, avgZ) openSlots &= ~CreateBlockerMask(lowZ - 16, avgZ, minZ); + hasNonTransientBlocker = true; } // Surface: water for swimmers, passable land for walkers @@ -1149,7 +1189,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa } } - // 2. Static and multi tiles + // 2. Static and multi tiles (always non-transient) foreach (var tile in Tiles.GetStaticAndMultiTiles(x, y)) { var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; @@ -1163,6 +1203,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa if ((isSurface || isImpassable) && !(canSwim && isWet)) { openSlots &= ~CreateBlockerMask(tile.Z - 16, tileTop, minZ); + hasNonTransientBlocker = true; } // Surface candidate @@ -1193,6 +1234,16 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa if ((isSurface || isImpassable) && !(canSwim && isWet)) { openSlots &= ~CreateBlockerMask(item.Z - 16, itemTop, minZ); + + // Movable items are transient, non-movable are permanent + if (item.Movable || item.CanDecay()) + { + hasTransientBlocker = true; + } + else + { + hasNonTransientBlocker = true; + } } // Surface candidate (non-movable only) @@ -1203,7 +1254,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa } } - // 4. Mobiles (blockers only) + // 4. Mobiles (always transient blockers) foreach (var m in sector.Mobiles) { if (m.Location.m_X == x && m.Location.m_Y == y && @@ -1211,6 +1262,7 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa { // Mobiles block z in range (m.Z - 16, m.Z + 16) openSlots &= ~CreateBlockerMask(m.Z - 16, m.Z + 16, minZ); + hasTransientBlocker = true; } } @@ -1218,12 +1270,25 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa var validSurfaces = surfaces & openSlots; if (validSurfaces == 0) { + // Determine failure reason based on what blockers we encountered + // If no surfaces existed at all, that's also a non-transient issue (map geometry) + if (hasNonTransientBlocker || surfaces == 0) + { + failureReason |= SpawnFailureReason.NonTransientBlocker; + } + + if (hasTransientBlocker) + { + failureReason |= SpawnFailureReason.TransientBlocker; + } + return false; } // TrailingZeroCount gives the position of the lowest set bit var lowestBit = BitOperations.TrailingZeroCount(validSurfaces); spawnZ = minZ + lowestBit; + return true; } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs new file mode 100644 index 000000000..7f3666fbc --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs @@ -0,0 +1,575 @@ +using System.Collections.Generic; +using Server; +using Server.Engines.Spawners; +using Xunit; + +namespace UOContent.Tests; + +public class SectorSpawnCacheTests +{ + [Fact] + public void SectorSpawnCache_InitialState_AllBitsZero() + { + var cache = new SectorSpawnCache(); + + Assert.Equal(0, cache.GetCount()); + } + + [Theory] + [InlineData(0)] + [InlineData(63)] + [InlineData(64)] + [InlineData(127)] + [InlineData(128)] + [InlineData(191)] + [InlineData(192)] + [InlineData(255)] + public void SectorSpawnCache_SetBit_SetsCorrectBit(int bitIndex) + { + var cache = new SectorSpawnCache(); + + cache.SetBit(bitIndex); + + Assert.True(cache.GetBit(bitIndex)); + Assert.Equal(1, cache.GetCount()); + } + + [Fact] + public void SectorSpawnCache_SetMultipleBits_CountsCorrectly() + { + var cache = new SectorSpawnCache(); + + cache.SetBit(0); + cache.SetBit(64); + cache.SetBit(128); + cache.SetBit(192); + + Assert.Equal(4, cache.GetCount()); + Assert.True(cache.GetBit(0)); + Assert.True(cache.GetBit(64)); + Assert.True(cache.GetBit(128)); + Assert.True(cache.GetBit(192)); + Assert.False(cache.GetBit(1)); + } + + [Fact] + public void SectorSpawnCache_ClearBit_ClearsCorrectBit() + { + var cache = new SectorSpawnCache(); + + cache.SetBit(50); + cache.SetBit(100); + Assert.Equal(2, cache.GetCount()); + + cache.ClearBit(50); + + Assert.False(cache.GetBit(50)); + Assert.True(cache.GetBit(100)); + Assert.Equal(1, cache.GetCount()); + } + + [Theory] + [InlineData(0, 0)] // First bit of first ulong + [InlineData(1, 64)] // First bit of second ulong + [InlineData(2, 128)] // First bit of third ulong + [InlineData(3, 192)] // First bit of fourth ulong + public void SectorSpawnCache_GetNthBitPosition_FirstBitInEachUlong(int n, int expectedPosition) + { + var cache = new SectorSpawnCache(); + + // Set first bit in each ulong + cache.SetBit(0); + cache.SetBit(64); + cache.SetBit(128); + cache.SetBit(192); + + Assert.Equal(expectedPosition, cache.GetNthBitPosition(n)); + } + + [Fact] + public void SectorSpawnCache_GetNthBitPosition_WithinSingleUlong() + { + var cache = new SectorSpawnCache(); + + cache.SetBit(5); + cache.SetBit(10); + cache.SetBit(20); + + Assert.Equal(5, cache.GetNthBitPosition(0)); + Assert.Equal(10, cache.GetNthBitPosition(1)); + Assert.Equal(20, cache.GetNthBitPosition(2)); + } +} + +[Collection("Sequential UOContent Tests")] +public class SectorSpawnCacheManagerTests +{ + public SectorSpawnCacheManagerTests() + { + // Clear cache before each test + SectorSpawnCacheManager.ClearAll(); + } + + [Fact] + public void ClearAll_ResetsCache() + { + SectorSpawnCacheManager.ClearAll(); + Assert.Equal(0, SectorSpawnCacheManager.CachedSectorCount); + Assert.Equal(0, SectorSpawnCacheManager.LandCacheCount); + Assert.Equal(0, SectorSpawnCacheManager.WaterCacheCount); + } + + [Fact] + public void SetValid_CreatesSectorCache() + { + var map = Map.Felucca; + var pos = new Point3D(100, 100, 0); + + SectorSpawnCacheManager.SetValid(map, pos, isWater: false); + + Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); + Assert.Equal(1, SectorSpawnCacheManager.LandCacheCount); + Assert.Equal(0, SectorSpawnCacheManager.WaterCacheCount); + } + + [Fact] + public void SetValid_Water_CreatesWaterCache() + { + var map = Map.Felucca; + var pos = new Point3D(100, 100, 0); + + SectorSpawnCacheManager.SetValid(map, pos, isWater: true); + + Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); + Assert.Equal(0, SectorSpawnCacheManager.LandCacheCount); + Assert.Equal(1, SectorSpawnCacheManager.WaterCacheCount); + } + + [Fact] + public void SetValid_LandAndWater_SeparateCaches() + { + var map = Map.Felucca; + + // Same sector, different cache types + SectorSpawnCacheManager.SetValid(map, new Point3D(100, 100, 0), isWater: false); + SectorSpawnCacheManager.SetValid(map, new Point3D(105, 105, 0), isWater: true); + + Assert.Equal(2, SectorSpawnCacheManager.CachedSectorCount); + Assert.Equal(1, SectorSpawnCacheManager.LandCacheCount); + Assert.Equal(1, SectorSpawnCacheManager.WaterCacheCount); + } + + [Fact] + public void SetValid_MultipleSameSector_OnlyOneCacheEntry() + { + var map = Map.Felucca; + + // All positions in same 16x16 sector (sector 6,6 for coords 96-111) + SectorSpawnCacheManager.SetValid(map, new Point3D(96, 96, 0), isWater: false); + SectorSpawnCacheManager.SetValid(map, new Point3D(100, 100, 0), isWater: false); + SectorSpawnCacheManager.SetValid(map, new Point3D(111, 111, 0), isWater: false); + + Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); + } + + [Fact] + public void SetValid_DifferentSectors_MultipleCacheEntries() + { + var map = Map.Felucca; + + // Different sectors + SectorSpawnCacheManager.SetValid(map, new Point3D(0, 0, 0), isWater: false); // Sector 0,0 + SectorSpawnCacheManager.SetValid(map, new Point3D(16, 0, 0), isWater: false); // Sector 1,0 + SectorSpawnCacheManager.SetValid(map, new Point3D(0, 16, 0), isWater: false); // Sector 0,1 + + Assert.Equal(3, SectorSpawnCacheManager.CachedSectorCount); + } + + [Fact] + public void TryGetRandomPosition_EmptyCache_ReturnsFalse() + { + var map = Map.Felucca; + var bounds = new Rectangle3D(0, 0, 0, 100, 100, 20); + + var result = SectorSpawnCacheManager.TryGetRandomPosition(map, bounds, isWater: false, out _); + + Assert.False(result); + } + + [Fact] + public void TryGetRandomPosition_WithCachedPosition_ReturnsTrue() + { + var map = Map.Felucca; + var cachedPos = new Point3D(50, 50, 0); + var bounds = new Rectangle3D(0, 0, -128, 100, 100, 256); + + SectorSpawnCacheManager.SetValid(map, cachedPos, isWater: false); + + var result = SectorSpawnCacheManager.TryGetRandomPosition(map, bounds, isWater: false, out var pos); + + Assert.True(result); + Assert.Equal(cachedPos.X, pos.X); + Assert.Equal(cachedPos.Y, pos.Y); + } + + [Fact] + public void TryGetRandomPosition_WaterVsLand_Separated() + { + var map = Map.Felucca; + + var landPos = new Point3D(50, 50, 0); + var waterPos = new Point3D(60, 60, 0); + var bounds = new Rectangle3D(0, 0, -128, 100, 100, 256); + + SectorSpawnCacheManager.SetValid(map, landPos, isWater: false); + SectorSpawnCacheManager.SetValid(map, waterPos, isWater: true); + + // Request land position + var landResult = SectorSpawnCacheManager.TryGetRandomPosition(map, bounds, isWater: false, out var pos1); + Assert.True(landResult); + Assert.Equal(landPos.X, pos1.X); + Assert.Equal(landPos.Y, pos1.Y); + + // Request water position + var waterResult = SectorSpawnCacheManager.TryGetRandomPosition(map, bounds, isWater: true, out var pos2); + Assert.True(waterResult); + Assert.Equal(waterPos.X, pos2.X); + Assert.Equal(waterPos.Y, pos2.Y); + } + + [Fact] + public void TryGetRandomPosition_OutsideBounds_ReturnsFalse() + { + var map = Map.Felucca; + var cachedPos = new Point3D(200, 200, 0); + var bounds = new Rectangle3D(0, 0, -128, 100, 100, 256); // Only covers 0-99 + + SectorSpawnCacheManager.SetValid(map, cachedPos, isWater: false); + + var result = SectorSpawnCacheManager.TryGetRandomPosition(map, bounds, isWater: false, out _); + + Assert.False(result); + } + + [Fact] + public void InvalidateSectors_RemovesCachedData() + { + var map = Map.Felucca; + + SectorSpawnCacheManager.SetValid(map, new Point3D(50, 50, 0), isWater: false); + Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); + + SectorSpawnCacheManager.InvalidateSectors(map, new Rectangle2D(0, 0, 100, 100)); + + Assert.Equal(0, SectorSpawnCacheManager.CachedSectorCount); + } + + [Fact] + public void InvalidateSectors_RemovesBothLandAndWater() + { + var map = Map.Felucca; + + // Same sector, both land and water + SectorSpawnCacheManager.SetValid(map, new Point3D(50, 50, 0), isWater: false); + SectorSpawnCacheManager.SetValid(map, new Point3D(55, 55, 0), isWater: true); + Assert.Equal(2, SectorSpawnCacheManager.CachedSectorCount); + + SectorSpawnCacheManager.InvalidateSectors(map, new Rectangle2D(0, 0, 100, 100)); + + Assert.Equal(0, SectorSpawnCacheManager.CachedSectorCount); + Assert.Equal(0, SectorSpawnCacheManager.LandCacheCount); + Assert.Equal(0, SectorSpawnCacheManager.WaterCacheCount); + } + + [Fact] + public void InvalidateSectors_OnlyAffectsSpecifiedArea() + { + var map = Map.Felucca; + + // Cache in two different areas + SectorSpawnCacheManager.SetValid(map, new Point3D(50, 50, 0), isWater: false); // Sector 3,3 + SectorSpawnCacheManager.SetValid(map, new Point3D(500, 500, 0), isWater: false); // Sector 31,31 + + Assert.Equal(2, SectorSpawnCacheManager.CachedSectorCount); + + // Only invalidate first area + SectorSpawnCacheManager.InvalidateSectors(map, new Rectangle2D(0, 0, 100, 100)); + + Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); + } + + [Fact] + public void InvalidateSectors_DifferentMaps_Independent() + { + var map1 = Map.Felucca; + var map2 = Map.Internal; + + SectorSpawnCacheManager.SetValid(map1, new Point3D(50, 50, 0), isWater: false); + SectorSpawnCacheManager.SetValid(map2, new Point3D(50, 50, 0), isWater: false); + + Assert.Equal(2, SectorSpawnCacheManager.CachedSectorCount); + + // Only invalidate first map + SectorSpawnCacheManager.InvalidateSectors(map1, new Rectangle2D(0, 0, 100, 100)); + + Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); + } +} + +[Collection("SectorSpawnCache")] +public class SpiralScanTests +{ + public SpiralScanTests() + { + SectorSpawnCacheManager.ClearAll(); + } + + [Theory] + [InlineData(1, 0, -1, -1)] // Ring 1, position 0: top-left + [InlineData(1, 1, 0, -1)] // Ring 1, position 1: top + [InlineData(1, 2, 1, -1)] // Ring 1, position 2: top-right (end of top edge) + [InlineData(1, 3, 1, 0)] // Ring 1, position 3: right + [InlineData(1, 4, 1, 1)] // Ring 1, position 4: bottom-right (end of right edge) + [InlineData(1, 5, 0, 1)] // Ring 1, position 5: bottom + [InlineData(1, 6, -1, 1)] // Ring 1, position 6: bottom-left (end of bottom edge) + [InlineData(1, 7, -1, 0)] // Ring 1, position 7: left + public void GetSpiralOffset_Ring1_CorrectOffsets(int ring, int position, int expectedDx, int expectedDy) + { + var (dx, dy) = SectorSpawnCacheManager.GetSpiralOffset(ring, position); + + Assert.Equal(expectedDx, dx); + Assert.Equal(expectedDy, dy); + } + + [Fact] + public void SpiralPattern_Ring1Has8Positions() + { + // Ring N has 8*N positions + const int ring1Positions = 1 * 8; + Assert.Equal(8, ring1Positions); + } + + [Fact] + public void SpiralPattern_Ring2Has16Positions() + { + const int ring2Positions = 2 * 8; + Assert.Equal(16, ring2Positions); + } + + [Fact] + public void SpiralPattern_Ring10Has80Positions() + { + const int ring10Positions = 10 * 8; + Assert.Equal(80, ring10Positions); + } + + [Fact] + public void SpiralPattern_Ring1_CoversAllAdjacentTiles() + { + // Ring 1 should cover all 8 adjacent tiles + var expectedOffsets = new HashSet<(int dx, int dy)> + { + (-1, -1), (0, -1), (1, -1), // Top row + (1, 0), // Right + (1, 1), (0, 1), (-1, 1), // Bottom row + (-1, 0) // Left + }; + + var actualOffsets = new HashSet<(int dx, int dy)>(); + + for (var position = 0; position < 8; position++) + { + actualOffsets.Add(SectorSpawnCacheManager.GetSpiralOffset(1, position)); + } + + Assert.Equal(expectedOffsets.Count, actualOffsets.Count); + foreach (var expected in expectedOffsets) + { + Assert.Contains(expected, actualOffsets); + } + } + + [Fact] + public void SpiralPattern_Ring2_CoversAllExpectedTiles() + { + // Ring 2 should cover all tiles at distance 2 + var actualOffsets = new HashSet<(int dx, int dy)>(); + + for (var position = 0; position < 16; position++) + { + actualOffsets.Add(SectorSpawnCacheManager.GetSpiralOffset(2, position)); + } + + // Should have 16 unique positions + Assert.Equal(16, actualOffsets.Count); + + // All positions should be at Chebyshev distance 2 + foreach (var (dx, dy) in actualOffsets) + { + var chebyshevDist = System.Math.Max(System.Math.Abs(dx), System.Math.Abs(dy)); + Assert.Equal(2, chebyshevDist); + } + } +} + +public class SpawnPositionStateTests +{ + [Fact] + public void SpawnPositionState_InitialState_ZeroCounts() + { + var state = new SpawnPositionState(); + + Assert.False(state.SpiralComplete); + Assert.Equal(0, state.SpiralRing); + Assert.Equal(0, state.SpiralRingPosition); + } + + [Fact] + public void Reset_ClearsAllState() + { + var state = new SpawnPositionState(); + state.SpiralRing = 5; + state.SpiralRingPosition = 10; + state.SpiralComplete = true; + state.RecordNonTransientFailure(); + + state.Reset(); + + Assert.False(state.SpiralComplete); + Assert.Equal(0, state.SpiralRing); + Assert.Equal(0, state.SpiralRingPosition); + } + + [Fact] + public void ShouldCachePositions_Enabled_AlwaysTrue() + { + var state = new SpawnPositionState(); + + Assert.True(state.ShouldCachePositions(SpawnPositionMode.Enabled)); + } + + [Fact] + public void ShouldCachePositions_Disabled_AlwaysFalse() + { + var state = new SpawnPositionState(); + state.RecordNonTransientFailure(); + + Assert.False(state.ShouldCachePositions(SpawnPositionMode.Disabled)); + } + + [Fact] + public void ShouldCachePositions_Automatic_FalseWithNoFailures() + { + var state = new SpawnPositionState(); + + Assert.False(state.ShouldCachePositions(SpawnPositionMode.Automatic)); + } + + [Fact] + public void ShouldCachePositions_Automatic_TrueAfterThresholdFailures() + { + var state = new SpawnPositionState(); + + // FailureThreshold is 5, so need more than 5 failures + for (var i = 0; i < 6; i++) + { + state.RecordNonTransientFailure(); + } + + Assert.True(state.ShouldCachePositions(SpawnPositionMode.Automatic)); + } + + [Fact] + public void ShouldCachePositions_Automatic_FalseWithFewerThanThresholdFailures() + { + var state = new SpawnPositionState(); + + // FailureThreshold is 5, so 5 or fewer failures should not trigger caching + for (var i = 0; i < 5; i++) + { + state.RecordNonTransientFailure(); + } + + Assert.False(state.ShouldCachePositions(SpawnPositionMode.Automatic)); + } + + [Fact] + public void ShouldAbandon_FalseWhenSpiralNotComplete() + { + var state = new SpawnPositionState(); + + // Record 25 useless results + for (var i = 0; i < 25; i++) + { + state.RecordUselessResult(); + } + + Assert.False(state.ShouldAbandon()); // Spiral not complete + } + + [Fact] + public void ShouldAbandon_TrueAfterThresholdUselessResults() + { + var state = new SpawnPositionState(); + state.SpiralComplete = true; + + // Record 25 useless results (cache miss or Location-only) + for (var i = 0; i < 25; i++) + { + state.RecordUselessResult(); + } + + Assert.True(state.ShouldAbandon()); + } + + [Fact] + public void ShouldAbandon_FalseWithUsefulCacheHit() + { + var state = new SpawnPositionState(); + state.SpiralComplete = true; + + // Record 24 useless results + for (var i = 0; i < 24; i++) + { + state.RecordUselessResult(); + } + + // Record a useful cache hit - resets counter + state.RecordUsefulCacheHit(); + + // Record a few more useless results + for (var i = 0; i < 5; i++) + { + state.RecordUselessResult(); + } + + Assert.False(state.ShouldAbandon()); // Counter was reset, only 5 now + } + + [Fact] + public void RecordUsefulCacheHit_ResetsUselessCounter() + { + var state = new SpawnPositionState(); + state.SpiralComplete = true; + + // Record 20 useless results + for (var i = 0; i < 20; i++) + { + state.RecordUselessResult(); + } + + // Useful cache hit resets counter + state.RecordUsefulCacheHit(); + + // Need 25 more to trigger abandon + for (var i = 0; i < 24; i++) + { + state.RecordUselessResult(); + } + + Assert.False(state.ShouldAbandon()); // Only 24 after reset + + state.RecordUselessResult(); // 25th + Assert.True(state.ShouldAbandon()); + } +} diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index dca5343eb..35cdbedce 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -10,6 +10,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Projects/UOContent/Commands/Generic/Commands/Commands.cs b/Projects/UOContent/Commands/Generic/Commands/Commands.cs index c6480db9f..865699db1 100644 --- a/Projects/UOContent/Commands/Generic/Commands/Commands.cs +++ b/Projects/UOContent/Commands/Generic/Commands/Commands.cs @@ -1199,7 +1199,7 @@ namespace Server.Commands.Generic { CommandLogging.WriteLine( from, - $"{from.AccessLevel} {CommandLogging.Format(from)} {(m_Ban ? "banning" : "kicking")} {(CommandLogging.Format(targ))}" + $"{from.AccessLevel} {CommandLogging.Format(from)} {(m_Ban ? "banning" : "kicking")} {CommandLogging.Format(targ)}" ); targ.Say(m_Ban ? "I've been banned." : "I've been kicked"); diff --git a/Projects/UOContent/Commands/Handlers.cs b/Projects/UOContent/Commands/Handlers.cs index c790948b9..e98ab1084 100644 --- a/Projects/UOContent/Commands/Handlers.cs +++ b/Projects/UOContent/Commands/Handlers.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Text; using Server.Commands.Generic; diff --git a/Projects/UOContent/Commands/VisibilityList.cs b/Projects/UOContent/Commands/VisibilityList.cs index b9c448abc..3e4a0dc2a 100644 --- a/Projects/UOContent/Commands/VisibilityList.cs +++ b/Projects/UOContent/Commands/VisibilityList.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using ModernUO.CodeGeneratedEvents; using Server.Mobiles; diff --git a/Projects/UOContent/Engines/Plants/PlantSystem.cs b/Projects/UOContent/Engines/Plants/PlantSystem.cs index c21f53e0e..29b9f48be 100644 --- a/Projects/UOContent/Engines/Plants/PlantSystem.cs +++ b/Projects/UOContent/Engines/Plants/PlantSystem.cs @@ -1,7 +1,6 @@ using System; using ModernUO.CodeGeneratedEvents; using ModernUO.Serialization; -using Server.Items; using Server.Misc; using Server.Mobiles; diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs index 4fba474d5..9c5c4f002 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs @@ -31,6 +31,38 @@ public abstract partial class BaseSpawner } _spawnLocationIsHome = false; + + // New v12 fields default to automatic/default + _spawnPositionMode = SpawnPositionMode.Automatic; + _maxSpawnAttempts = DefaultMaxSpawnAttempts; + } + + private void MigrateFrom(V11Content content) + { + _guid = content.Guid; + _returnOnDeactivate = content.ReturnOnDeactivate; + _entries = content.Entries; + _walkingRange = content.WalkingRange; + _wayPoint = content.WayPoint; + _group = content.Group; + _minDelay = content.MinDelay; + _maxDelay = content.MaxDelay; + _count = content.Count; + _team = content.Team; + + // Moved to Spawner + if (this is Spawner spawner) + { + spawner.SpawnBounds = content.SpawnBounds; + } + + _running = content.Running; + _spawnLocationIsHome = content.SpawnLocationIsHome; + _end = _running ? content.End : Core.Now; + + // New v12 fields default to automatic/default + _spawnPositionMode = SpawnPositionMode.Automatic; + _maxSpawnAttempts = DefaultMaxSpawnAttempts; } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 64138e6df..aa90a1de9 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -7,19 +7,58 @@ using Server.Commands; using Server.Gumps; using Server.Items; using Server.Json; +using Server.Logging; using Server.Mobiles; using static Server.Attributes; namespace Server.Engines.Spawners; -[SerializationGenerator(11, false)] +/// +/// Controls how a spawner handles spawn position optimization. +/// +public enum SpawnPositionMode : byte +{ + /// + /// Auto-detect if optimization is needed based on failure patterns. + /// Only engages lazy caching after non-transient spawn failures. + /// + Automatic = 0, + + /// + /// Force optimization on. Always cache successful spawn positions. + /// + Enabled = 1, + + /// + /// Force optimization off. Use only random position attempts. + /// + Disabled = 2, + + /// + /// Spawner has given up due to 100% failure rate. + /// Skips all spawn position logic and returns spawner location. + /// Admin can reset via [props. + /// + Abandoned = 3 +} + +[SerializationGenerator(12, false)] public abstract partial class BaseSpawner : Item, ISpawner { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseSpawner)); + + // Default values for serialization optimization + private static readonly TimeSpan DefaultMinDelay = TimeSpan.FromMinutes(5); + private static readonly TimeSpan DefaultMaxDelay = TimeSpan.FromMinutes(10); + [SerializedIgnoreDupe] [SerializableField(0)] [SerializedCommandProperty(AccessLevel.Developer)] private Guid _guid; + [SerializableFieldSaveFlag(1)] + private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate; + [SerializableField(1)] [SerializedCommandProperty(AccessLevel.Developer)] private bool _returnOnDeactivate; @@ -30,48 +69,105 @@ public abstract partial class BaseSpawner : Item, ISpawner private int _walkingRange = -1; + [SerializableFieldSaveFlag(4)] + private bool ShouldSerializeWayPoint() => _wayPoint != null; + [SerializableField(4)] [SerializedCommandProperty(AccessLevel.Developer)] private WayPoint _wayPoint; + [SerializableFieldSaveFlag(5)] + private bool ShouldSerializeGroup() => _group; + [InvalidateProperties] [SerializableField(5)] [SerializedCommandProperty(AccessLevel.Developer)] private bool _group; + [SerializableFieldSaveFlag(6)] + private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay; + + [SerializableFieldDefault(6)] + private TimeSpan MinDelayDefault() => DefaultMinDelay; + [InvalidateProperties] [SerializableField(6)] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _minDelay; + [SerializableFieldSaveFlag(7)] + private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay; + + [SerializableFieldDefault(7)] + private TimeSpan MaxDelayDefault() => DefaultMaxDelay; + [InvalidateProperties] [SerializableField(7)] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _maxDelay; + [SerializableFieldSaveFlag(9)] + private bool ShouldSerializeTeam() => _team != 0; + [InvalidateProperties] [SerializableField(9)] [SerializedCommandProperty(AccessLevel.Developer)] private int _team; - [InvalidateProperties] - [SerializableField(10)] - [SerializedCommandProperty(AccessLevel.Developer)] - private Rectangle3D _spawnBounds; + /// + /// The spawn bounds for this spawner. Abstract to allow derived classes to manage their own storage. + /// + [CommandProperty(AccessLevel.Developer)] + public abstract Rectangle3D SpawnBounds { get; set; } /// /// If true, the home location of the spawn is the location where it spawned /// If false, the home location of the spawn is the location of the spawner /// + [SerializableFieldSaveFlag(11)] + private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome; + [InvalidateProperties] - [SerializableField(12)] + [SerializableField(11)] [SerializedCommandProperty(AccessLevel.Developer)] private bool _spawnLocationIsHome; - [SerializableField(13)] + [SerializableFieldSaveFlag(12)] + private bool ShouldSerializeEnd() => _end != default; + + [SerializableField(12)] [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; + /// + /// Controls how spawn position optimization is handled. + /// + [SerializableFieldSaveFlag(13)] + private bool ShouldSerializeSpawnPositionMode() => + _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned; + + [SerializableField(13)] + [SerializedCommandProperty(AccessLevel.Developer)] + private SpawnPositionMode _spawnPositionMode; + + private const int DefaultMaxSpawnAttempts = 10; + + /// + /// Maximum number of random position attempts before engaging optimization. + /// + [SerializableFieldSaveFlag(14)] + private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts; + + [SerializableFieldDefault(14)] + private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts; + + [SerializableField(14)] + [SerializedCommandProperty(AccessLevel.Developer)] + private int _maxSpawnAttempts; + + // Non-serialized: Runtime state for spawn position optimization + private SpawnPositionState _spawnPositionState; + private InternalTimer _timer; /// @@ -83,16 +179,16 @@ public abstract partial class BaseSpawner : Item, ISpawner { get { - if (_spawnBounds == default) + if (SpawnBounds == default) { return 0; } // Distance from spawner location to nearest edge - var distToMinX = Math.Abs(Location.X - _spawnBounds.Start.X); - var distToMaxX = Math.Abs(_spawnBounds.End.X - Location.X); - var distToMinY = Math.Abs(Location.Y - _spawnBounds.Start.Y); - var distToMaxY = Math.Abs(_spawnBounds.End.Y - Location.Y); + var distToMinX = Math.Abs(Location.X - SpawnBounds.Start.X); + var distToMaxX = Math.Abs(SpawnBounds.End.X - Location.X); + var distToMinY = Math.Abs(Location.Y - SpawnBounds.Start.Y); + var distToMaxY = Math.Abs(SpawnBounds.End.Y - Location.Y); // Return smallest distance to any edge return Math.Min(Math.Min(distToMinX, distToMaxX), Math.Min(distToMinY, distToMaxY)); @@ -105,7 +201,7 @@ public abstract partial class BaseSpawner : Item, ISpawner : Location.Z; // Create square bounds centered on spawner, Z range from surface to surface + 16 - _spawnBounds = new Rectangle3D( + SpawnBounds = new Rectangle3D( Location.X - value, Location.Y - value, surfaceZ, @@ -128,20 +224,20 @@ public abstract partial class BaseSpawner : Item, ISpawner /// public bool IsHomeRangeStyleAt(Point3D location) { - if (_spawnBounds == default) + if (SpawnBounds == default) { return true; // No bounds = default HomeRange behavior } // Must be square - if (_spawnBounds.Width != _spawnBounds.Height) + if (SpawnBounds.Width != SpawnBounds.Height) { return false; } // Given location must be at center - var centerX = _spawnBounds.Start.X + _spawnBounds.Width / 2; - var centerY = _spawnBounds.Start.Y + _spawnBounds.Height / 2; + var centerX = SpawnBounds.Start.X + SpawnBounds.Width / 2; + var centerY = SpawnBounds.Start.Y + SpawnBounds.Height / 2; return centerX == location.X && centerY == location.Y; } @@ -152,7 +248,7 @@ public abstract partial class BaseSpawner : Item, ISpawner /// public virtual bool IsInSpawnBounds(Point3D location) { - return _spawnBounds == default || _spawnBounds.Contains(location); + return SpawnBounds == default || SpawnBounds.Contains(location); } public BaseSpawner() : this(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)) @@ -198,23 +294,18 @@ public abstract partial class BaseSpawner : Item, ISpawner } json.GetProperty("count", options, out int amount); - json.GetProperty("minDelay", options, out TimeSpan minDelay); - json.GetProperty("maxDelay", options, out TimeSpan maxDelay); + json.GetProperty("minDelay", options, DefaultMinDelay, out TimeSpan minDelay); + json.GetProperty("maxDelay", options, DefaultMaxDelay, out TimeSpan maxDelay); json.GetProperty("team", options, out int team); json.GetProperty("homeRange", options, out int homeRange); - json.GetProperty("walkingRange", options, out int walkingRange); - _walkingRange = walkingRange; + json.GetProperty("walkingRange", options, out _walkingRange); - // Try new format first - if (json.GetProperty("spawnBounds", options, out Rectangle3D spawnBounds)) - { - _spawnBounds = spawnBounds; - } - else if (homeRange > 0 && json.GetProperty("location", options, out Point3D location)) + // Handle legacy homeRange format (new spawnBounds format handled by derived classes) + if (homeRange > 0 && json.GetProperty("location", options, out Point3D location)) { // Fall back to homeRange with location for oldest format // Note: Map not available during JSON loading, so use location.Z directly - _spawnBounds = new Rectangle3D( + SpawnBounds = new Rectangle3D( location.X - homeRange, location.Y - homeRange, location.Z, @@ -224,10 +315,11 @@ public abstract partial class BaseSpawner : Item, ISpawner ); } - json.GetProperty("spawnLocationIsHome", options, out bool spawnLocationIsHome); - _spawnLocationIsHome = spawnLocationIsHome; + json.GetProperty("spawnLocationIsHome", options, out _spawnLocationIsHome); + json.GetProperty("spawnPositionMode", options, out _spawnPositionMode); + json.GetProperty("maxSpawnAttempts", options, DefaultMaxSpawnAttempts, out _maxSpawnAttempts); - InitSpawn(amount, minDelay, maxDelay, team, _spawnBounds); + InitSpawn(amount, minDelay, maxDelay, team, SpawnBounds); json.GetProperty("entries", options, out List entries); @@ -279,7 +371,7 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableProperty(11)] + [SerializableProperty(10)] [CommandProperty(AccessLevel.Developer)] public bool Running { @@ -319,6 +411,29 @@ public abstract partial class BaseSpawner : Item, ISpawner public abstract Region Region { get; } + /// + /// Returns the bounds to use for a single spawn attempt. + /// Called for each random attempt in Phase 1. + /// Spawner: returns SpawnBounds + /// RegionSpawner: picks a weighted random rectangle from the region + /// + protected abstract Rectangle3D GetBoundsForSpawnAttempt(); + + /// + /// Returns all possible spawn bounds for cache operations. + /// Used in Phase 3 to search for cached positions. + /// Spawner: returns single-element array with SpawnBounds + /// RegionSpawner: returns all region rectangles + /// + protected abstract ReadOnlySpan GetAllSpawnBounds(); + + /// + /// Whether this spawner supports spiral scanning. + /// Only makes sense for contiguous bounds (Spawner). + /// Disjoint rectangles (RegionSpawner) should return false. + /// + protected virtual bool SupportsSpiralScan => false; + public void Remove(ISpawnable spawn) { Defrag(); @@ -356,21 +471,260 @@ public abstract partial class BaseSpawner : Item, ISpawner public virtual void ToJson(DynamicJson json, JsonSerializerOptions options) { json.Type = GetType().Name; - json.SetProperty("name", options, Name); + + // Always required json.SetProperty("guid", options, _guid); json.SetProperty("location", options, Location); json.SetProperty("map", options, Map); json.SetProperty("count", options, Count); - json.SetProperty("minDelay", options, MinDelay); - json.SetProperty("maxDelay", options, MaxDelay); - json.SetProperty("team", options, Team); - json.SetProperty("walkingRange", options, WalkingRange); json.SetProperty("entries", options, Entries); - json.SetProperty("spawnBounds", options, SpawnBounds); - json.SetProperty("spawnLocationIsHome", options, SpawnLocationIsHome); + + // Only write if non-default + if (!string.IsNullOrEmpty(Name)) + { + json.SetProperty("name", options, Name); + } + + if (_minDelay != DefaultMinDelay) + { + json.SetProperty("minDelay", options, MinDelay); + } + + if (_maxDelay != DefaultMaxDelay) + { + json.SetProperty("maxDelay", options, MaxDelay); + } + + if (_team != 0) + { + json.SetProperty("team", options, Team); + } + + if (_walkingRange != 0) + { + json.SetProperty("walkingRange", options, WalkingRange); + } + + if (_spawnLocationIsHome) + { + json.SetProperty("spawnLocationIsHome", options, SpawnLocationIsHome); + } + + if (_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned) + { + json.SetProperty("spawnPositionMode", options, _spawnPositionMode); + } + + if (_maxSpawnAttempts != DefaultMaxSpawnAttempts) + { + json.SetProperty("maxSpawnAttempts", options, _maxSpawnAttempts); + } } - public abstract Point3D GetSpawnPosition(ISpawnable spawned, Map map); + public virtual Point3D GetSpawnPosition(ISpawnable spawned, Map map) + { + // Abandoned spawners skip all work + if (map == null || map == Map.Internal || _spawnPositionMode == SpawnPositionMode.Abandoned) + { + return Location; + } + + // Ensure state is initialized + _spawnPositionState ??= new SpawnPositionState(); + + // Determine mob type for caching + var isMobile = spawned is Mobile; + var canSwim = isMobile && ((Mobile)spawned).CanSwim; + var cantWalk = isMobile && ((Mobile)spawned).CantWalk; + var isWaterMob = canSwim && cantWalk; + var hasNonTransientFailure = false; + + // Phase 1: Random attempts (always first - maintains randomness) + var maxAttempts = _maxSpawnAttempts > 0 ? _maxSpawnAttempts : DefaultMaxSpawnAttempts; + for (var i = 0; i < maxAttempts; i++) + { + var bounds = GetBoundsForSpawnAttempt(); + + // No bounds = spawn at spawner location + if (bounds == default) + { + return Location; + } + + var x = Utility.RandomMinMax(bounds.Start.X, bounds.End.X - 1); + var y = Utility.RandomMinMax(bounds.Start.Y, bounds.End.Y - 1); + var minZ = bounds.Start.Z; + var maxZ = bounds.End.Z - 1; + + bool success; + int spawnZ; + SpawnFailureReason failureReason; + + if (isMobile) + { + success = map.CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out spawnZ, out failureReason); + } + else + { + success = map.CanSpawnItem(x, y, minZ, maxZ, out spawnZ); + failureReason = success ? SpawnFailureReason.None : SpawnFailureReason.NonTransientBlocker; + } + + if (success) + { + // Check if blocked by a private house (non-transient) + if (isMobile && SectorSpawnCacheManager.IsBlockedByHouse(map, x, y, spawnZ)) + { + hasNonTransientFailure = true; + } + else + { + // Lazy cache: add successful position to global sector cache + if (_spawnPositionState.ShouldCachePositions(_spawnPositionMode)) + { + SectorSpawnCacheManager.SetValid(map, new Point3D(x, y, spawnZ), isWaterMob); + } + + return new Point3D(x, y, spawnZ); + } + } + else if ((failureReason & SpawnFailureReason.NonTransientBlocker) != 0) + { + hasNonTransientFailure = true; + } + } + + // Phase 2: Check if optimization should engage + if (_spawnPositionMode == SpawnPositionMode.Disabled) + { + return Location; + } + + var useOptimization = _spawnPositionMode == SpawnPositionMode.Enabled + || _spawnPositionMode == SpawnPositionMode.Automatic && hasNonTransientFailure; + + if (!useOptimization) + { + // Transient failure only - just use spawner location + return Location; + } + + _spawnPositionState.RecordNonTransientFailure(); + + var allBounds = GetAllSpawnBounds(); + + // Phase 3: Continue spiral scan to populate cache (before selecting from it) + if (!SupportsSpiralScan) + { + // Mark spiral complete for non-spiral spawners so ShouldAbandon() can trigger + _spawnPositionState.SpiralComplete = true; + } + else if (!_spawnPositionState.SpiralComplete) + { + // Use the first bounds for spiral center/range + var primaryBounds = allBounds.Length > 0 ? allBounds[0] : default; + if (primaryBounds != default) + { + var minZ = primaryBounds.Start.Z; + var maxZ = primaryBounds.End.Z - 1; + + // Scan more rings initially (3), fewer once cache has positions (1) + var ringsPerTick = _spawnPositionState.SpiralRing == 0 ? 3 : 1; + + _spawnPositionState.SpiralComplete = SectorSpawnCacheManager.ContinueSpiralScan( + map, + Location, + primaryBounds, + minZ, + maxZ, + canSwim, + cantWalk, + ref _spawnPositionState.SpiralRing, + ref _spawnPositionState.SpiralRingPosition, + ringsPerTick + ); + } + } + + // Phase 4: Try cached positions from global sector cache (uses deduplicated sectors) + if (TryGetVerifiedCachedPosition(map, allBounds, isMobile, isWaterMob, canSwim, cantWalk, out var spawnPos)) + { + // Check if cache returned a useful position (not just spawner's own location) + if (spawnPos != Location) + { + _spawnPositionState.RecordUsefulCacheHit(); + return spawnPos; + } + } + + // Cache miss or returned Location only + _spawnPositionState.RecordUselessResult(); + + // Phase 5: Check for abandoned state (only auto-abandon from Automatic mode) + if (_spawnPositionMode == SpawnPositionMode.Automatic && _spawnPositionState.ShouldAbandon()) + { + SpawnPositionMode = SpawnPositionMode.Abandoned; + _spawnPositionState = null; + logger.Warning( + "Spawner {Serial} at {Location} ({Map}) marked abandoned - no valid spawn positions found after spiral scan.", + Serial, + Location, + map.Name ?? "null" + ); + } + + return Location; + } + + /// + /// Attempts to get a verified spawn position from the sector cache across all bounds. + /// Uses deduplicated sector lookup for uniform distribution. + /// + private static bool TryGetVerifiedCachedPosition( + Map map, + ReadOnlySpan allBounds, + bool isMobile, + bool isWaterMob, + bool canSwim, + bool cantWalk, + out Point3D spawnPos) + { + if (!SectorSpawnCacheManager.TryGetRandomPosition( + map, + allBounds, + isWaterMob, + out var cachedPos, + out var containingBounds + )) + { + spawnPos = default; + return false; + } + + // Re-verify in 3D using the bounds that contains this position + var minZ = containingBounds.Start.Z; + var maxZ = containingBounds.End.Z - 1; + + var verified = isMobile + ? map.CanSpawnMobile(cachedPos.X, cachedPos.Y, minZ, maxZ, canSwim, cantWalk, out var verifiedZ) + : map.CanSpawnItem(cachedPos.X, cachedPos.Y, minZ, maxZ, out verifiedZ); + + if (!verified) + { + spawnPos = default; + return false; + } + + // Skip positions inside private houses + if (isMobile && SectorSpawnCacheManager.IsBlockedByHouse(map, cachedPos.X, cachedPos.Y, verifiedZ)) + { + spawnPos = default; + return false; + } + + spawnPos = new Point3D(cachedPos.X, cachedPos.Y, verifiedZ); + return true; + } public override void OnAfterDuped(Item newItem) { @@ -402,7 +756,25 @@ public abstract partial class BaseSpawner : Item, ISpawner // Recalculate HomeRange-style bounds when spawner moves if (IsHomeRangeStyleAt(oldLocation)) { - HomeRange = _spawnBounds.Width / 2; + HomeRange = SpawnBounds.Width / 2; + } + + // Reset spawn position optimization state when spawner moves + ResetSpawnPositionState(); + } + + /// + /// Resets the spawn position optimization state. + /// Called when spawner moves or bounds change. + /// + protected void ResetSpawnPositionState() + { + _spawnPositionState?.Reset(); + + // If we were abandoned, reset to automatic to give it another chance + if (_spawnPositionMode == SpawnPositionMode.Abandoned) + { + _spawnPositionMode = SpawnPositionMode.Automatic; } } @@ -438,7 +810,7 @@ public abstract partial class BaseSpawner : Item, ISpawner if (spawnBounds != default) { - _spawnBounds = spawnBounds; + SpawnBounds = spawnBounds; } else { @@ -1027,7 +1399,7 @@ public abstract partial class BaseSpawner : Item, ISpawner if (_pendingHomeRangeMigrations.Remove(this, out var homeRange)) { var surfaceZ = Map?.GetTopSurfaceZ(Location) ?? Location.Z; - _spawnBounds = new Rectangle3D( + SpawnBounds = new Rectangle3D( Location.X - homeRange, Location.Y - homeRange, surfaceZ, diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.cs index a566e8d59..10063c062 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.cs @@ -1,6 +1,6 @@ /************************************************************************* * ModernUO * - * Copyright 2019-2023 - ModernUO Development Team * + * Copyright 2019-2025 - ModernUO Development Team * * Email: hi@modernuo.com * * File: RegionSpawner.cs * * * @@ -74,6 +74,48 @@ public partial class RegionSpawner : Spawner } } + // RegionSpawner does not support spiral scan (disjoint rectangles make it ineffective) + protected override bool SupportsSpiralScan => false; + + protected override Rectangle3D GetBoundsForSpawnAttempt() + { + if (_spawnRegion == null || _spawnRegion.TotalWeight <= 0) + { + return default; + } + + // Pick a weighted random rectangle from the region + var rand = Utility.Random(_spawnRegion.TotalWeight); + + for (var j = 0; j < _spawnRegion.RectangleWeights.Length; j++) + { + var curWeight = _spawnRegion.RectangleWeights[j]; + + if (rand < curWeight) + { + return _spawnRegion.Rectangles[j]; + } + + rand -= curWeight; + } + + return default; + } + + protected override ReadOnlySpan GetAllSpawnBounds() => _spawnRegion?.Rectangles; + + public override Point3D GetSpawnPosition(ISpawnable spawned, Map map) + { + // Check for region/map mismatch before delegating to base + if (_spawnRegion == null || map == null || map == Map.Internal || + map != _spawnRegion.Map || _spawnRegion.TotalWeight <= 0) + { + return Location; + } + + return base.GetSpawnPosition(spawned, map); + } + public override void ToJson(DynamicJson json, JsonSerializerOptions options) { base.ToJson(json, options); @@ -90,55 +132,6 @@ public partial class RegionSpawner : Spawner } } - public override Point3D GetSpawnPosition(ISpawnable spawned, Map map) - { - if (_spawnRegion == null || map == null || map == Map.Internal || map != _spawnRegion.Map || - _spawnRegion.TotalWeight <= 0) - { - return Location; - } - - // Try 10 times to find a valid location. - for (var i = 0; i < 10; i++) - { - var rand = Utility.Random(_spawnRegion.TotalWeight); - - var x = int.MinValue; - var y = int.MinValue; - var minZ = (int)sbyte.MinValue; - var maxZ = (int)sbyte.MaxValue; - - for (var j = 0; j < _spawnRegion.RectangleWeights.Length; j++) - { - var curWeight = _spawnRegion.RectangleWeights[j]; - - if (rand < curWeight) - { - var rect = _spawnRegion.Rectangles[j]; - - x = rect.Start.X + rand % rect.Width; - y = rect.Start.Y + rand / rect.Width; - - // Use rectangle's Z range for multi-floor region support - minZ = rect.Start.Z; - maxZ = rect.End.Z - 1; - - break; - } - - rand -= curWeight; - } - - if (spawned is Mobile mob && map.CanSpawnMobile(x, y, minZ, maxZ, mob.CanSwim, mob.CantWalk, out var spawnZ) - || spawned is Item && map.CanSpawnItem(x, y, minZ, maxZ, out spawnZ)) - { - return new Point3D(x, y, spawnZ); - } - } - - return Location; - } - [AfterDeserialization(false)] private void AfterDeserialization() { diff --git a/Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs b/Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs new file mode 100644 index 000000000..936fc064c --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs @@ -0,0 +1,493 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SectorSpawnCache.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics.X86; +using Server.Collections; +using Server.Regions; + +namespace Server.Engines.Spawners; + +/// +/// Cached spawn position data for a 16x16 sector. +/// Uses a bitmap to track valid spawn positions (256 bits = 4 ulongs = 32 bytes). +/// +public struct SectorSpawnCache +{ + public ulong Bits0; + public ulong Bits1; + public ulong Bits2; + public ulong Bits3; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly int GetCount() => + BitOperations.PopCount(Bits0) + + BitOperations.PopCount(Bits1) + + BitOperations.PopCount(Bits2) + + BitOperations.PopCount(Bits3); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetBit(int bitIndex) + { + var ulongIndex = bitIndex >> 6; // / 64 + var bitPosition = bitIndex & 0x3F; // % 64 + + switch (ulongIndex) + { + case 0: Bits0 |= 1UL << bitPosition; break; + case 1: Bits1 |= 1UL << bitPosition; break; + case 2: Bits2 |= 1UL << bitPosition; break; + case 3: Bits3 |= 1UL << bitPosition; break; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool GetBit(int bitIndex) + { + var ulongIndex = bitIndex >> 6; + var bitPosition = bitIndex & 0x3F; + + return ulongIndex switch + { + 0 => (Bits0 & (1UL << bitPosition)) != 0, + 1 => (Bits1 & (1UL << bitPosition)) != 0, + 2 => (Bits2 & (1UL << bitPosition)) != 0, + 3 => (Bits3 & (1UL << bitPosition)) != 0, + _ => false + }; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ClearBit(int bitIndex) + { + var ulongIndex = bitIndex >> 6; + var bitPosition = bitIndex & 0x3F; + + switch (ulongIndex) + { + case 0: Bits0 &= ~(1UL << bitPosition); break; + case 1: Bits1 &= ~(1UL << bitPosition); break; + case 2: Bits2 &= ~(1UL << bitPosition); break; + case 3: Bits3 &= ~(1UL << bitPosition); break; + } + } + + /// + /// Gets the Nth set bit position (0-indexed) from the bitmap. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly int GetNthBitPosition(int n) + { + var count0 = BitOperations.PopCount(Bits0); + if (n < count0) + { + return GetNthBitInUlong(Bits0, n); + } + n -= count0; + + var count1 = BitOperations.PopCount(Bits1); + if (n < count1) + { + return 64 + GetNthBitInUlong(Bits1, n); + } + n -= count1; + + var count2 = BitOperations.PopCount(Bits2); + if (n < count2) + { + return 128 + GetNthBitInUlong(Bits2, n); + } + n -= count2; + + return 192 + GetNthBitInUlong(Bits3, n); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int GetNthBitInUlong(ulong bits, int n) + { + // BMI2 PDEP: O(1) - deposits the nth selector bit into the position of the nth set bit + if (Bmi2.X64.IsSupported) + { + var deposited = Bmi2.X64.ParallelBitDeposit(1UL << n, bits); + return BitOperations.TrailingZeroCount(deposited); + } + + // Fallback: O(popcount) - clear n set bits, then find position of next one + while (n > 0 && bits != 0) + { + bits &= bits - 1; // Clear lowest set bit + n--; + } + + return bits == 0 ? -1 : BitOperations.TrailingZeroCount(bits); + } +} + +/// +/// Global manager for sector-based spawn position caching. +/// Shared across all spawners for efficient memory usage and house invalidation. +/// Uses separate caches for land and water to minimize memory usage since most +/// sectors are either all land or all water. +/// +public static class SectorSpawnCacheManager +{ + private static readonly Dictionary<(Map, int, int), SectorSpawnCache> _landCaches = []; + private static readonly Dictionary<(Map, int, int), SectorSpawnCache> _waterCaches = []; + + /// + /// Marks a position as valid for spawning in the global cache. + /// + /// The map containing the position + /// The valid spawn position + /// True for water mob, false for land mob + public static void SetValid(Map map, Point3D pos, bool isWater) + { + var sectorX = pos.X >> Map.SectorShift; + var sectorY = pos.Y >> Map.SectorShift; + var bitIndex = (pos.X & (Map.SectorSize - 1)) + ((pos.Y & (Map.SectorSize - 1)) << Map.SectorShift); + + var caches = isWater ? _waterCaches : _landCaches; + ref var cache = ref CollectionsMarshal.GetValueRefOrAddDefault(caches, (map, sectorX, sectorY), out _); + cache.SetBit(bitIndex); + } + + /// + /// Attempts to get a random valid position from cached sectors within the specified bounds. + /// + /// The map to search + /// The spawn bounds to search within + /// True for water mob, false for land mob + /// The selected position (X, Y only - caller must verify Z) + /// True if a cached position was found + public static bool TryGetRandomPosition(Map map, Rectangle3D bounds, bool isWater, out Point2D pos) + { + ReadOnlySpan singleBounds = [bounds]; + return TryGetRandomPosition(map, singleBounds, isWater, out pos, out _); + } + + /// + /// Attempts to get a random valid position from cached sectors across multiple bounds. + /// Deduplicates overlapping sectors for uniform distribution. + /// + /// The map to search + /// All spawn bounds to search within + /// True for water mob, false for land mob + /// The selected position (X, Y only - caller must verify Z) + /// The bounds rectangle containing the selected position + /// Maximum retries if selected position is outside bounds + /// True if a cached position was found + public static bool TryGetRandomPosition( + Map map, + ReadOnlySpan allBounds, + bool isWater, + out Point2D pos, + out Rectangle3D containingBounds, + int maxRetries = 5) + { + pos = Point2D.Zero; + containingBounds = default; + + if (allBounds.Length == 0) + { + return false; + } + + var caches = isWater ? _waterCaches : _landCaches; + + // Collect unique sectors and their counts + using var sectorList = PooledRefList<(int sx, int sy, int count)>.Create(); + var totalPositions = 0; + + for (var i = 0; i < allBounds.Length; i++) + { + var bounds = allBounds[i]; + var startSectorX = bounds.Start.X >> Map.SectorShift; + var startSectorY = bounds.Start.Y >> Map.SectorShift; + var endSectorX = (bounds.End.X - 1) >> Map.SectorShift; + var endSectorY = (bounds.End.Y - 1) >> Map.SectorShift; + + for (var sx = startSectorX; sx <= endSectorX; sx++) + { + for (var sy = startSectorY; sy <= endSectorY; sy++) + { + // Check if we've already added this sector + var alreadyAdded = false; + for (var j = 0; j < sectorList.Count; j++) + { + var (checkSX, checkSY, _) = sectorList[j]; + if (checkSX == sx && checkSY == sy) + { + alreadyAdded = true; + break; + } + } + + if (alreadyAdded) + { + continue; + } + + if (caches.TryGetValue((map, sx, sy), out var cache)) + { + var count = cache.GetCount(); + if (count > 0) + { + sectorList.Add((sx, sy, count)); + totalPositions += count; + } + } + } + } + } + + if (totalPositions == 0) + { + return false; + } + + // Retry loop for when selected position is outside all bounds + for (var attempt = 0; attempt <= maxRetries; attempt++) + { + // Pick a random position + var targetIndex = Utility.Random(totalPositions); + + // Find the sector containing that index + var currentIndex = 0; + for (var i = 0; i < sectorList.Count; i++) + { + var (sx, sy, sectorCount) = sectorList[i]; + if (targetIndex < currentIndex + sectorCount) + { + // Target is in this sector - look up cache again + if (!caches.TryGetValue((map, sx, sy), out var cache)) + { + break; // Should not happen, try again + } + + var indexInSector = targetIndex - currentIndex; + var bitPosition = cache.GetNthBitPosition(indexInSector); + + var localX = bitPosition & (Map.SectorSize - 1); + var localY = bitPosition >> Map.SectorShift; + + pos = new Point2D((sx << Map.SectorShift) + localX, (sy << Map.SectorShift) + localY); + + // Find which bounds contains this position + for (var j = 0; j < allBounds.Length; j++) + { + var bounds = allBounds[j]; + if (pos.X >= bounds.Start.X && pos.X < bounds.End.X && + pos.Y >= bounds.Start.Y && pos.Y < bounds.End.Y) + { + containingBounds = bounds; + return true; + } + } + + // Position outside all bounds - retry + break; + } + + currentIndex += sectorCount; + } + } + + return false; + } + + /// + /// Checks if a position is blocked by a private house. + /// Public AoS houses (with unlocked doors) allow spawning. + /// + /// The map to check + /// X coordinate + /// Y coordinate + /// Z coordinate + /// True if blocked by a private house + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsBlockedByHouse(Map map, int x, int y, int z) + { + if (Region.Find(new Point3D(x, y, z), map) is HouseRegion houseRegion) + { + var house = houseRegion.House; + // Allow spawning in public AoS houses (unlocked doors, free entry) + return !(house.IsAosRules && house.Public); + } + + return false; + } + + /// + /// Invalidates all cached data for sectors within the specified bounds. + /// Called when houses are placed or demolished. + /// + /// The map to invalidate + /// The affected area + public static void InvalidateSectors(Map map, Rectangle2D bounds) + { + var startSectorX = bounds.Start.X >> Map.SectorShift; + var startSectorY = bounds.Start.Y >> Map.SectorShift; + var endSectorX = bounds.End.X >> Map.SectorShift; + var endSectorY = bounds.End.Y >> Map.SectorShift; + + for (var sx = startSectorX; sx <= endSectorX; sx++) + { + for (var sy = startSectorY; sy <= endSectorY; sy++) + { + var key = (map, sx, sy); + _landCaches.Remove(key); + _waterCaches.Remove(key); + } + } + } + + /// + /// Performs incremental spiral scanning to find and cache valid spawn positions. + /// + /// The map to scan + /// The center point to spiral from + /// The spawn bounds to stay within + /// Minimum Z for spawn checks + /// Maximum Z for spawn checks + /// Whether to find water positions + /// Whether the mob can't walk (water-only) + /// Current ring being scanned (updated on return) + /// Position within current ring (updated on return) + /// Number of rings to scan per call + /// True if scan is complete (exhausted bounds) + public static bool ContinueSpiralScan( + Map map, + Point3D center, + Rectangle3D bounds, + int minZ, + int maxZ, + bool canSwim, + bool cantWalk, + ref int currentRing, + ref int ringPosition, + int ringsPerTick = 3) + { + var maxRing = Math.Max(bounds.Width, bounds.Height) / 2 + 1; + var ringsToScan = Math.Min(currentRing + ringsPerTick, maxRing); + + while (currentRing < ringsToScan) + { + // Ring 0 is just the center point + if (currentRing == 0) + { + CheckAndCachePosition(map, center.X, center.Y, minZ, maxZ, bounds, canSwim, cantWalk); + } + else + { + // Ring N has 8*N positions + var positionsInRing = currentRing * 8; + for (var p = 0; p < positionsInRing; p++) + { + var (dx, dy) = GetSpiralOffset(currentRing, p); + var x = center.X + dx; + var y = center.Y + dy; + + CheckAndCachePosition(map, x, y, minZ, maxZ, bounds, canSwim, cantWalk); + } + } + + currentRing++; + } + + ringPosition = 0; + return currentRing >= maxRing; + } + + private static void CheckAndCachePosition( + Map map, + int x, int y, + int minZ, int maxZ, + Rectangle3D bounds, + bool canSwim, + bool cantWalk) + { + // Check bounds + if (x < bounds.Start.X || x >= bounds.End.X || + y < bounds.Start.Y || y >= bounds.End.Y) + { + return; + } + + // Check if position is valid for spawning + if (map.CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out var spawnZ)) + { + // Skip positions inside private houses + if (IsBlockedByHouse(map, x, y, spawnZ)) + { + return; + } + + var pos = new Point3D(x, y, spawnZ); + var isWater = canSwim && cantWalk; + SetValid(map, pos, isWater); + } + } + + /// + /// Gets the X,Y offset for a position in a spiral ring. + /// Ring 0 = center (no offset) + /// Ring 1 = 8 positions around center + /// Ring N = 8*N positions + /// + public static (int dx, int dy) GetSpiralOffset(int ring, int position) + { + // Each ring has 4 sides, each side has ring*2 positions + var sideLength = ring * 2; + var side = position / sideLength; + var sidePos = position % sideLength; + + return side switch + { + 0 => (-ring + sidePos, -ring), // Top edge, left to right + 1 => (ring, -ring + sidePos), // Right edge, top to bottom + 2 => (ring - sidePos, ring), // Bottom edge, right to left + 3 => (-ring, ring - sidePos), // Left edge, bottom to top + _ => (0, 0) + }; + } + + /// + /// Clears all cached data. Used for testing or server restart. + /// + public static void ClearAll() + { + _landCaches.Clear(); + _waterCaches.Clear(); + } + + /// + /// Gets the number of sectors currently cached (land + water). + /// + public static int CachedSectorCount => _landCaches.Count + _waterCaches.Count; + + /// + /// Gets the number of land sectors currently cached. + /// + public static int LandCacheCount => _landCaches.Count; + + /// + /// Gets the number of water sectors currently cached. + /// + public static int WaterCacheCount => _waterCaches.Count; +} diff --git a/Projects/UOContent/Engines/Spawners/SpawnPositionState.cs b/Projects/UOContent/Engines/Spawners/SpawnPositionState.cs new file mode 100644 index 000000000..9a18f3f6f --- /dev/null +++ b/Projects/UOContent/Engines/Spawners/SpawnPositionState.cs @@ -0,0 +1,80 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2025 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SpawnPositionState.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +namespace Server.Engines.Spawners; + +/// +/// Runtime state for spawn position optimization. +/// Not serialized - resets on server restart. +/// +public sealed class SpawnPositionState +{ + // Tunable thresholds for auto-detection + private const int FailureThreshold = 5; + private const int AbandonThreshold = 25; + + // Failure tracking for auto-detection + private int _nonTransientFailures; + + // Abandon tracking - counts consecutive cache misses or Location-only results + private int _consecutiveUselessResults; + + // Spiral scan state + public int SpiralRing; + public int SpiralRingPosition; + public bool SpiralComplete; + + /// + /// Resets all state. Called when spawner moves or bounds change. + /// + public void Reset() + { + _nonTransientFailures = 0; + _consecutiveUselessResults = 0; + SpiralRing = 0; + SpiralRingPosition = 0; + SpiralComplete = false; + } + + /// + /// Records a useful cache hit (position other than spawner's own location). + /// Resets the abandon counter. + /// + public void RecordUsefulCacheHit() => _consecutiveUselessResults = 0; + + /// + /// Records a useless result (cache miss or cache returned spawner's location). + /// Counts toward abandonment threshold. + /// + public void RecordUselessResult() => _consecutiveUselessResults++; + + /// + /// Records a non-transient spawn failure for auto-detection. + /// + public void RecordNonTransientFailure() => _nonTransientFailures++; + + /// + /// Returns true if the spawner should cache successful positions. + /// + public bool ShouldCachePositions(SpawnPositionMode mode) => + mode == SpawnPositionMode.Enabled || mode == SpawnPositionMode.Automatic && _nonTransientFailures > FailureThreshold; + + /// + /// Returns true if the spawner should be marked as abandoned. + /// Triggers after spiral completes, and we get consecutive useless results + /// (cache misses or cache only returning spawner's own location). + /// + public bool ShouldAbandon() => SpiralComplete && _consecutiveUselessResults >= AbandonThreshold; +} diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index b5a781b43..d5724819d 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -5,9 +5,35 @@ using Server.Json; namespace Server.Engines.Spawners; -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class Spawner : BaseSpawner { + /// + /// When true, enables proactive spiral scanning to find valid spawn positions. + /// Only relevant when SpawnPositionMode is Automatic or Enabled. + /// + [SerializableFieldSaveFlag(0)] + private bool ShouldSerializeUseSpiralScan() => _useSpiralScan; + + [SerializableField(0)] + [SerializedCommandProperty(AccessLevel.Developer)] + private bool _useSpiralScan; + + [SerializableFieldSaveFlag(1)] + private bool ShouldSerializeSpawnBounds() => _spawnBounds != default; + + [SerializableProperty(1)] + public override Rectangle3D SpawnBounds + { + get => _spawnBounds; + set + { + _spawnBounds = value; + InvalidateProperties(); + this.MarkDirty(); + } + } + [Constructible(AccessLevel.Developer)] public Spawner() { @@ -32,57 +58,33 @@ public partial class Spawner : BaseSpawner public Spawner(DynamicJson json, JsonSerializerOptions options) : base(json, options) { + // Read spawnBounds (not in BaseSpawner to allow RegionSpawner to skip it) + if (json.GetProperty("spawnBounds", options, out Rectangle3D spawnBounds)) + { + SpawnBounds = spawnBounds; + } } - /* - public override bool OnDefragSpawn(ISpawnable spawned, bool remove) + public override void ToJson(DynamicJson json, JsonSerializerOptions options) { - // To despawn a mob that was lured 4x away from its spawner - // TODO: Move this to a config - if (spawned is BaseCreature c && c.Combatant == null && c.GetDistanceToSqrt( Location ) > c.RangeHome * 4) - { - c.Delete(); - remove = true; - } + base.ToJson(json, options); - return base.OnDefragSpawn(entry, spawned, remove); + if (SpawnBounds != default) + { + json.SetProperty("spawnBounds", options, SpawnBounds); + } } - */ public override Region Region => Region.Find(Location, Map); - public override Point3D GetSpawnPosition(ISpawnable spawned, Map map) + protected override bool SupportsSpiralScan => _useSpiralScan; + + protected override Rectangle3D GetBoundsForSpawnAttempt() => SpawnBounds; + + protected override ReadOnlySpan GetAllSpawnBounds() => new(ref _spawnBounds); + + private void MigrateFrom(V0Content content) { - if (map == null || map == Map.Internal) - { - return Location; - } - - var bounds = SpawnBounds; - - // No bounds = HomeRange of 0, spawn at spawner location - if (bounds == default) - { - return Location; - } - - // Z range from SpawnBounds (supports multi-story buildings) - var minZ = bounds.Start.Z; - var maxZ = bounds.End.Z - 1; - - // Try 10 times to find a valid location. - for (var i = 0; i < 10; i++) - { - var x = Utility.RandomMinMax(bounds.Start.X, bounds.End.X - 1); - var y = Utility.RandomMinMax(bounds.Start.Y, bounds.End.Y - 1); - - if (spawned is Mobile mob && map.CanSpawnMobile(x, y, minZ, maxZ, mob.CanSwim, mob.CantWalk, out var spawnZ) - || spawned is Item && map.CanSpawnItem(x, y, minZ, maxZ, out spawnZ)) - { - return new Point3D(x, y, spawnZ); - } - } - - return Location; + // V0 had no fields in Spawner, new v1 field _useSpiralScan defaults to false } } diff --git a/Projects/UOContent/Engines/Virtues/VirtueGump.cs b/Projects/UOContent/Engines/Virtues/VirtueGump.cs index 3d0ac833e..87ab6e669 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueGump.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueGump.cs @@ -112,7 +112,7 @@ public class VirtueGump : Gump private int GetHueFor(int index) { - var value = VirtueSystem.GetVirtues((_beheld))?.GetValue(index) ?? 0; + var value = VirtueSystem.GetVirtues(_beheld)?.GetValue(index) ?? 0; if (value < 4000) { diff --git a/Projects/UOContent/Items/Games/Mahjong/MahjongPlayers.cs b/Projects/UOContent/Items/Games/Mahjong/MahjongPlayers.cs index fd5d613f3..82d023f13 100644 --- a/Projects/UOContent/Items/Games/Mahjong/MahjongPlayers.cs +++ b/Projects/UOContent/Items/Games/Mahjong/MahjongPlayers.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using ModernUO.Serialization; using Server.Network; diff --git a/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v12.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v12.json new file mode 100644 index 000000000..13fcec9c5 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v12.json @@ -0,0 +1,123 @@ +{ + "version": 12, + "type": "Server.Engines.Spawners.BaseSpawner", + "properties": [ + { + "name": "Guid", + "type": "System.Guid", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ReturnOnDeactivate", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Entries", + "type": "System.Collections.Generic.List\u003CServer.Engines.Spawners.SpawnerEntry\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Engines.Spawners.SpawnerEntry", + "RawSerializableMigrationRule", + "DeserializationRequiresParent" + ] + }, + { + "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.v1.json b/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v1.json new file mode 100644 index 000000000..1e7b3a83f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v1.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "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" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index bc82d001c..3e3d70a67 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -13,8 +13,6 @@ * along with this program. If not, see . * ************************************************************************/ -using Server.Engines.Spawners; - namespace Server.Mobiles; public abstract partial class BaseAI diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs index e70e81650..b6abd5cf7 100644 --- a/Projects/UOContent/Multis/Houses/BaseHouse.cs +++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs @@ -4,6 +4,7 @@ using ModernUO.CodeGeneratedEvents; using Server.Accounting; using Server.Collections; using Server.ContextMenus; +using Server.Engines.Spawners; using Server.Ethics; using Server.Guilds; using Server.Gumps; @@ -1561,6 +1562,19 @@ namespace Server.Multis UpdateRegion(); + // Invalidate spawn position cache for affected sectors + if (Map != null && Map != Map.Internal) + { + var mcl = Components; + var bounds = new Rectangle2D( + X + mcl.Min.X, + Y + mcl.Min.Y, + mcl.Width, + mcl.Height + ); + SectorSpawnCacheManager.InvalidateSectors(Map, bounds); + } + if (Sign?.Deleted == false) { Sign.Map = Map; @@ -3329,6 +3343,19 @@ namespace Server.Multis { RestoreRelocatedEntities(); + // Invalidate spawn position cache for affected sectors before deletion + if (Map != null && Map != Map.Internal) + { + var mcl = Components; + var bounds = new Rectangle2D( + X + mcl.Min.X, + Y + mcl.Min.Y, + mcl.Width, + mcl.Height + ); + SectorSpawnCacheManager.InvalidateSectors(Map, bounds); + } + new FixColumnTimer(this).Start(); base.OnDelete(); diff --git a/Projects/UOContent/Network/EntityPackets.cs b/Projects/UOContent/Network/EntityPackets.cs index a58fd2341..015158a38 100644 --- a/Projects/UOContent/Network/EntityPackets.cs +++ b/Projects/UOContent/Network/EntityPackets.cs @@ -13,7 +13,6 @@ * along with this program. If not, see . * *************************************************************************/ -using System; using System.Collections.Generic; using System.Runtime.CompilerServices; diff --git a/Projects/UOContent/Spells/Fifth/Paralyze.cs b/Projects/UOContent/Spells/Fifth/Paralyze.cs index 7f3a8a0de..9140d55e9 100644 --- a/Projects/UOContent/Spells/Fifth/Paralyze.cs +++ b/Projects/UOContent/Spells/Fifth/Paralyze.cs @@ -39,7 +39,7 @@ namespace Server.Spells.Fifth if (Core.AOS) { - var secs = (GetDamageSkill(Caster) / 10 - GetResistSkill(m) / 10); + var secs = GetDamageSkill(Caster) / 10 - GetResistSkill(m) / 10; if (!Core.AOS) {