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:
parent
6d51b33cf8
commit
3e8d548f38
23 changed files with 1938 additions and 153 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Server.Commands.Generic;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ModernUO.CodeGeneratedEvents;
|
||||
using Server.Mobiles;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using ModernUO.CodeGeneratedEvents;
|
||||
using ModernUO.Serialization;
|
||||
using Server.Items;
|
||||
using Server.Misc;
|
||||
using Server.Mobiles;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
493
Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs
Normal file
493
Projects/UOContent/Engines/Spawners/SectorSpawnCache.cs
Normal 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;
|
||||
}
|
||||
80
Projects/UOContent/Engines/Spawners/SpawnPositionState.cs
Normal file
80
Projects/UOContent/Engines/Spawners/SpawnPositionState.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ModernUO.Serialization;
|
||||
using Server.Network;
|
||||
|
|
|
|||
123
Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v12.json
generated
Normal file
123
Projects/UOContent/Migrations/Server.Engines.Spawners.BaseSpawner.v12.json
generated
Normal 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": [
|
||||
""
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
24
Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v1.json
generated
Normal file
24
Projects/UOContent/Migrations/Server.Engines.Spawners.Spawner.v1.json
generated
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue