feat: Add CanSpawnMobile overload with props Z-range support. (#2293)

### Summary

- Adds CanSpawnMobile(x, y, minZ, maxZ, canSwim, cantWalk, out spawnZ) overload for finding spawn surfaces within a Z range
- Adds CanSpawnItem(x, y, minZ, maxZ, out spawnZ) for item spawning with Surface+Impassable support (tables, furniture)
- Uses bitmask optimization inspired by Item.DropToWorld's m_OpenSlots pattern for O(1) surface/blocker checks
- HomeRange spawners now use surface detection to set proper Z bounds

### Key Changes

Map.cs:
- CanSpawnMobile with Z-range finds lowest valid surface for mobiles
- CanSpawnItem with Z-range finds lowest valid surface for items (including tables)
- CanFitItem for point-check item placement on Surface+Impassable tiles
- Bitmask approach eliminates nested loops and stackalloc arrays

Spawners:
- Simplified GetSpawnPosition using new Z-range methods
- HomeRange setter detects surface below spawner for proper Z bounds
- Consistent handling for mobiles and items

### Bug Fixes

- Water tiles (Impassable | Wet) no longer block swimming mobs
- Items can now spawn on tables/furniture (Surface+Impassable)

### Test Plan

- Run dotnet test - 631 tests pass
- Manual testing: multi-story spawning, water mobs, item spawning on tables
- Verify HomeRange spawner movement shifts bounds correctly
This commit is contained in:
Kamron Batman 2025-12-27 17:01:15 -08:00 committed by GitHub
parent ebaf104935
commit 6d51b33cf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1604 additions and 214 deletions

View file

@ -1,38 +1,43 @@
using System;
using System.Reflection;
using Xunit;
namespace Server.Tests;
/// <summary>
/// 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.
/// </summary>
[CollectionDefinition("Sequential Server Tests", DisableParallelization = true)]
public class ServerFixture : ICollectionFixture<ServerFixture>, IDisposable
{
/// <summary>
/// True if TileData was successfully loaded from client files.
/// </summary>
public static bool TileDataLoaded => TestServerInitializer.TileDataLoaded;
/// <summary>
/// A tile ID known to have the Surface flag. 0 if not found.
/// </summary>
public static ushort SurfaceTileId => TestServerInitializer.SurfaceTileId;
/// <summary>
/// A tile ID known to have the Impassable flag. 0 if not found.
/// </summary>
public static ushort ImpassableTileId => TestServerInitializer.ImpassableTileId;
/// <summary>
/// A tile ID known to have the Wet flag (water). 0 if not found.
/// </summary>
public static ushort WetTileId => TestServerInitializer.WetTileId;
/// <summary>
/// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found.
/// </summary>
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()

View file

@ -0,0 +1,176 @@
using System.IO;
using System.Reflection;
using System.Threading;
namespace Server.Tests;
/// <summary>
/// Shared server initialization logic for test fixtures.
/// Ensures the server is only initialized once across all test collections.
/// </summary>
public static class TestServerInitializer
{
private const string DefaultDataDirectory = @"C:\Ultima Online Classic";
private static bool _initialized;
private static readonly Lock _lock = new();
/// <summary>
/// True if TileData was successfully loaded from client files.
/// </summary>
public static bool TileDataLoaded { get; private set; }
/// <summary>
/// A tile ID known to have the Surface flag. 0 if not found.
/// </summary>
public static ushort SurfaceTileId { get; private set; }
/// <summary>
/// A tile ID known to have the Impassable flag. 0 if not found.
/// </summary>
public static ushort ImpassableTileId { get; private set; }
/// <summary>
/// A tile ID known to have the Wet flag (water). 0 if not found.
/// </summary>
public static ushort WetTileId { get; private set; }
/// <summary>
/// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found.
/// </summary>
public static ushort SurfaceImpassableTileId { get; private set; }
/// <summary>
/// Initializes the test server. Safe to call multiple times - only initializes once.
/// </summary>
/// <param name="loadTileData">If true, attempts to load TileData from client files.</param>
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;
}
}

View file

@ -7,6 +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.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View file

@ -0,0 +1,327 @@
using Server.Items;
using Xunit;
namespace Server.Tests.Tests.Maps;
/// <summary>
/// Tests for CanFitItem which allows Surface+Impassable tiles (tables, furniture) as valid surfaces.
/// </summary>
[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
}

View file

@ -0,0 +1,179 @@
using Xunit;
namespace Server.Tests.Tests.Maps;
/// <summary>
/// Tests for CanSpawnMobile that don't require TileData (client files).
/// For tests that require client files, see CanSpawnMobileTileDataTests.
/// </summary>
[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
}

View file

@ -0,0 +1,372 @@
using Server.Items;
using Xunit;
namespace Server.Tests.Tests.Maps;
/// <summary>
/// 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.
/// </summary>
[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
}