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