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:
parent
ebaf104935
commit
6d51b33cf8
15 changed files with 1604 additions and 214 deletions
|
|
@ -1,38 +1,43 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Reflection;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Server.Tests;
|
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)]
|
[CollectionDefinition("Sequential Server Tests", DisableParallelization = true)]
|
||||||
public class ServerFixture : ICollectionFixture<ServerFixture>, IDisposable
|
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()
|
public ServerFixture()
|
||||||
{
|
{
|
||||||
Core.ApplicationAssembly = Assembly.GetExecutingAssembly(); // Server.Tests.dll
|
TestServerInitializer.Initialize(loadTileData: true);
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
|
||||||
176
Projects/Server.Tests/Fixtures/TestServerInitializer.cs
Normal file
176
Projects/Server.Tests/Fixtures/TestServerInitializer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.SkippableFact" Version="1.5.23" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
|
||||||
327
Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs
Normal file
327
Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs
Normal 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
|
||||||
|
}
|
||||||
179
Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs
Normal file
179
Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs
Normal 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
|
||||||
|
}
|
||||||
372
Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs
Normal file
372
Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ public interface ISpawner : IEntity
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if the given location is within the spawn bounds.
|
/// Checks if the given location is within the spawn bounds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool IsInSpawnBounds(IPoint3D location);
|
bool IsInSpawnBounds(Point3D location);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ISpawnable : IEntity
|
public interface ISpawnable : IEntity
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Numerics;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Server.Buffers;
|
using Server.Buffers;
|
||||||
using Server.Collections;
|
using Server.Collections;
|
||||||
|
|
@ -469,6 +470,89 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
||||||
return surface;
|
return surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Z level of the highest surface that is at or below <paramref name="p" />.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="p">The reference point.</param>
|
||||||
|
/// <returns>The Z level of the surface, or p.Z if no surface is found below.</returns>
|
||||||
|
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)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Bound(int x, int y, out int newX, out int newY)
|
public void Bound(int x, int y, out int newX, out int newY)
|
||||||
{
|
{
|
||||||
|
|
@ -915,6 +999,91 @@ public sealed partial class Map : IComparable<Map>, ISpanFormattable, ISpanParsa
|
||||||
return !requireSurface || hasSurface;
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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(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);
|
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<Map>, ISpanFormattable, ISpanParsa
|
||||||
public bool CanSpawnMobile(int x, int y, int z) =>
|
public bool CanSpawnMobile(int x, int y, int z) =>
|
||||||
Region.Find(new Point3D(x, y, z), this).AllowSpawn() && CanFit(x, y, z, 16);
|
Region.Find(new Point3D(x, y, z), this).AllowSpawn() && CanFit(x, y, z, 16);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a valid spawn Z within the specified range by checking land, static, multi tiles, and world items.
|
||||||
|
/// Prefers the lowest valid surface (ground/floor over tables/platforms).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="x">X coordinate</param>
|
||||||
|
/// <param name="y">Y coordinate</param>
|
||||||
|
/// <param name="minZ">Minimum Z (inclusive)</param>
|
||||||
|
/// <param name="maxZ">Maximum Z (inclusive)</param>
|
||||||
|
/// <param name="canSwim">Whether the spawned entity can swim (water surfaces valid)</param>
|
||||||
|
/// <param name="cantWalk">Whether the spawned entity cannot walk (water-only)</param>
|
||||||
|
/// <param name="spawnZ">The valid spawn Z if found</param>
|
||||||
|
/// <returns>True if a valid spawn Z was found within the range</returns>
|
||||||
|
public bool CanSpawnMobile(int x, int y, int minZ, int maxZ, bool canSwim, bool cantWalk, out int spawnZ)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a valid spawn Z for items within the specified range.
|
||||||
|
/// Unlike CanSpawnMobile, treats Surface+Impassable tiles (tables, furniture) as valid surfaces.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="x">X coordinate</param>
|
||||||
|
/// <param name="y">Y coordinate</param>
|
||||||
|
/// <param name="minZ">Minimum Z (inclusive)</param>
|
||||||
|
/// <param name="maxZ">Maximum Z (inclusive)</param>
|
||||||
|
/// <param name="spawnZ">The valid spawn Z if found</param>
|
||||||
|
/// <returns>True if a valid spawn Z was found within the range</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a bitmask for a blocker range. Blocker blocks Z where blockLow < z < blockHigh.
|
||||||
|
/// Bits are relative to minZ, clamped to [0, 63].
|
||||||
|
/// </summary>
|
||||||
|
[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<Item>
|
private class ZComparer : IComparer<Item>
|
||||||
{
|
{
|
||||||
public static readonly ZComparer Default = new();
|
public static readonly ZComparer Default = new();
|
||||||
|
|
|
||||||
|
|
@ -300,7 +300,7 @@ public static class TileData
|
||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static unsafe void Load()
|
internal static unsafe void Load()
|
||||||
{
|
{
|
||||||
var filePath = Core.FindDataFile("tiledata.mul");
|
var filePath = Core.FindDataFile("tiledata.mul");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,10 @@ public abstract partial class BaseSpawner
|
||||||
_group = content.Group;
|
_group = content.Group;
|
||||||
_minDelay = content.MinDelay;
|
_minDelay = content.MinDelay;
|
||||||
_maxDelay = content.MaxDelay;
|
_maxDelay = content.MaxDelay;
|
||||||
|
_count = content.Count;
|
||||||
_team = content.Team;
|
_team = content.Team;
|
||||||
_end = content.End;
|
_running = content.Running;
|
||||||
|
_end = _running ? content.End : Core.Now;
|
||||||
|
|
||||||
// Defer SpawnBounds calculation to AfterDeserialization when Location is available
|
// Defer SpawnBounds calculation to AfterDeserialization when Location is available
|
||||||
if (content.HomeRange > 0)
|
if (content.HomeRange > 0)
|
||||||
|
|
|
||||||
|
|
@ -99,14 +99,19 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
}
|
}
|
||||||
set
|
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(
|
_spawnBounds = new Rectangle3D(
|
||||||
Location.X - value,
|
Location.X - value,
|
||||||
Location.Y - value,
|
Location.Y - value,
|
||||||
sbyte.MinValue,
|
surfaceZ,
|
||||||
value * 2 + 1,
|
value * 2 + 1,
|
||||||
value * 2 + 1,
|
value * 2 + 1,
|
||||||
256 // Full Z range: -128 to 127
|
17 // Z range: surface to surface + 16
|
||||||
);
|
);
|
||||||
this.MarkDirty();
|
this.MarkDirty();
|
||||||
InvalidateProperties();
|
InvalidateProperties();
|
||||||
|
|
@ -116,41 +121,38 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if SpawnBounds represents a HomeRange-style square centered on the spawner.
|
/// Returns true if SpawnBounds represents a HomeRange-style square centered on the spawner.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsHomeRangeStyle
|
public bool IsHomeRangeStyle => IsHomeRangeStyleAt(Location);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if SpawnBounds represents a HomeRange-style square centered on the given location.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsHomeRangeStyleAt(Point3D location)
|
||||||
{
|
{
|
||||||
get
|
if (_spawnBounds == default)
|
||||||
{
|
{
|
||||||
if (_spawnBounds == default)
|
return true; // No bounds = default HomeRange behavior
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if the given location is within the spawn bounds.
|
/// Checks if the given location is within the spawn bounds.
|
||||||
/// Virtual to allow RegionSpawner to override with region-based logic.
|
/// Virtual to allow RegionSpawner to override with region-based logic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual bool IsInSpawnBounds(IPoint3D location)
|
public virtual bool IsInSpawnBounds(Point3D location)
|
||||||
{
|
{
|
||||||
if (_spawnBounds == default)
|
return _spawnBounds == default || _spawnBounds.Contains(location);
|
||||||
{
|
|
||||||
return true; // No bounds = always in bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
return _spawnBounds.Contains(location);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public BaseSpawner() : this(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10))
|
public BaseSpawner() : this(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10))
|
||||||
|
|
@ -208,16 +210,17 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
{
|
{
|
||||||
_spawnBounds = spawnBounds;
|
_spawnBounds = spawnBounds;
|
||||||
}
|
}
|
||||||
// Fall back to homeRange with location for oldest format
|
|
||||||
else if (homeRange > 0 && json.GetProperty("location", options, out Point3D location))
|
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(
|
_spawnBounds = new Rectangle3D(
|
||||||
location.X - homeRange,
|
location.X - homeRange,
|
||||||
location.Y - homeRange,
|
location.Y - homeRange,
|
||||||
sbyte.MinValue,
|
location.Z,
|
||||||
homeRange * 2 + 1,
|
homeRange * 2 + 1,
|
||||||
homeRange * 2 + 1,
|
homeRange * 2 + 1,
|
||||||
256
|
17
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,39 +399,11 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
{
|
{
|
||||||
base.OnLocationChange(oldLocation);
|
base.OnLocationChange(oldLocation);
|
||||||
|
|
||||||
// Only shift bounds if they represent a HomeRange-style square
|
// Recalculate HomeRange-style bounds when spawner moves
|
||||||
// (spawner was centered and bounds are square)
|
if (IsHomeRangeStyleAt(oldLocation))
|
||||||
if (_spawnBounds == default)
|
|
||||||
{
|
{
|
||||||
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(
|
public SpawnerEntry AddEntry(
|
||||||
|
|
@ -833,7 +808,9 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
Spawned.Add(m, entry);
|
Spawned.Add(m, entry);
|
||||||
entry.AddToSpawned(m);
|
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.OnBeforeSpawn(spawnLocation, map);
|
||||||
m.MoveToWorld(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
|
// Handle v10 migration - convert HomeRange to SpawnBounds now that Location is available
|
||||||
if (_pendingHomeRangeMigrations.Remove(this, out var homeRange))
|
if (_pendingHomeRangeMigrations.Remove(this, out var homeRange))
|
||||||
{
|
{
|
||||||
|
var surfaceZ = Map?.GetTopSurfaceZ(Location) ?? Location.Z;
|
||||||
_spawnBounds = new Rectangle3D(
|
_spawnBounds = new Rectangle3D(
|
||||||
Location.X - homeRange,
|
Location.X - homeRange,
|
||||||
Location.Y - homeRange,
|
Location.Y - homeRange,
|
||||||
sbyte.MinValue,
|
surfaceZ,
|
||||||
homeRange * 2 + 1,
|
homeRange * 2 + 1,
|
||||||
homeRange * 2 + 1,
|
homeRange * 2 + 1,
|
||||||
256
|
17
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,19 +98,6 @@ public partial class RegionSpawner : Spawner
|
||||||
return Location;
|
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.
|
// Try 10 times to find a valid location.
|
||||||
for (var i = 0; i < 10; i++)
|
for (var i = 0; i < 10; i++)
|
||||||
{
|
{
|
||||||
|
|
@ -118,6 +105,8 @@ public partial class RegionSpawner : Spawner
|
||||||
|
|
||||||
var x = int.MinValue;
|
var x = int.MinValue;
|
||||||
var y = 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++)
|
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;
|
x = rect.Start.X + rand % rect.Width;
|
||||||
y = rect.Start.Y + 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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
rand -= curWeight;
|
rand -= curWeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mapZ = map.GetAverageZ(x, y);
|
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 (waterMob)
|
|
||||||
{
|
{
|
||||||
if (IsValidWater(map, x, y, Z))
|
return new Point3D(x, y, spawnZ);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
public override bool OnDefragSpawn(ISpawnable spawned, bool remove)
|
||||||
{
|
{
|
||||||
|
|
@ -83,68 +58,28 @@ public partial class Spawner : BaseSpawner
|
||||||
return Location;
|
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 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.
|
// Try 10 times to find a valid location.
|
||||||
for (var i = 0; i < 10; i++)
|
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
|
return new Point3D(x, y, spawnZ);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,13 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
private void WalkRandomWithHome(int chanceToNotMove, int chanceToDir, int steps)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
|
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
|
||||||
"version": "0.15.3"
|
"version": "0.15.4"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue