feat: Add spawn position caching and spiral scan optimization (#2295)

### Summary

Adds spawn position caching and optimization for constrained spawners (e.g., those near houses, water, or blocked terrain).

### Key features:
- Sector-based bitmap cache (32 bytes per 16x16 sector) stores valid spawn positions
- Spiral scan progressively discovers positions from spawner center outward
- Automatic mode detects constrained spawners after 5+ non-transient failures
- Prevents mob spawning inside private houses (allows public AoS buildings)
- Deduplicates sector lookups for multi-bounds spawners (RegionSpawner)
- Cache invalidation on house placement/demolition
- Moves SpawnBounds to Spawner

### New spawner properties:
- SpawnPositionMode: Automatic (default), Enabled, Disabled, Abandoned
- MaxSpawnAttempts: Configurable attempts before optimization engages (default: 5)
This commit is contained in:
Kamron Batman 2025-12-28 02:40:21 -08:00 committed by GitHub
parent 6d51b33cf8
commit 3e8d548f38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1938 additions and 153 deletions

View file

@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.SkippableFact" Version="1.5.23" />
<PackageReference Include="xunit.SkippableFact" Version="1.5.61" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -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<T>(string key, JsonSerializerOptions options, out T t)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool GetProperty<T>(string key, JsonSerializerOptions options, out T t) =>
GetProperty(key, options, default, out t);
public bool GetProperty<T>(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;
}

View file

@ -39,6 +39,20 @@ public enum MapRules
FeluccaRules = None
}
/// <summary>
/// Indicates why a spawn position check failed. Used by spawners to determine
/// if optimization (caching) should be enabled.
/// </summary>
[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<Map>, ISpanFormattable, ISpanParsable<Map>
{
public const int SectorSize = 16;
@ -1103,17 +1117,38 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
/// <param name="cantWalk">Whether the spawned entity cannot walk (water-only)</param>
/// <param name="spawnZ">The valid spawn Z if found</param>
/// <returns>True if a valid spawn Z was found within the range</returns>
[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 _);
/// <summary>
/// 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.
/// </summary>
/// <param name="x">X coordinate</param>
/// <param name="y">Y coordinate</param>
/// <param name="minZ">Minimum Z (inclusive)</param>
/// <param name="maxZ">Maximum Z (inclusive)</param>
/// <param name="canSwim">Whether the spawned entity can swim (water surfaces valid)</param>
/// <param name="cantWalk">Whether the spawned entity cannot walk (water-only)</param>
/// <param name="spawnZ">The valid spawn Z if found</param>
/// <param name="failureReason">Indicates why spawn failed (if it did)</param>
/// <returns>True if a valid spawn Z was found within the range</returns>
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<Map>, 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<Map>, 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<Map>, 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<Map>, 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<Map>, 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<Map>, 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<Map>, 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<Map>, 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;
}

View file

@ -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());
}
}

View file

@ -10,6 +10,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.SkippableFact" Version="1.5.61" />
<ProjectReference Include="..\Server\Server.csproj" />
<ProjectReference Include="..\UOContent\UOContent.csproj" />
<ProjectReference Include="..\Server.Tests\Server.Tests.csproj" />

View file

@ -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");

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Text;
using Server.Commands.Generic;

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using ModernUO.CodeGeneratedEvents;
using Server.Mobiles;

View file

@ -1,7 +1,6 @@
using System;
using ModernUO.CodeGeneratedEvents;
using ModernUO.Serialization;
using Server.Items;
using Server.Misc;
using Server.Mobiles;

View file

@ -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)

View file

@ -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)]
/// <summary>
/// Controls how a spawner handles spawn position optimization.
/// </summary>
public enum SpawnPositionMode : byte
{
/// <summary>
/// Auto-detect if optimization is needed based on failure patterns.
/// Only engages lazy caching after non-transient spawn failures.
/// </summary>
Automatic = 0,
/// <summary>
/// Force optimization on. Always cache successful spawn positions.
/// </summary>
Enabled = 1,
/// <summary>
/// Force optimization off. Use only random position attempts.
/// </summary>
Disabled = 2,
/// <summary>
/// Spawner has given up due to 100% failure rate.
/// Skips all spawn position logic and returns spawner location.
/// Admin can reset via [props.
/// </summary>
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;
/// <summary>
/// The spawn bounds for this spawner. Abstract to allow derived classes to manage their own storage.
/// </summary>
[CommandProperty(AccessLevel.Developer)]
public abstract Rectangle3D SpawnBounds { get; set; }
/// <summary>
/// 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
/// </summary>
[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;
/// <summary>
/// Controls how spawn position optimization is handled.
/// </summary>
[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;
/// <summary>
/// Maximum number of random position attempts before engaging optimization.
/// </summary>
[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;
/// <summary>
@ -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
/// </summary>
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
/// </summary>
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<SpawnerEntry> 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; }
/// <summary>
/// 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
/// </summary>
protected abstract Rectangle3D GetBoundsForSpawnAttempt();
/// <summary>
/// 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
/// </summary>
protected abstract ReadOnlySpan<Rectangle3D> GetAllSpawnBounds();
/// <summary>
/// Whether this spawner supports spiral scanning.
/// Only makes sense for contiguous bounds (Spawner).
/// Disjoint rectangles (RegionSpawner) should return false.
/// </summary>
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;
}
/// <summary>
/// Attempts to get a verified spawn position from the sector cache across all bounds.
/// Uses deduplicated sector lookup for uniform distribution.
/// </summary>
private static bool TryGetVerifiedCachedPosition(
Map map,
ReadOnlySpan<Rectangle3D> 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();
}
/// <summary>
/// Resets the spawn position optimization state.
/// Called when spawner moves or bounds change.
/// </summary>
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,

View file

@ -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<Rectangle3D> 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()
{

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
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;
/// <summary>
/// Cached spawn position data for a 16x16 sector.
/// Uses a bitmap to track valid spawn positions (256 bits = 4 ulongs = 32 bytes).
/// </summary>
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;
}
}
/// <summary>
/// Gets the Nth set bit position (0-indexed) from the bitmap.
/// </summary>
[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);
}
}
/// <summary>
/// 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.
/// </summary>
public static class SectorSpawnCacheManager
{
private static readonly Dictionary<(Map, int, int), SectorSpawnCache> _landCaches = [];
private static readonly Dictionary<(Map, int, int), SectorSpawnCache> _waterCaches = [];
/// <summary>
/// Marks a position as valid for spawning in the global cache.
/// </summary>
/// <param name="map">The map containing the position</param>
/// <param name="pos">The valid spawn position</param>
/// <param name="isWater">True for water mob, false for land mob</param>
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);
}
/// <summary>
/// Attempts to get a random valid position from cached sectors within the specified bounds.
/// </summary>
/// <param name="map">The map to search</param>
/// <param name="bounds">The spawn bounds to search within</param>
/// <param name="isWater">True for water mob, false for land mob</param>
/// <param name="pos">The selected position (X, Y only - caller must verify Z)</param>
/// <returns>True if a cached position was found</returns>
public static bool TryGetRandomPosition(Map map, Rectangle3D bounds, bool isWater, out Point2D pos)
{
ReadOnlySpan<Rectangle3D> singleBounds = [bounds];
return TryGetRandomPosition(map, singleBounds, isWater, out pos, out _);
}
/// <summary>
/// Attempts to get a random valid position from cached sectors across multiple bounds.
/// Deduplicates overlapping sectors for uniform distribution.
/// </summary>
/// <param name="map">The map to search</param>
/// <param name="allBounds">All spawn bounds to search within</param>
/// <param name="isWater">True for water mob, false for land mob</param>
/// <param name="pos">The selected position (X, Y only - caller must verify Z)</param>
/// <param name="containingBounds">The bounds rectangle containing the selected position</param>
/// <param name="maxRetries">Maximum retries if selected position is outside bounds</param>
/// <returns>True if a cached position was found</returns>
public static bool TryGetRandomPosition(
Map map,
ReadOnlySpan<Rectangle3D> 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;
}
/// <summary>
/// Checks if a position is blocked by a private house.
/// Public AoS houses (with unlocked doors) allow spawning.
/// </summary>
/// <param name="map">The map to check</param>
/// <param name="x">X coordinate</param>
/// <param name="y">Y coordinate</param>
/// <param name="z">Z coordinate</param>
/// <returns>True if blocked by a private house</returns>
[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;
}
/// <summary>
/// Invalidates all cached data for sectors within the specified bounds.
/// Called when houses are placed or demolished.
/// </summary>
/// <param name="map">The map to invalidate</param>
/// <param name="bounds">The affected area</param>
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);
}
}
}
/// <summary>
/// Performs incremental spiral scanning to find and cache valid spawn positions.
/// </summary>
/// <param name="map">The map to scan</param>
/// <param name="center">The center point to spiral from</param>
/// <param name="bounds">The spawn bounds to stay within</param>
/// <param name="minZ">Minimum Z for spawn checks</param>
/// <param name="maxZ">Maximum Z for spawn checks</param>
/// <param name="canSwim">Whether to find water positions</param>
/// <param name="cantWalk">Whether the mob can't walk (water-only)</param>
/// <param name="currentRing">Current ring being scanned (updated on return)</param>
/// <param name="ringPosition">Position within current ring (updated on return)</param>
/// <param name="ringsPerTick">Number of rings to scan per call</param>
/// <returns>True if scan is complete (exhausted bounds)</returns>
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);
}
}
/// <summary>
/// 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
/// </summary>
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)
};
}
/// <summary>
/// Clears all cached data. Used for testing or server restart.
/// </summary>
public static void ClearAll()
{
_landCaches.Clear();
_waterCaches.Clear();
}
/// <summary>
/// Gets the number of sectors currently cached (land + water).
/// </summary>
public static int CachedSectorCount => _landCaches.Count + _waterCaches.Count;
/// <summary>
/// Gets the number of land sectors currently cached.
/// </summary>
public static int LandCacheCount => _landCaches.Count;
/// <summary>
/// Gets the number of water sectors currently cached.
/// </summary>
public static int WaterCacheCount => _waterCaches.Count;
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
namespace Server.Engines.Spawners;
/// <summary>
/// Runtime state for spawn position optimization.
/// Not serialized - resets on server restart.
/// </summary>
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;
/// <summary>
/// Resets all state. Called when spawner moves or bounds change.
/// </summary>
public void Reset()
{
_nonTransientFailures = 0;
_consecutiveUselessResults = 0;
SpiralRing = 0;
SpiralRingPosition = 0;
SpiralComplete = false;
}
/// <summary>
/// Records a useful cache hit (position other than spawner's own location).
/// Resets the abandon counter.
/// </summary>
public void RecordUsefulCacheHit() => _consecutiveUselessResults = 0;
/// <summary>
/// Records a useless result (cache miss or cache returned spawner's location).
/// Counts toward abandonment threshold.
/// </summary>
public void RecordUselessResult() => _consecutiveUselessResults++;
/// <summary>
/// Records a non-transient spawn failure for auto-detection.
/// </summary>
public void RecordNonTransientFailure() => _nonTransientFailures++;
/// <summary>
/// Returns true if the spawner should cache successful positions.
/// </summary>
public bool ShouldCachePositions(SpawnPositionMode mode) =>
mode == SpawnPositionMode.Enabled || mode == SpawnPositionMode.Automatic && _nonTransientFailures > FailureThreshold;
/// <summary>
/// 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).
/// </summary>
public bool ShouldAbandon() => SpiralComplete && _consecutiveUselessResults >= AbandonThreshold;
}

View file

@ -5,9 +5,35 @@ using Server.Json;
namespace Server.Engines.Spawners;
[SerializationGenerator(0)]
[SerializationGenerator(1)]
public partial class Spawner : BaseSpawner
{
/// <summary>
/// When true, enables proactive spiral scanning to find valid spawn positions.
/// Only relevant when SpawnPositionMode is Automatic or Enabled.
/// </summary>
[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<Rectangle3D> 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
}
}

View file

@ -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)
{

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using ModernUO.Serialization;
using Server.Network;

View file

@ -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": [
""
]
}
]
}

View file

@ -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"
]
}
]
}

View file

@ -13,8 +13,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
************************************************************************/
using Server.Engines.Spawners;
namespace Server.Mobiles;
public abstract partial class BaseAI

View file

@ -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();

View file

@ -13,7 +13,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;

View file

@ -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)
{