ModernUO/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs
Kamron Batman 6d51b33cf8
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
2025-12-27 17:01:15 -08:00

372 lines
10 KiB
C#

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
}