diff --git a/Projects/Server.Tests/Fixtures/ServerFixture.cs b/Projects/Server.Tests/Fixtures/ServerFixture.cs index f66a578db..1059436f0 100644 --- a/Projects/Server.Tests/Fixtures/ServerFixture.cs +++ b/Projects/Server.Tests/Fixtures/ServerFixture.cs @@ -1,38 +1,43 @@ using System; -using System.Reflection; using Xunit; namespace Server.Tests; +/// +/// Fixture for all server tests. Attempts to load TileData from client files if available. +/// Configure client path via MODERNUO_CLIENT_PATH environment variable or place files at C:\Ultima Online Classic. +/// [CollectionDefinition("Sequential Server Tests", DisableParallelization = true)] public class ServerFixture : ICollectionFixture, IDisposable { + /// + /// True if TileData was successfully loaded from client files. + /// + public static bool TileDataLoaded => TestServerInitializer.TileDataLoaded; + + /// + /// A tile ID known to have the Surface flag. 0 if not found. + /// + public static ushort SurfaceTileId => TestServerInitializer.SurfaceTileId; + + /// + /// A tile ID known to have the Impassable flag. 0 if not found. + /// + public static ushort ImpassableTileId => TestServerInitializer.ImpassableTileId; + + /// + /// A tile ID known to have the Wet flag (water). 0 if not found. + /// + public static ushort WetTileId => TestServerInitializer.WetTileId; + + /// + /// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found. + /// + public static ushort SurfaceImpassableTileId => TestServerInitializer.SurfaceImpassableTileId; + public ServerFixture() { - Core.ApplicationAssembly = Assembly.GetExecutingAssembly(); // Server.Tests.dll - - // Load Configurations - ServerConfiguration.Load(true); - - // Load an empty assembly list into the resolver - ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory); - AssemblyHandler.LoadAssemblies(["Server.dll"]); - - Core.LoopContext = new EventLoopContext(); - Core.Expansion = Expansion.EJ; - - // Configure / Initialize - TestMapDefinitions.ConfigureTestMapDefinitions(); - - // Configure the world - World.Configure(); - - Timer.Init(0); - - // Load the world - World.Load(); - - World.ExitSerializationThreads(); + TestServerInitializer.Initialize(loadTileData: true); } public void Dispose() diff --git a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs new file mode 100644 index 000000000..ef5a9864f --- /dev/null +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -0,0 +1,176 @@ +using System.IO; +using System.Reflection; +using System.Threading; + +namespace Server.Tests; + +/// +/// Shared server initialization logic for test fixtures. +/// Ensures the server is only initialized once across all test collections. +/// +public static class TestServerInitializer +{ + private const string DefaultDataDirectory = @"C:\Ultima Online Classic"; + private static bool _initialized; + private static readonly Lock _lock = new(); + + /// + /// True if TileData was successfully loaded from client files. + /// + public static bool TileDataLoaded { get; private set; } + + /// + /// A tile ID known to have the Surface flag. 0 if not found. + /// + public static ushort SurfaceTileId { get; private set; } + + /// + /// A tile ID known to have the Impassable flag. 0 if not found. + /// + public static ushort ImpassableTileId { get; private set; } + + /// + /// A tile ID known to have the Wet flag (water). 0 if not found. + /// + public static ushort WetTileId { get; private set; } + + /// + /// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found. + /// + public static ushort SurfaceImpassableTileId { get; private set; } + + /// + /// Initializes the test server. Safe to call multiple times - only initializes once. + /// + /// If true, attempts to load TileData from client files. + public static void Initialize(bool loadTileData = false) + { + lock (_lock) + { + if (_initialized) + { + // Already initialized, but check if we need to load TileData now + if (loadTileData && !TileDataLoaded && TileData.MaxItemValue == 0) + { + TryLoadTileData(); + DetectTileIds(); + } + return; + } + + Core.ApplicationAssembly = Assembly.GetExecutingAssembly(); + + // Load Configurations + ServerConfiguration.Load(true); + + // Try to load TileData if requested + if (loadTileData) + { + TryLoadTileData(); + } + + // Load an empty assembly list into the resolver + ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory); + AssemblyHandler.LoadAssemblies(["Server.dll"]); + + Core.LoopContext = new EventLoopContext(); + Core.Expansion = Expansion.EJ; + + // Configure / Initialize + TestMapDefinitions.ConfigureTestMapDefinitions(); + + // Configure the world + World.Configure(); + + Timer.Init(0); + + // Load the world + World.Load(); + + World.ExitSerializationThreads(); + + // Detect tile IDs if TileData was loaded + if (loadTileData) + { + DetectTileIds(); + } + + _initialized = true; + } + } + + private static void TryLoadTileData() + { + var dataDir = GetDataDirectory(); + if (string.IsNullOrEmpty(dataDir) || !Directory.Exists(dataDir)) + { + return; + } + + ServerConfiguration.DataDirectories.Add(dataDir); + + var tileDataPath = Core.FindDataFile("tiledata.mul", false); + if (File.Exists(tileDataPath)) + { + TileData.Load(); + } + } + + private static void DetectTileIds() + { + if (TileData.MaxItemValue == 0) + { + return; + } + + SurfaceTileId = FindTileWithFlag(TileFlag.Surface); + ImpassableTileId = FindTileWithFlag(TileFlag.Impassable); + WetTileId = FindTileWithFlag(TileFlag.Wet); + SurfaceImpassableTileId = FindTileWithFlags(TileFlag.Surface | TileFlag.Impassable); + TileDataLoaded = SurfaceTileId > 0 && ImpassableTileId > 0 && WetTileId > 0; + } + + private static ushort FindTileWithFlag(TileFlag flag) + { + for (ushort i = 1; i <= TileData.MaxItemValue && i < 0xFFFF; i++) + { + var data = TileData.ItemTable[i]; + if ((data.Flags & flag) != 0) + { + return i; + } + } + return 0; + } + + private static ushort FindTileWithFlags(TileFlag flags) + { + for (ushort i = 1; i <= TileData.MaxItemValue && i < 0xFFFF; i++) + { + var data = TileData.ItemTable[i]; + if ((data.Flags & flags) == flags) + { + return i; + } + } + return 0; + } + + private static string GetDataDirectory() + { + // Check environment variable first + var envDir = System.Environment.GetEnvironmentVariable("MODERNUO_CLIENT_PATH"); + if (!string.IsNullOrEmpty(envDir) && Directory.Exists(envDir)) + { + return envDir; + } + + // Fall back to default directory + if (Directory.Exists(DefaultDataDirectory)) + { + return DefaultDataDirectory; + } + + return null; + } +} diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 0f913dd48..91c7db3de 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs new file mode 100644 index 000000000..dadabb8be --- /dev/null +++ b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs @@ -0,0 +1,327 @@ +using Server.Items; +using Xunit; + +namespace Server.Tests.Tests.Maps; + +/// +/// Tests for CanFitItem which allows Surface+Impassable tiles (tables, furniture) as valid surfaces. +/// +[Collection("Sequential Server Tests")] +public class CanFitItemTests +{ + private readonly ServerFixture _fixture; + + public CanFitItemTests(ServerFixture fixture) + { + _fixture = fixture; + } + + private void SkipIfNoTileData() + { + Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); + } + + #region Basic Validation Tests + + [Fact] + public void CanFitItem_InternalMapReturnsFalse() + { + var result = Map.Internal.CanFitItem(100, 100, 0, 1); + + Assert.False(result); + } + + [Fact] + public void CanFitItem_InvalidCoordinatesReturnsFalse() + { + var map = Map.Felucca; + + Assert.False(map.CanFitItem(-1, 100, 0, 1)); + Assert.False(map.CanFitItem(100, -1, 0, 1)); + Assert.False(map.CanFitItem(map.Width + 1, 100, 0, 1)); + Assert.False(map.CanFitItem(100, map.Height + 1, 0, 1)); + } + + #endregion + + #region Land Surface Tests + + private const int TestLandX = 1500; + private const int TestLandY = 1600; + + [Fact] + public void CanFitItem_LandSurfaceReturnsTrue() + { + var map = Map.Felucca; + var avgZ = map.GetAverageZ(TestLandX, TestLandY); + + var result = map.CanFitItem(TestLandX, TestLandY, avgZ, 1); + + Assert.True(result, $"Expected land at Z={avgZ} to be valid surface for items"); + } + + [Fact] + public void CanFitItem_AboveLandWithNoSurfaceReturnsFalse() + { + var map = Map.Felucca; + var avgZ = map.GetAverageZ(TestLandX, TestLandY); + + // Try to place item 50 units above land with no surface there + var result = map.CanFitItem(TestLandX, TestLandY, avgZ + 50, 1); + + Assert.False(result, "No surface 50 units above land"); + } + + #endregion + + #region Surface + Impassable Tests (Tables, Furniture) + + [SkippableFact] + public void CanFitItem_SurfaceImpassableMultiIsValidSurface() + { + SkipIfNoTileData(); + Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData"); + + var map = Map.Felucca; + const int x = 1700; + const int y = 1700; + + TestMulti multi = null; + try + { + // Get the actual tile height from TileData + var tileData = TileData.ItemTable[ServerFixture.SurfaceImpassableTileId]; + var tileHeight = tileData.CalcHeight; + const int tileZ = 10; + + // Create a multi with a Surface+Impassable tile (like a table) at Z=10 + multi = CreateSurfaceImpassableMulti(map, new Point3D(x, y, 0), surfaceZ: tileZ); + + // CanFitItem should treat Surface+Impassable as a valid surface + var surfaceTop = tileZ + tileHeight; + var result = map.CanFitItem(x, y, surfaceTop, 1); + + Assert.True(result, $"Surface+Impassable tile should be valid surface for items at Z={surfaceTop}"); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanFitItem_CanFitDoesNotAllowSurfaceImpassable() + { + SkipIfNoTileData(); + Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData"); + + var map = Map.Felucca; + const int x = 1750; + const int y = 1750; + + TestMulti multi = null; + try + { + // Get the actual tile height from TileData + var tileData = TileData.ItemTable[ServerFixture.SurfaceImpassableTileId]; + var tileHeight = tileData.CalcHeight; + const int tileZ = 10; + + // Create a multi with a Surface+Impassable tile at Z=10 + multi = CreateSurfaceImpassableMulti(map, new Point3D(x, y, 0), surfaceZ: tileZ); + + var surfaceTop = tileZ + tileHeight; + + // Regular CanFit should NOT treat Surface+Impassable as valid (for comparison) + var canFitResult = map.CanFit(x, y, surfaceTop, 1, requireSurface: true); + + // This test documents the difference between CanFit and CanFitItem + // CanFit requires surface && !impassable + Assert.False(canFitResult, "CanFit should NOT allow Surface+Impassable as valid surface"); + } + finally + { + multi?.Delete(); + } + } + + #endregion + + #region World Item Surface Tests + + [SkippableFact] + public void CanFitItem_NonMovableWorldItemSurfaceWorks() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1800; + const int y = 1800; + + Item surfaceItem = null; + try + { + // Create a non-movable surface item (like a placed table) + surfaceItem = new Item(ServerFixture.SurfaceTileId) + { + Movable = false + }; + surfaceItem.MoveToWorld(new Point3D(x, y, 10), map); + + var itemTop = 10 + surfaceItem.ItemData.CalcHeight; + var result = map.CanFitItem(x, y, itemTop, 1); + + Assert.True(result, $"Non-movable surface item should be valid surface at Z={itemTop}"); + } + finally + { + surfaceItem?.Delete(); + } + } + + [SkippableFact] + public void CanFitItem_MovableWorldItemNotValidSurface() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1850; + const int y = 1850; + + Item movableItem = null; + try + { + // Create a movable surface item + movableItem = new Item(ServerFixture.SurfaceTileId) + { + Movable = true + }; + movableItem.MoveToWorld(new Point3D(x, y, 10), map); + + var itemTop = 10 + movableItem.ItemData.CalcHeight; + // Try placing at item top - should fail unless there's land there too + var avgZ = map.GetAverageZ(x, y); + + // If avgZ != itemTop, there's no other surface, so it should fail + if (avgZ != itemTop) + { + var result = map.CanFitItem(x, y, itemTop, 1); + Assert.False(result, "Movable item should not count as valid surface"); + } + } + finally + { + movableItem?.Delete(); + } + } + + #endregion + + #region Blocking Tests + + [SkippableFact] + public void CanFitItem_ImpassableTileBlocksPlacement() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1900; + const int y = 1900; + + TestMulti multi = null; + try + { + // Create a multi with an impassable blocker at Z=10 + multi = CreateImpassableMulti(map, new Point3D(x, y, 0), blockerZ: 10); + + // Try to place item at Z=10 (inside the blocker) + var result = map.CanFitItem(x, y, 10, 5); + + Assert.False(result, "Should not be able to place item inside impassable tile"); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanFitItem_CanPlaceOnTopOfImpassable() + { + SkipIfNoTileData(); + Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData"); + + var map = Map.Felucca; + const int x = 1950; + const int y = 1950; + + TestMulti multi = null; + try + { + // Get the actual tile height from TileData + var tileData = TileData.ItemTable[ServerFixture.SurfaceImpassableTileId]; + var tileHeight = tileData.CalcHeight; + const int tileZ = 10; + + // Create a Surface+Impassable tile at Z=10 + multi = CreateSurfaceImpassableMulti(map, new Point3D(x, y, 0), surfaceZ: tileZ); + + // Place item on top should work + var surfaceTop = tileZ + tileHeight; + var result = map.CanFitItem(x, y, surfaceTop, 1); + + Assert.True(result, $"Should be able to place item on top of Surface+Impassable tile at Z={surfaceTop}"); + } + finally + { + multi?.Delete(); + } + } + + #endregion + + #region Helper Methods and Classes + + private TestMulti CreateSurfaceImpassableMulti(Map map, Point3D location, int surfaceZ) + { + // Use SurfaceImpassableTileId - a tile that has both Surface and Impassable flags + var multi = new TestMulti(new MultiComponentList( + [ + new MultiTileEntry( + ServerFixture.SurfaceImpassableTileId, + 0, 0, (short)surfaceZ, + TileFlag.Surface | TileFlag.Impassable + ) + ])); + multi.MoveToWorld(location, map); + return multi; + } + + private TestMulti CreateImpassableMulti(Map map, Point3D location, int blockerZ) + { + var multi = new TestMulti(new MultiComponentList( + [ + new MultiTileEntry( + ServerFixture.ImpassableTileId, + 0, 0, (short)blockerZ, + TileFlag.Impassable + ) + ])); + multi.MoveToWorld(location, map); + return multi; + } + + private class TestMulti : BaseMulti + { + private readonly MultiComponentList _components; + + public TestMulti(MultiComponentList components) : base(0x1) + { + _components = components ?? MultiComponentList.Empty; + } + + public override MultiComponentList Components => _components; + } + + #endregion +} diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs new file mode 100644 index 000000000..b549b98ba --- /dev/null +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs @@ -0,0 +1,179 @@ +using Xunit; + +namespace Server.Tests.Tests.Maps; + +/// +/// Tests for CanSpawnMobile that don't require TileData (client files). +/// For tests that require client files, see CanSpawnMobileTileDataTests. +/// +[Collection("Sequential Server Tests")] +public class CanSpawnMobileTests +{ + #region Basic Validation Tests + + [Fact] + public void CanSpawnMobile_InternalMapReturnsFalse() + { + var result = Map.Internal.CanSpawnMobile(100, 100, -128, 127, false, false, out var spawnZ); + + Assert.False(result); + Assert.Equal(0, spawnZ); + } + + [Fact] + public void CanSpawnMobile_InvalidCoordinatesReturnsFalse() + { + var map = Map.Felucca; + + Assert.False(map.CanSpawnMobile(-1, 100, -128, 127, false, false, out _)); + Assert.False(map.CanSpawnMobile(100, -1, -128, 127, false, false, out _)); + Assert.False(map.CanSpawnMobile(map.Width + 1, 100, -128, 127, false, false, out _)); + Assert.False(map.CanSpawnMobile(100, map.Height + 1, -128, 127, false, false, out _)); + } + + #endregion + + #region Land Surface Tests + + // Use coordinates near Britain which should be walkable land in both test and real data + private const int TestLandX = 1500; + private const int TestLandY = 1600; + + [Fact] + public void CanSpawnMobile_EmptyLocationWithLandReturnsTrue() + { + var map = Map.Felucca; + map.GetAverageZ(TestLandX, TestLandY, out _, out var landZ, out _); + + // Test with full Z range to find land + var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, false, out var spawnZ); + + // Should find a valid spawn point (land surface) + Assert.True(result, $"Expected to find valid spawn point on land at ({TestLandX}, {TestLandY})"); + } + + [Fact] + public void CanSpawnMobile_ZRangeExcludingLandFails() + { + var map = Map.Felucca; + map.GetAverageZ(TestLandX, TestLandY, out _, out var avgZ, out _); + + // Use a Z range that excludes the land surface (50+ units above land) + var result = map.CanSpawnMobile(TestLandX, TestLandY, avgZ + 50, avgZ + 100, false, false, out var spawnZ); + + Assert.False(result, $"Expected no spawn point above land at Z={avgZ}"); + } + + [Fact] + public void CanSpawnMobile_CantWalkSkipsLandSurface() + { + var map = Map.Felucca; + // cantWalk=true means the mob can only swim, so land shouldn't be valid + var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, true, out var spawnZ); + + Assert.False(result, "cantWalk=true should not find land as valid surface"); + } + + #endregion + + #region Mobile Blocking Tests + + [Fact] + public void CanSpawnMobile_MobileBlocksSpawn() + { + var map = Map.Felucca; + // Use the same test coordinates + map.GetAverageZ(TestLandX, TestLandY, out _, out var landZ, out _); + + Mobile blockingMobile = null; + try + { + // Create a mobile at the land surface + blockingMobile = new TestMobile(); + blockingMobile.MoveToWorld(new Point3D(TestLandX, TestLandY, landZ), map); + + // The mobile should block spawning at its location and Z + var result = map.CanSpawnMobile(TestLandX, TestLandY, landZ - 16, landZ + 16, false, false, out var spawnZ); + + // Land surface is blocked by mobile at the same Z + Assert.False(result, "Mobile should block spawn at same location"); + } + finally + { + blockingMobile?.Delete(); + } + } + + [Fact] + public void CanSpawnMobile_HiddenGMDoesNotBlock() + { + var map = Map.Felucca; + // Use slightly different coordinates to avoid test interference + const int x = TestLandX + 10; + const int y = TestLandY + 10; + map.GetAverageZ(x, y, out _, out var landZ, out _); + + Mobile gm = null; + try + { + // Create a hidden GM mobile + gm = new TestMobile + { + AccessLevel = AccessLevel.GameMaster, + Hidden = true + }; + gm.MoveToWorld(new Point3D(x, y, landZ), map); + + // Hidden GM should not block spawning + var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out var spawnZ); + + Assert.True(result, "Hidden GM should not block spawn"); + } + finally + { + gm?.Delete(); + } + } + + [Fact] + public void CanSpawnMobile_MobileAtDifferentZDoesNotBlock() + { + var map = Map.Felucca; + // Use slightly different coordinates to avoid test interference + const int x = TestLandX + 20; + const int y = TestLandY + 20; + map.GetAverageZ(x, y, out _, out var landZ, out _); + + Mobile blockingMobile = null; + try + { + // Create a mobile at Z=landZ+50 (far above the land) + blockingMobile = new TestMobile(); + blockingMobile.MoveToWorld(new Point3D(x, y, landZ + 50), map); + + // Land should still be valid (mobile above doesn't block ground level) + var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out var spawnZ); + + Assert.True(result, "Mobile at different Z should not block"); + } + finally + { + blockingMobile?.Delete(); + } + } + + #endregion + + #region Helper Classes + + private class TestMobile : Mobile + { + public TestMobile() + { + AccessLevel = AccessLevel.Player; + Hidden = false; + } + } + + #endregion +} diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs new file mode 100644 index 000000000..5c6e56dcc --- /dev/null +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs @@ -0,0 +1,372 @@ +using Server.Items; +using Xunit; + +namespace Server.Tests.Tests.Maps; + +/// +/// Tests for CanSpawnMobile that require TileData (client files) to be loaded. +/// These tests will be skipped if client files are not available. +/// Configure client path via MODERNUO_CLIENT_PATH environment variable or place files at C:\Ultima Online Classic. +/// +[Collection("Sequential Server Tests")] +public class CanSpawnMobileTileDataTests +{ + private readonly ServerFixture _fixture; + + public CanSpawnMobileTileDataTests(ServerFixture fixture) + { + _fixture = fixture; + } + + private void SkipIfNoTileData() + { + Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); + } + + #region Multi-Based Tests + + [SkippableFact] + public void CanSpawnMobile_FindsSurfaceOnMulti() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1100; + const int y = 1100; + + TestMulti multi = null; + try + { + // Create a multi with a floor at Z=20 using a real surface tile + multi = CreateFloorMulti(map, new Point3D(x, y, 0), floorZ: 20); + + var result = map.CanSpawnMobile(x, y, 15, 30, false, false, out var spawnZ); + + Assert.True(result); + Assert.True(spawnZ >= 15 && spawnZ <= 30); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanSpawnMobile_FindsLowestValidSurface() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1200; + const int y = 1200; + + TestMulti multi = null; + try + { + // Create a multi with floors at Z=10 and Z=50 + // Need 16+ units between floors for mobile clearance + multi = CreateTwoFloorMulti(map, new Point3D(x, y, 0), floor1Z: 10, floor2Z: 50); + + var result = map.CanSpawnMobile(x, y, 5, 60, false, false, out var spawnZ); + + Assert.True(result); + // Should find the lowest floor (around Z=10 + tile height) + Assert.True(spawnZ <= 20, $"Expected lowest floor around Z=10-15, got {spawnZ}"); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanSpawnMobile_ZRangeSelectsCorrectFloor() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1300; + const int y = 1300; + + TestMulti multi = null; + try + { + // Create a multi with floors at Z=10 and Z=50 + // Need 16+ units between floors for mobile clearance + multi = CreateTwoFloorMulti(map, new Point3D(x, y, 0), floor1Z: 10, floor2Z: 50); + + // Request Z range that only includes the second floor + var result = map.CanSpawnMobile(x, y, 45, 60, false, false, out var spawnZ); + + Assert.True(result); + Assert.True(spawnZ >= 45 && spawnZ <= 60, $"Expected second floor around Z=50, got {spawnZ}"); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanSpawnMobile_LowCeilingBlocksSpawn() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1400; + const int y = 1400; + + TestMulti multi = null; + try + { + // Create a multi with floor at Z=20 and ceiling at Z=28 (only 8 units clearance) + // Mobile height is 16, so this should block + multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 28); + + // The low ceiling should block spawning (need 16 units clearance for mobiles) + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + + Assert.False(result); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanSpawnMobile_HighCeilingAllowsSpawn() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1450; + const int y = 1450; + + TestMulti multi = null; + try + { + // Create a multi with floor at Z=20 and ceiling at Z=50 (30 units clearance) + multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 50); + + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + + Assert.True(result); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanSpawnMobile_BlockerOutsideZRangeIgnored() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1475; + const int y = 1475; + + TestMulti multi = null; + try + { + // Create a multi with floor at Z=20 and a blocker at Z=100 (outside our search range) + multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 100); + + // The blocker at Z=100 shouldn't affect spawning in range 15-35 + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + + Assert.True(result); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanSpawnMobile_WaterSurfaceForSwimmingMob() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1550; + const int y = 1550; + + TestMulti multi = null; + try + { + // Create a multi with a water tile at Z=0 + multi = CreateWaterMulti(map, new Point3D(x, y, 0), waterZ: 0); + + // canSwim=true should find the water surface + var result = map.CanSpawnMobile(x, y, -10, 10, true, false, out var spawnZ); + + Assert.True(result); + } + finally + { + multi?.Delete(); + } + } + + [SkippableFact] + public void CanSpawnMobile_WorldItemSurfaceWorks() + { + SkipIfNoTileData(); + + var map = Map.Felucca; + const int x = 1650; + const int y = 1650; + + Item surfaceItem = null; + try + { + // Create a non-movable item that acts as a surface using a real surface tile ID + surfaceItem = new Item(ServerFixture.SurfaceTileId) + { + Movable = false + }; + surfaceItem.MoveToWorld(new Point3D(x, y, 10), map); + + var result = map.CanSpawnMobile(x, y, 5, 30, false, false, out var spawnZ); + + // Should find either land at Z=0 or the item surface, whichever is lowest + Assert.True(result); + } + finally + { + surfaceItem?.Delete(); + } + } + + #endregion + + #region Real Map Data Tests + + [SkippableFact] + public void CanSpawnMobile_MalasBuilding_FindsGroundFloor() + { + SkipIfNoTileData(); + + var map = Map.Malas; + const int x = 991; + const int y = 519; + + var result = map.CanSpawnMobile(x, y, -60, -40, false, false, out var spawnZ); + + Assert.True(result); + Assert.True(spawnZ >= -60 && spawnZ <= -40, $"Expected ground floor around -50, got {spawnZ}"); + } + + [SkippableFact] + public void CanSpawnMobile_MalasBuilding_FindsSecondFloor() + { + SkipIfNoTileData(); + + var map = Map.Malas; + const int x = 991; + const int y = 519; + + var result = map.CanSpawnMobile(x, y, -30, 0, false, false, out var spawnZ); + + Assert.True(result); + Assert.True(spawnZ >= -30 && spawnZ <= 0, $"Expected second floor, got {spawnZ}"); + } + + [SkippableFact] + public void CanSpawnMobile_MalasBuilding_LowestFloorPreferred() + { + SkipIfNoTileData(); + + var map = Map.Malas; + const int x = 991; + const int y = 519; + + var result = map.CanSpawnMobile(x, y, -60, 0, false, false, out var spawnZ); + + Assert.True(result); + Assert.True(spawnZ <= -40, $"Expected lowest floor around -50, got {spawnZ}"); + } + + #endregion + + #region Helper Methods and Classes + + private TestMulti CreateFloorMulti(Map map, Point3D location, int floorZ) + { + var multi = new TestMulti(CreateFloorComponents(floorZ)); + multi.MoveToWorld(location, map); + return multi; + } + + private TestMulti CreateTwoFloorMulti(Map map, Point3D location, int floor1Z, int floor2Z) + { + var multi = new TestMulti(CreateTwoFloorComponents(floor1Z, floor2Z)); + multi.MoveToWorld(location, map); + return multi; + } + + private TestMulti CreateFloorWithCeilingMulti(Map map, Point3D location, int floorZ, int ceilingZ) + { + var multi = new TestMulti(CreateFloorWithCeilingComponents(floorZ, ceilingZ)); + multi.MoveToWorld(location, map); + return multi; + } + + private TestMulti CreateWaterMulti(Map map, Point3D location, int waterZ) + { + var multi = new TestMulti(CreateWaterComponents(waterZ)); + multi.MoveToWorld(location, map); + return multi; + } + + private MultiComponentList CreateFloorComponents(int floorZ) + { + return new MultiComponentList( + [ + new MultiTileEntry(ServerFixture.SurfaceTileId, 0, 0, (short)floorZ, TileFlag.Surface) + ]); + } + + private MultiComponentList CreateTwoFloorComponents(int floor1Z, int floor2Z) + { + return new MultiComponentList( + [ + new MultiTileEntry(ServerFixture.SurfaceTileId, 0, 0, (short)floor1Z, TileFlag.Surface), + new MultiTileEntry(ServerFixture.SurfaceTileId, 0, 0, (short)floor2Z, TileFlag.Surface) + ]); + } + + private MultiComponentList CreateFloorWithCeilingComponents(int floorZ, int ceilingZ) + { + return new MultiComponentList( + [ + new MultiTileEntry(ServerFixture.SurfaceTileId, 0, 0, (short)floorZ, TileFlag.Surface), + new MultiTileEntry(ServerFixture.ImpassableTileId, 0, 0, (short)ceilingZ, TileFlag.Impassable) + ]); + } + + private MultiComponentList CreateWaterComponents(int waterZ) + { + return new MultiComponentList( + [ + new MultiTileEntry(ServerFixture.WetTileId, 0, 0, (short)waterZ, TileFlag.Wet) + ]); + } + + private class TestMulti : BaseMulti + { + private readonly MultiComponentList _components; + + public TestMulti(MultiComponentList components) : base(0x1) + { + _components = components ?? MultiComponentList.Empty; + } + + public override MultiComponentList Components => _components; + } + + #endregion +} diff --git a/Projects/Server/Interfaces.cs b/Projects/Server/Interfaces.cs index 95fb64d37..8638e4fc7 100644 --- a/Projects/Server/Interfaces.cs +++ b/Projects/Server/Interfaces.cs @@ -70,7 +70,7 @@ public interface ISpawner : IEntity /// /// Checks if the given location is within the spawn bounds. /// - bool IsInSpawnBounds(IPoint3D location); + bool IsInSpawnBounds(Point3D location); } public interface ISpawnable : IEntity diff --git a/Projects/Server/Maps/Map.cs b/Projects/Server/Maps/Map.cs index 1cef774f1..58ac68582 100644 --- a/Projects/Server/Maps/Map.cs +++ b/Projects/Server/Maps/Map.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Numerics; using System.Runtime.CompilerServices; using Server.Buffers; using Server.Collections; @@ -469,6 +470,89 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa return surface; } + /// + /// Gets the Z level of the highest surface that is at or below . + /// + /// The reference point. + /// The Z level of the surface, or p.Z if no surface is found below. + public int GetTopSurfaceZ(Point3D p) + { + if (this == Internal) + { + return p.Z; + } + + var surfaceZ = int.MinValue; + + var lt = Tiles.GetLandTile(p.X, p.Y); + + if (!lt.Ignored) + { + var avgZ = GetAverageZ(p.X, p.Y); + + if (avgZ <= p.Z) + { + surfaceZ = avgZ; + + if (surfaceZ == p.Z) + { + return surfaceZ; + } + } + } + + foreach (var tile in Tiles.GetStaticAndMultiTiles(p.X, p.Y)) + { + var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + + if (id.Surface || id.Wet) + { + var tileZ = tile.Z + id.CalcHeight; + + if (tileZ > surfaceZ && tileZ <= p.Z) + { + surfaceZ = tileZ; + + if (surfaceZ == p.Z) + { + return surfaceZ; + } + } + } + } + + var sector = GetSector(p.X, p.Y); + + foreach (var item in sector.Items) + { + if (item is BaseMulti || item.ItemID > TileData.MaxItemValue || !item.AtWorldPoint(p.X, p.Y) || + item.Movable) + { + continue; + } + + var id = item.ItemData; + + if (id.Surface || id.Wet) + { + var itemZ = item.Z + id.CalcHeight; + + if (itemZ > surfaceZ && itemZ <= p.Z) + { + surfaceZ = itemZ; + + if (surfaceZ == p.Z) + { + return surfaceZ; + } + } + } + } + + // If no surface found below, return the original Z + return surfaceZ == int.MinValue ? p.Z : surfaceZ; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Bound(int x, int y, out int newX, out int newY) { @@ -915,6 +999,91 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa return !requireSurface || hasSurface; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanFitItem(Point3D p, int height) => CanFitItem(p.m_X, p.m_Y, p.m_Z, height); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanFitItem(Point2D p, int z, int height) => CanFitItem(p.m_X, p.m_Y, z, height); + + /// + /// Checks if an item can be placed at the specified location. + /// Unlike CanFit, this treats Surface+Impassable tiles (tables, furniture) as valid surfaces, + /// matching the behavior of item drop logic. + /// + public bool CanFitItem(int x, int y, int z, int height) + { + if (this == Internal || x < 0 || y < 0 || x >= Width || y >= Height) + { + return false; + } + + var hasSurface = false; + + var lt = Tiles.GetLandTile(x, y); + GetAverageZ(x, y, out var lowZ, out var avgZ, out _); + var landFlags = TileData.LandTable[lt.ID & TileData.MaxLandValue].Flags; + + // Impassable land still blocks items + if ((landFlags & TileFlag.Impassable) != 0 && avgZ > z && z + height > lowZ) + { + return false; + } + + // Passable land is a valid surface + if ((landFlags & TileFlag.Impassable) == 0 && z == avgZ && !lt.Ignored) + { + hasSurface = true; + } + + foreach (var tile in Tiles.GetStaticAndMultiTiles(x, y)) + { + var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var surface = id.Surface; + var impassable = id.Impassable; + + // Tiles block if item would intersect with them + if ((surface || impassable) && tile.Z + id.CalcHeight > z && z + height > tile.Z) + { + return false; + } + + // Surface tiles (including Surface+Impassable like tables) are valid surfaces for items + if (surface && z == tile.Z + id.CalcHeight) + { + hasSurface = true; + } + } + + var sector = GetSector(x, y); + + foreach (var item in sector.Items) + { + if (item is BaseMulti || item.ItemID > TileData.MaxItemValue || !item.AtWorldPoint(x, y)) + { + continue; + } + + var id = item.ItemData; + var surface = id.Surface; + var impassable = id.Impassable; + + // Items block if placement would intersect + if ((surface || impassable) && item.Z + id.CalcHeight > z && z + height > item.Z) + { + return false; + } + + // Surface items (including Surface+Impassable like tables) are valid surfaces + // Must be non-movable to be a stable surface + if (surface && !item.Movable && z == item.Z + id.CalcHeight) + { + hasSurface = true; + } + } + + return hasSurface; + } + public bool CanSpawnMobile(Point3D p) => CanSpawnMobile(p.m_X, p.m_Y, p.m_Z); public bool CanSpawnMobile(Point2D p, int z) => CanSpawnMobile(p.m_X, p.m_Y, z); @@ -922,6 +1091,277 @@ public sealed partial class Map : IComparable, ISpanFormattable, ISpanParsa public bool CanSpawnMobile(int x, int y, int z) => Region.Find(new Point3D(x, y, z), this).AllowSpawn() && CanFit(x, y, z, 16); + /// + /// 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). + /// + /// X coordinate + /// Y coordinate + /// Minimum Z (inclusive) + /// Maximum Z (inclusive) + /// Whether the spawned entity can swim (water surfaces valid) + /// Whether the spawned entity cannot walk (water-only) + /// The valid spawn Z if found + /// True if a valid spawn Z was found within the range + public bool CanSpawnMobile(int x, int y, int minZ, int maxZ, bool canSwim, bool cantWalk, out int spawnZ) + { + spawnZ = 0; + + if (this == Internal || x < 0 || y < 0 || x >= Width || y >= Height) + { + return false; + } + + if (!Region.Find(new Point3D(x, y, minZ), this).AllowSpawn()) + { + return false; + } + + // Bitmask approach inspired by Item.DropToWorld's m_OpenSlots pattern. + // Each bit represents a Z level relative to minZ. + // openSlots: bit set = Z level is not blocked + // surfaces: bit set = Z level has a valid surface + // Final result: lowest set bit in (surfaces & openSlots) + var openSlots = ulong.MaxValue; + ulong surfaces = 0; + + // 1. Land tile + var landTile = Tiles.GetLandTile(x, y); + GetAverageZ(x, y, out var lowZ, out var avgZ, out _); + + if (!landTile.Ignored) + { + var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + var isImpassable = (landFlags & TileFlag.Impassable) != 0; + var isWet = (landFlags & TileFlag.Wet) != 0; + + // Impassable land blocks, except water tiles don't block swimming mobs + if (isImpassable && !(canSwim && isWet)) + { + // Impassable land blocks z in range (lowZ - 16, avgZ) + openSlots &= ~CreateBlockerMask(lowZ - 16, avgZ, minZ); + } + + // Surface: water for swimmers, passable land for walkers + if (avgZ >= minZ && avgZ <= maxZ && (canSwim && isWet || !cantWalk && !isImpassable)) + { + surfaces |= 1UL << (avgZ - minZ); + } + } + + // 2. Static and multi tiles + foreach (var tile in Tiles.GetStaticAndMultiTiles(x, y)) + { + var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var tileTop = tile.Z + id.CalcHeight; + var isSurface = id.Surface; + var isImpassable = id.Impassable; + var isWet = id.Wet; + + // Blocking: (surface || impassable) tiles block z in range (tile.Z - 16, tileTop) + // Exception: water tiles (Impassable | Wet) don't block swimming mobs + if ((isSurface || isImpassable) && !(canSwim && isWet)) + { + openSlots &= ~CreateBlockerMask(tile.Z - 16, tileTop, minZ); + } + + // Surface candidate + if (tileTop >= minZ && tileTop <= maxZ && + (canSwim && isWet || !cantWalk && isSurface && !isImpassable)) + { + surfaces |= 1UL << (tileTop - minZ); + } + } + + // 3. World items + var sector = GetSector(x, y); + foreach (var item in sector.Items) + { + if (item is BaseMulti || item.ItemID > TileData.MaxItemValue || !item.AtWorldPoint(x, y)) + { + continue; + } + + var id = item.ItemData; + var itemTop = item.Z + id.CalcHeight; + var isSurface = id.Surface; + var isImpassable = id.Impassable; + var isWet = id.Wet; + + // Blocking: (surface || impassable) items block z in range (item.Z - 16, itemTop) + // Exception: water items (Impassable | Wet) don't block swimming mobs + if ((isSurface || isImpassable) && !(canSwim && isWet)) + { + openSlots &= ~CreateBlockerMask(item.Z - 16, itemTop, minZ); + } + + // Surface candidate (non-movable only) + if (!item.Movable && itemTop >= minZ && itemTop <= maxZ && + (canSwim && isWet || !cantWalk && isSurface && !isImpassable)) + { + surfaces |= 1UL << (itemTop - minZ); + } + } + + // 4. Mobiles (blockers only) + foreach (var m in sector.Mobiles) + { + if (m.Location.m_X == x && m.Location.m_Y == y && + (m.AccessLevel == AccessLevel.Player || !m.Hidden)) + { + // Mobiles block z in range (m.Z - 16, m.Z + 16) + openSlots &= ~CreateBlockerMask(m.Z - 16, m.Z + 16, minZ); + } + } + + // Find the lowest unblocked surface using bit operations + var validSurfaces = surfaces & openSlots; + if (validSurfaces == 0) + { + return false; + } + + // TrailingZeroCount gives the position of the lowest set bit + var lowestBit = BitOperations.TrailingZeroCount(validSurfaces); + spawnZ = minZ + lowestBit; + return true; + } + + /// + /// Finds a valid spawn Z for items within the specified range. + /// Unlike CanSpawnMobile, treats Surface+Impassable tiles (tables, furniture) as valid surfaces. + /// + /// X coordinate + /// Y coordinate + /// Minimum Z (inclusive) + /// Maximum Z (inclusive) + /// The valid spawn Z if found + /// True if a valid spawn Z was found within the range + public bool CanSpawnItem(int x, int y, int minZ, int maxZ, out int spawnZ) + { + spawnZ = 0; + + if (this == Internal || x < 0 || y < 0 || x >= Width || y >= Height) + { + return false; + } + + if (!Region.Find(new Point3D(x, y, minZ), this).AllowSpawn()) + { + return false; + } + + // Bitmask approach: find surfaces within Z range, check for blockers. + // Unlike CanSpawnMobile, Surface+Impassable tiles (tables) are valid surfaces for items. + var openSlots = ulong.MaxValue; + ulong surfaces = 0; + + // 1. Land tile + var landTile = Tiles.GetLandTile(x, y); + GetAverageZ(x, y, out var lowZ, out var avgZ, out _); + + if (!landTile.Ignored) + { + var landFlags = TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags; + var isImpassable = (landFlags & TileFlag.Impassable) != 0; + + // Impassable land blocks + if (isImpassable) + { + openSlots &= ~CreateBlockerMask(lowZ - 16, avgZ, minZ); + } + + // Passable land is a valid surface + if (!isImpassable && avgZ >= minZ && avgZ <= maxZ) + { + surfaces |= 1UL << (avgZ - minZ); + } + } + + // 2. Static and multi tiles + foreach (var tile in Tiles.GetStaticAndMultiTiles(x, y)) + { + var id = TileData.ItemTable[tile.ID & TileData.MaxItemValue]; + var tileTop = tile.Z + id.CalcHeight; + var isSurface = id.Surface; + var isImpassable = id.Impassable; + + // Blocking: (surface || impassable) tiles block z in range (tile.Z - 16, tileTop) + if (isSurface || isImpassable) + { + openSlots &= ~CreateBlockerMask(tile.Z - 16, tileTop, minZ); + } + + // Surface candidate: Surface flag (including Surface+Impassable like tables) + if (isSurface && tileTop >= minZ && tileTop <= maxZ) + { + surfaces |= 1UL << (tileTop - minZ); + } + } + + // 3. World items + var sector = GetSector(x, y); + foreach (var item in sector.Items) + { + if (item is BaseMulti || item.ItemID > TileData.MaxItemValue || !item.AtWorldPoint(x, y)) + { + continue; + } + + var id = item.ItemData; + var itemTop = item.Z + id.CalcHeight; + var isSurface = id.Surface; + var isImpassable = id.Impassable; + + // Blocking: (surface || impassable) items block + if (isSurface || isImpassable) + { + openSlots &= ~CreateBlockerMask(item.Z - 16, itemTop, minZ); + } + + // Surface candidate: non-movable Surface items (including Surface+Impassable) + if (!item.Movable && isSurface && itemTop >= minZ && itemTop <= maxZ) + { + surfaces |= 1UL << (itemTop - minZ); + } + } + + // Find the lowest unblocked surface using bit operations + var validSurfaces = surfaces & openSlots; + if (validSurfaces == 0) + { + return false; + } + + var lowestBit = BitOperations.TrailingZeroCount(validSurfaces); + spawnZ = minZ + lowestBit; + return true; + } + + /// + /// Creates a bitmask for a blocker range. Blocker blocks Z where blockLow < z < blockHigh. + /// Bits are relative to minZ, clamped to [0, 63]. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ulong CreateBlockerMask(int blockLow, int blockHigh, int minZ) + { + // Blocked range is (blockLow, blockHigh) exclusive, = [blockLow + 1, blockHigh - 1] inclusive + var startBit = blockLow - minZ + 1; + var endBit = blockHigh - minZ - 1; + + if (endBit < 0 || startBit > 63 || startBit > endBit) + { + return 0; + } + + startBit = Math.Max(0, startBit); + endBit = Math.Min(63, endBit); + + var bitCount = endBit - startBit + 1; + var mask = bitCount >= 64 ? ulong.MaxValue : (1UL << bitCount) - 1; + return mask << startBit; + } + private class ZComparer : IComparer { public static readonly ZComparer Default = new(); diff --git a/Projects/Server/TileData.cs b/Projects/Server/TileData.cs index 4b1ba805b..01fd3b773 100644 --- a/Projects/Server/TileData.cs +++ b/Projects/Server/TileData.cs @@ -300,7 +300,7 @@ public static class TileData Load(); } - private static unsafe void Load() + internal static unsafe void Load() { var filePath = Core.FindDataFile("tiledata.mul"); diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs index 3c728aafc..4fba474d5 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.Migrations.cs @@ -19,8 +19,10 @@ public abstract partial class BaseSpawner _group = content.Group; _minDelay = content.MinDelay; _maxDelay = content.MaxDelay; + _count = content.Count; _team = content.Team; - _end = content.End; + _running = content.Running; + _end = _running ? content.End : Core.Now; // Defer SpawnBounds calculation to AfterDeserialization when Location is available if (content.HomeRange > 0) diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index a8eb602d5..64138e6df 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -99,14 +99,19 @@ public abstract partial class BaseSpawner : Item, ISpawner } set { - // Create square bounds centered on spawner with full Z range + // Find the surface below the spawner (handles spawners placed in air) + var surfaceZ = Map != null && Map != Map.Internal + ? Map.GetTopSurfaceZ(Location) + : Location.Z; + + // Create square bounds centered on spawner, Z range from surface to surface + 16 _spawnBounds = new Rectangle3D( Location.X - value, Location.Y - value, - sbyte.MinValue, + surfaceZ, value * 2 + 1, value * 2 + 1, - 256 // Full Z range: -128 to 127 + 17 // Z range: surface to surface + 16 ); this.MarkDirty(); InvalidateProperties(); @@ -116,41 +121,38 @@ public abstract partial class BaseSpawner : Item, ISpawner /// /// Returns true if SpawnBounds represents a HomeRange-style square centered on the spawner. /// - public bool IsHomeRangeStyle + public bool IsHomeRangeStyle => IsHomeRangeStyleAt(Location); + + /// + /// Returns true if SpawnBounds represents a HomeRange-style square centered on the given location. + /// + public bool IsHomeRangeStyleAt(Point3D location) { - get + if (_spawnBounds == default) { - if (_spawnBounds == default) - { - return true; // No bounds = default HomeRange behavior - } - - // Must be square - if (_spawnBounds.Width != _spawnBounds.Height) - { - return false; - } - - // Spawner must be at center - var centerX = _spawnBounds.Start.X + _spawnBounds.Width / 2; - var centerY = _spawnBounds.Start.Y + _spawnBounds.Height / 2; - - return centerX == Location.X && centerY == Location.Y; + return true; // No bounds = default HomeRange behavior } + + // Must be square + 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; + + return centerX == location.X && centerY == location.Y; } /// /// Checks if the given location is within the spawn bounds. /// Virtual to allow RegionSpawner to override with region-based logic. /// - public virtual bool IsInSpawnBounds(IPoint3D location) + public virtual bool IsInSpawnBounds(Point3D location) { - if (_spawnBounds == default) - { - return true; // No bounds = always in bounds - } - - return _spawnBounds.Contains(location); + return _spawnBounds == default || _spawnBounds.Contains(location); } public BaseSpawner() : this(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)) @@ -208,16 +210,17 @@ public abstract partial class BaseSpawner : Item, ISpawner { _spawnBounds = spawnBounds; } - // Fall back to homeRange with location for oldest format else 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( location.X - homeRange, location.Y - homeRange, - sbyte.MinValue, + location.Z, homeRange * 2 + 1, homeRange * 2 + 1, - 256 + 17 ); } @@ -396,39 +399,11 @@ public abstract partial class BaseSpawner : Item, ISpawner { base.OnLocationChange(oldLocation); - // Only shift bounds if they represent a HomeRange-style square - // (spawner was centered and bounds are square) - if (_spawnBounds == default) + // Recalculate HomeRange-style bounds when spawner moves + if (IsHomeRangeStyleAt(oldLocation)) { - return; + HomeRange = _spawnBounds.Width / 2; } - - var isSquare = _spawnBounds.Width == _spawnBounds.Height; - if (!isSquare) - { - return; - } - - // Check if spawner was at center of bounds - var centerX = _spawnBounds.Start.X + _spawnBounds.Width / 2; - var centerY = _spawnBounds.Start.Y + _spawnBounds.Height / 2; - if (centerX != oldLocation.X || centerY != oldLocation.Y) - { - return; - } - - // Shift bounds by the location delta - var deltaX = Location.X - oldLocation.X; - var deltaY = Location.Y - oldLocation.Y; - - _spawnBounds = new Rectangle3D( - _spawnBounds.Start.X + deltaX, - _spawnBounds.Start.Y + deltaY, - _spawnBounds.Start.Z, - _spawnBounds.Width, - _spawnBounds.Height, - _spawnBounds.Depth - ); } public SpawnerEntry AddEntry( @@ -833,7 +808,9 @@ public abstract partial class BaseSpawner : Item, ISpawner Spawned.Add(m, entry); entry.AddToSpawned(m); - var spawnLocation = m is BaseVendor ? Location : GetSpawnPosition(m, map); + // var spawnLocation = m is BaseVendor ? Location : GetSpawnPosition(m, map); + + var spawnLocation = GetSpawnPosition(m, map); m.OnBeforeSpawn(spawnLocation, map); m.MoveToWorld(spawnLocation, map); @@ -1049,13 +1026,14 @@ public abstract partial class BaseSpawner : Item, ISpawner // Handle v10 migration - convert HomeRange to SpawnBounds now that Location is available if (_pendingHomeRangeMigrations.Remove(this, out var homeRange)) { + var surfaceZ = Map?.GetTopSurfaceZ(Location) ?? Location.Z; _spawnBounds = new Rectangle3D( Location.X - homeRange, Location.Y - homeRange, - sbyte.MinValue, + surfaceZ, homeRange * 2 + 1, homeRange * 2 + 1, - 256 + 17 ); } diff --git a/Projects/UOContent/Engines/Spawners/RegionSpawner.cs b/Projects/UOContent/Engines/Spawners/RegionSpawner.cs index 6d9ceacb8..a566e8d59 100644 --- a/Projects/UOContent/Engines/Spawners/RegionSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/RegionSpawner.cs @@ -98,19 +98,6 @@ public partial class RegionSpawner : Spawner return Location; } - bool waterMob, waterOnlyMob; - - if (spawned is Mobile mob) - { - waterMob = mob.CanSwim; - waterOnlyMob = mob.CanSwim && mob.CantWalk; - } - else - { - waterMob = false; - waterOnlyMob = false; - } - // Try 10 times to find a valid location. for (var i = 0; i < 10; i++) { @@ -118,6 +105,8 @@ public partial class RegionSpawner : Spawner 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++) { @@ -130,38 +119,20 @@ public partial class RegionSpawner : Spawner 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; } - var mapZ = map.GetAverageZ(x, y); - - if (waterMob) + 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)) { - if (IsValidWater(map, x, y, Z)) - { - return new Point3D(x, y, Z); - } - - if (IsValidWater(map, x, y, mapZ)) - { - return new Point3D(x, y, mapZ); - } - } - - if (!waterOnlyMob) - { - if (map.CanSpawnMobile(x, y, Z)) - { - return new Point3D(x, y, Z); - } - - if (map.CanSpawnMobile(x, y, mapZ)) - { - return new Point3D(x, y, mapZ); - } + return new Point3D(x, y, spawnZ); } } diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index 146639d0c..b5a781b43 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -34,31 +34,6 @@ public partial class Spawner : BaseSpawner { } - public static bool IsValidWater(Map map, int x, int y, int z) - { - if (!Region.Find(new Point3D(x, y, z), map).AllowSpawn() || !map.CanFit(x, y, z, 16, false, true, false)) - { - return false; - } - - var landTile = map.Tiles.GetLandTile(x, y); - - if (landTile.Z == z && (TileData.LandTable[landTile.ID & TileData.MaxLandValue].Flags & TileFlag.Wet) != 0) - { - return true; - } - - foreach (var staticTile in map.Tiles.GetStaticAndMultiTiles(x, y)) - { - if (staticTile.Z == z && TileData.ItemTable[staticTile.ID & TileData.MaxItemValue].Wet) - { - return true; - } - } - - return false; - } - /* public override bool OnDefragSpawn(ISpawnable spawned, bool remove) { @@ -83,68 +58,28 @@ public partial class Spawner : BaseSpawner return Location; } - bool waterMob, waterOnlyMob; - - if (spawned is Mobile mob) - { - waterMob = mob.CanSwim; - waterOnlyMob = mob.CanSwim && mob.CantWalk; - } - else - { - waterMob = false; - waterOnlyMob = false; - } - var bounds = SpawnBounds; - var hasBounds = bounds != default; + + // 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++) { - int x, y; + var x = Utility.RandomMinMax(bounds.Start.X, bounds.End.X - 1); + var y = Utility.RandomMinMax(bounds.Start.Y, bounds.End.Y - 1); - if (hasBounds) + 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)) { - // Use SpawnBounds for X/Y selection - x = Utility.RandomMinMax(bounds.Start.X, bounds.End.X - 1); - y = Utility.RandomMinMax(bounds.Start.Y, bounds.End.Y - 1); - } - else - { - // No bounds set - spawn at spawner location - x = Location.X; - y = Location.Y; - } - - // Note: Z-level logic uses spawner Z and map average Z. - // Multi-story building support (using bounds Z range) is planned for a future PR. - var mapZ = map.GetAverageZ(x, y); - - if (waterMob) - { - if (IsValidWater(map, x, y, Z)) - { - return new Point3D(x, y, Z); - } - - if (IsValidWater(map, x, y, mapZ)) - { - return new Point3D(x, y, mapZ); - } - } - - if (!waterOnlyMob) - { - if (map.CanSpawnMobile(x, y, Z)) - { - return new Point3D(x, y, Z); - } - - if (map.CanSpawnMobile(x, y, mapZ)) - { - return new Point3D(x, y, mapZ); - } + return new Point3D(x, y, spawnZ); } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/WalkRandomLogic.cs b/Projects/UOContent/Mobiles/AI/BaseAI/WalkRandomLogic.cs index e7d1af42f..249fa11e2 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/WalkRandomLogic.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/WalkRandomLogic.cs @@ -86,9 +86,13 @@ public abstract partial class BaseAI private void WalkRandomWithHome(int chanceToNotMove, int chanceToDir, int steps) { - if (Mobile.RangeHome == 0 && Mobile.Location != Mobile.Home) + if (Mobile.RangeHome == 0) { - DoMove(Mobile.GetDirectionTo(Mobile.Home)); + if (Mobile.Location != Mobile.Home) + { + DoMove(Mobile.GetDirectionTo(Mobile.Home)); + } + return; } diff --git a/version.json b/version.json index c752fe966..9a9ad6785 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.15.3" + "version": "0.15.4" }