test: run tile-dependent tests on synthetic terrain instead of client files (#2725)
## Summary About 114 tests skipped on CI because they needed the official client files (`tiledata.mul`, map and multi files). They gave CI no signal, and locally they ran against whichever client a developer had installed. The test hosts now build their own world instead, so every test runs on CI and runs the same on every machine. CI simulation: Server.Tests 837 passed, UOContent.Tests 1131 passed, **0 skipped**. - **Generated tile data.** The fixtures write a pre-HS `tiledata.mul` from a small invented tile kit (`Server.Tests/Terrain/TestTiles`) and load it through the real `TileData.Load()`. The test hosts no longer read any client directory (`MODERNUO_CLIENT_PATH`, `MODERNUO_TEST_DATA_DIR` and `C:\Ultima Online Classic` are gone from test code). The file is written to `SyntheticData` in each test project's output, overwritten every run and removed by `dotnet clean`. - **Injected terrain.** `TestTerrain` stamps land and statics into Felucca and Trammel, which share one terrain. Writes after startup must go in a `TestTerrain.Scope()`, which puts back every block it touched and keeps the step cache fresh. - **Prefab scenes.** Seven named scenes (town, causeway, sewer walkway, coastline, deep sea, map-edge sea, an upstairs room with an L-shaped counter) replace the Britain coordinates the pathing, AI and fishing tests relied on. Each scene-based test asserts its scene is present first, so a missing scene fails instead of passing on flat ground. Stamping all seven takes 3-5 ms. - **Multi footprints.** Contrived footprints for the three multi IDs content looks up (0x74, 0x7A, 0x0) are seeded through a new internal `MultiData.SetComponents`. - **Fingerprint.** `StepCacheFile.HashTileDataFile` is extracted so a test can show that a one-flag change to the file changes the fingerprint, while runtime table mutation does not. ### Engine changes - `TileMatrix.GetLandBlock`/`GetStaticBlock`: on a map without its files, return the block injected with `SetLandBlock`/`SetStaticBlock`, or the default when nothing was injected. Maps with files take the same path as before. - `MultiData.SetComponents` (internal): registers a footprint without multi files. ### Test changes worth a look - Values that were properties of real Britain terrain (probes at Z 10, a pinned plain walk mask of 0xC1) are re-pinned to the synthetic plain (Z 0, 0xFF). - No tests were deleted. The multi walkability and boat-deck tests assert movement behaviour over a footprint, not what a real multi looks like, so they run on the contrived footprints. - Maps 0 and 1 keep file indexes 0 and 1, and every stamp writes both, rather than sharing one file index. On a map without files the getters never consult the shared-file cache, and its patch bits would hide one facet's injection from the other. ## Test plan - [x] `dotnet test -p:SolutionDir=... --filter "Category!=Manual"` with the client env vars pointed at an empty directory: 837 + 1131 passed, 0 skipped, 0 failed - [x] Mutation check: removing the step-cache clear from `TestTerrain` makes the stale-cache tests fail - [x] CI green on all platforms
This commit is contained in:
parent
00134cac33
commit
54c0040ac8
51 changed files with 1517 additions and 601 deletions
|
|
@ -1,41 +1,28 @@
|
|||
using Server.Tests.Terrain;
|
||||
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.
|
||||
/// Fixture for all server tests. The host runs on generated tile data and terrain (see <see cref="SyntheticWorld"/>).
|
||||
/// </summary>
|
||||
[CollectionDefinition("Sequential Server Tests", DisableParallelization = true)]
|
||||
public class ServerFixture : ICollectionFixture<ServerFixture>
|
||||
{
|
||||
/// <summary>
|
||||
/// True if TileData was successfully loaded from client files.
|
||||
/// </summary>
|
||||
public static bool TileDataLoaded => TestServerInitializer.TileDataLoaded;
|
||||
/// <summary>A tile with the Surface flag.</summary>
|
||||
public static ushort SurfaceTileId => TestTiles.Floor;
|
||||
|
||||
/// <summary>
|
||||
/// A tile ID known to have the Surface flag. 0 if not found.
|
||||
/// </summary>
|
||||
public static ushort SurfaceTileId => TestServerInitializer.SurfaceTileId;
|
||||
/// <summary>A tile with the Impassable flag.</summary>
|
||||
public static ushort ImpassableTileId => TestTiles.Rock;
|
||||
|
||||
/// <summary>
|
||||
/// A tile ID known to have the Impassable flag. 0 if not found.
|
||||
/// </summary>
|
||||
public static ushort ImpassableTileId => TestServerInitializer.ImpassableTileId;
|
||||
/// <summary>A tile with the Wet flag (water).</summary>
|
||||
public static ushort WetTileId => TestTiles.Water;
|
||||
|
||||
/// <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;
|
||||
/// <summary>A tile with both Surface and Impassable flags (tables, furniture).</summary>
|
||||
public static ushort SurfaceImpassableTileId => TestTiles.Counter;
|
||||
|
||||
// Global init runs exactly once via the shared, guarded TestServerInitializer. The single
|
||||
// bootstrap owns global state for the lifetime of the test host, so there is no
|
||||
// per-collection teardown — matching UOContent.Tests' TestServerInitializer pattern.
|
||||
public ServerFixture() => TestServerInitializer.Initialize(loadTileData: true);
|
||||
public ServerFixture() => TestServerInitializer.Initialize();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using Server.Items;
|
||||
using Server.Tests.Maps;
|
||||
using Server.Tests.Terrain;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
|
|
@ -13,51 +13,18 @@ namespace Server.Tests;
|
|||
/// </summary>
|
||||
internal 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)
|
||||
public static void Initialize()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
// Already initialized, but check if we need to load TileData now
|
||||
if (loadTileData && !TileDataLoaded && TileData.MaxItemValue == 0)
|
||||
{
|
||||
TryLoadTileData();
|
||||
DetectTileIds();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -66,11 +33,7 @@ internal static class TestServerInitializer
|
|||
// Load Configurations
|
||||
ServerConfiguration.Load(true);
|
||||
|
||||
// Try to load TileData if requested
|
||||
if (loadTileData)
|
||||
{
|
||||
TryLoadTileData();
|
||||
}
|
||||
SyntheticWorld.LoadData();
|
||||
|
||||
// Load an empty assembly list into the resolver
|
||||
ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory);
|
||||
|
|
@ -93,6 +56,7 @@ internal static class TestServerInitializer
|
|||
|
||||
// Configure / Initialize
|
||||
TestMapDefinitions.ConfigureTestMapDefinitions();
|
||||
SyntheticWorld.StampTerrain();
|
||||
|
||||
// Configure the world
|
||||
World.Configure();
|
||||
|
|
@ -102,90 +66,9 @@ internal static class TestServerInitializer
|
|||
|
||||
World.ExitSerializationThreads();
|
||||
|
||||
// Detect tile IDs if TileData was loaded
|
||||
if (loadTileData)
|
||||
{
|
||||
DetectTileIds();
|
||||
}
|
||||
|
||||
DecayScheduler.Configure();
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,8 @@
|
|||
<Target Name="CopyData" AfterTargets="AfterBuild">
|
||||
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />
|
||||
</Target>
|
||||
<!-- The test host writes its generated tile data here at runtime (SyntheticWorld), so Clean does not track it. -->
|
||||
<Target Name="CleanSyntheticData" AfterTargets="Clean">
|
||||
<RemoveDir Directories="$(OutDir)\SyntheticData" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
|
|
|||
306
Projects/Server.Tests/Terrain/Prefabs.cs
Normal file
306
Projects/Server.Tests/Terrain/Prefabs.cs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
/// <summary>
|
||||
/// A named scene stamped once into the shared synthetic terrain. Tests that rely on one call
|
||||
/// <see cref="AssertPresent"/> first, so a missing scene fails instead of passing on flat default ground.
|
||||
/// </summary>
|
||||
public sealed class Prefab
|
||||
{
|
||||
private readonly Action<Prefab> _stamp;
|
||||
private readonly Func<Prefab, bool> _isPresent;
|
||||
|
||||
public Prefab(string name, Rectangle2D bounds, Point3D anchor, Action<Prefab> stamp, Func<Prefab, bool> isPresent)
|
||||
{
|
||||
Name = name;
|
||||
Bounds = bounds;
|
||||
Anchor = anchor;
|
||||
_stamp = stamp;
|
||||
_isPresent = isPresent;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public Rectangle2D Bounds { get; }
|
||||
|
||||
/// <summary>The cell a test starts from, at its standing Z.</summary>
|
||||
public Point3D Anchor { get; }
|
||||
|
||||
/// <summary>A second named cell, for scenes with a start and a goal.</summary>
|
||||
public Point3D Target { get; init; }
|
||||
|
||||
internal void Stamp() => _stamp(this);
|
||||
|
||||
public void AssertPresent()
|
||||
{
|
||||
if (!_isPresent(this))
|
||||
{
|
||||
throw new InvalidOperationException($"Prefab {Name} is not stamped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shared scenes. Each lives in its own part of the region (256, 256)-(1280, 1280), and they never
|
||||
/// overlap. <see cref="MapEdgeSea"/> is the exception: it has to touch the map edge. Bespoke terrain belongs
|
||||
/// outside that region; only tests about prefabs themselves stamp over one.
|
||||
/// </summary>
|
||||
public static class Prefabs
|
||||
{
|
||||
private static bool _stamped;
|
||||
|
||||
// Sea to the west at Z -5, grass to the east at Z 0. Anchor: the first grass cell east of the water.
|
||||
public static Prefab Coastline { get; } = new(
|
||||
"Coastline",
|
||||
new Rectangle2D(256, 256, 32, 32),
|
||||
new Point3D(272, 272, 0),
|
||||
p =>
|
||||
{
|
||||
TestTerrain.FillLand(new Rectangle2D(p.Bounds.X, p.Bounds.Y, 16, 32), TestTiles.SeaWater, -5);
|
||||
TestTerrain.FillLand(new Rectangle2D(p.Bounds.X + 16, p.Bounds.Y, 16, 32), TestTiles.Grass, 0);
|
||||
},
|
||||
p => IsLand(p.Anchor.X, p.Anchor.Y, TestTiles.Grass, 0) && IsLand(p.Anchor.X - 1, p.Anchor.Y, TestTiles.SeaWater, -5)
|
||||
);
|
||||
|
||||
// Open water 64 tiles square, ringed by the default land. Anchor: the middle.
|
||||
public static Prefab DeepSea { get; } = new(
|
||||
"DeepSea",
|
||||
new Rectangle2D(320, 256, 64, 64),
|
||||
new Point3D(352, 288, -5),
|
||||
p => TestTerrain.FillLand(p.Bounds, TestTiles.SeaWater, -5),
|
||||
p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.SeaWater, -5) &&
|
||||
IsLand(p.Bounds.End.X - 1, p.Bounds.End.Y - 1, TestTiles.SeaWater, -5) &&
|
||||
IsLand(p.Anchor.X, p.Anchor.Y, TestTiles.SeaWater, -5)
|
||||
);
|
||||
|
||||
// Open water against the west edge of the map. Anchor: 13 tiles in from the edge.
|
||||
public static Prefab MapEdgeSea { get; } = new(
|
||||
"MapEdgeSea",
|
||||
new Rectangle2D(0, 176, 48, 48),
|
||||
new Point3D(13, 200, -5),
|
||||
p => TestTerrain.FillLand(p.Bounds, TestTiles.SeaWater, -5),
|
||||
p => IsLand(0, p.Anchor.Y, TestTiles.SeaWater, -5) && IsLand(p.Bounds.End.X - 1, p.Anchor.Y, TestTiles.SeaWater, -5)
|
||||
);
|
||||
|
||||
// Two walled buildings on open ground: one with a doorway and a counter inside, one with a window, and a
|
||||
// ramp climbing two Z a tile to a walled deck at Z 20 over open ground, so its cells stand at two Zs.
|
||||
// Anchor: the ground just outside the first building's doorway.
|
||||
public static Prefab Town { get; } = new(
|
||||
"Town",
|
||||
new Rectangle2D(448, 256, 32, 32),
|
||||
new Point3D(455, 271, 0),
|
||||
p =>
|
||||
{
|
||||
int x0 = p.Bounds.X, y0 = p.Bounds.Y;
|
||||
|
||||
WallRing(x0 + 2, y0 + 2, x0 + 13, y0 + 13, 0, x0 + 7, y0 + 13);
|
||||
for (var x = x0 + 4; x <= x0 + 10; x++)
|
||||
{
|
||||
TestTerrain.AddStatic(x, y0 + 6, TestTiles.Counter, 0);
|
||||
}
|
||||
|
||||
WallRing(x0 + 24, y0 + 20, x0 + 29, y0 + 25, 0, x0 + 26, y0 + 20);
|
||||
TestTerrain.ClearStatics(x0 + 29, y0 + 22);
|
||||
TestTerrain.AddStatic(x0 + 29, y0 + 22, TestTiles.Window, 0);
|
||||
|
||||
// The ramp runs north up x0 + 17 from y0 + 20 (Z 2) to y0 + 11 (Z 20), onto the deck.
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
TestTerrain.AddStatic(x0 + 17, y0 + 20 - i, TestTiles.Floor, (sbyte)(2 + 2 * i));
|
||||
}
|
||||
|
||||
for (var x = x0 + 15; x <= x0 + 20; x++)
|
||||
{
|
||||
for (var y = y0 + 2; y <= y0 + 10; y++)
|
||||
{
|
||||
var edge = x == x0 + 15 || x == x0 + 20 || y == y0 + 2 || y == y0 + 10;
|
||||
var ramp = x == x0 + 17 && y == y0 + 10;
|
||||
TestTerrain.AddStatic(x, y, edge && !ramp ? TestTiles.Wall : TestTiles.Floor, 20);
|
||||
}
|
||||
}
|
||||
},
|
||||
p => IsStatic(p.Bounds.X + 2, p.Bounds.Y + 2, TestTiles.Wall, 0) &&
|
||||
IsStatic(p.Bounds.X + 17, p.Bounds.Y + 5, TestTiles.Floor, 20)
|
||||
);
|
||||
|
||||
// Impassable rock crossed by a T of bridge tiles. Anchor: on the bridge.
|
||||
public static Prefab Causeway { get; } = new(
|
||||
"Causeway",
|
||||
new Rectangle2D(512, 256, 32, 32),
|
||||
new Point3D(516, 272, 1),
|
||||
p =>
|
||||
{
|
||||
int x0 = p.Bounds.X, y0 = p.Bounds.Y;
|
||||
TestTerrain.FillLand(p.Bounds, TestTiles.RockLand, 0);
|
||||
|
||||
for (var x = x0; x < x0 + 32; x++)
|
||||
{
|
||||
for (var y = y0 + 15; y <= y0 + 17; y++)
|
||||
{
|
||||
TestTerrain.AddStatic(x, y, TestTiles.Bridge, 0);
|
||||
}
|
||||
}
|
||||
|
||||
for (var y = y0; y < y0 + 15; y++)
|
||||
{
|
||||
for (var x = x0 + 15; x <= x0 + 17; x++)
|
||||
{
|
||||
TestTerrain.AddStatic(x, y, TestTiles.Bridge, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.RockLand, 0) && IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Bridge, 0)
|
||||
);
|
||||
|
||||
// Impassable rock under a square loop of walkway three tiles wide at Z 5, the only ground to stand on.
|
||||
// Anchor: on the walkway.
|
||||
public static Prefab SewerWalkway { get; } = new(
|
||||
"SewerWalkway",
|
||||
new Rectangle2D(576, 256, 32, 32),
|
||||
new Point3D(579, 271, 5),
|
||||
p =>
|
||||
{
|
||||
int x0 = p.Bounds.X, y0 = p.Bounds.Y;
|
||||
TestTerrain.FillLand(p.Bounds, TestTiles.RockLand, 0);
|
||||
|
||||
for (var x = x0 + 2; x <= x0 + 29; x++)
|
||||
{
|
||||
for (var y = y0 + 2; y <= y0 + 29; y++)
|
||||
{
|
||||
var onLoop = x <= x0 + 4 || x >= x0 + 27 || y <= y0 + 4 || y >= y0 + 27;
|
||||
if (onLoop)
|
||||
{
|
||||
TestTerrain.AddStatic(x, y, TestTiles.Floor, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.RockLand, 0) && IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Floor, 5)
|
||||
);
|
||||
|
||||
// A walled upper floor at Z 20 with an L-shaped counter between the south and north halves; the only way
|
||||
// round is past the east end. Anchor: south of the counter. Target: north of it.
|
||||
public static Prefab InnUpstairs { get; } = new(
|
||||
"InnUpstairs",
|
||||
new Rectangle2D(640, 256, 24, 24),
|
||||
new Point3D(648, 271, 20),
|
||||
p =>
|
||||
{
|
||||
int x0 = p.Bounds.X, y0 = p.Bounds.Y;
|
||||
|
||||
for (var x = x0; x < x0 + 24; x++)
|
||||
{
|
||||
for (var y = y0; y < y0 + 24; y++)
|
||||
{
|
||||
var edge = x == x0 || x == x0 + 23 || y == y0 || y == y0 + 23;
|
||||
TestTerrain.AddStatic(x, y, edge ? TestTiles.Wall : TestTiles.Floor, 20);
|
||||
}
|
||||
}
|
||||
|
||||
for (var x = x0 + 1; x <= x0 + 16; x++)
|
||||
{
|
||||
TestTerrain.AddStatic(x, y0 + 11, TestTiles.Counter, 20);
|
||||
}
|
||||
|
||||
for (var y = y0 + 12; y <= y0 + 14; y++)
|
||||
{
|
||||
TestTerrain.AddStatic(x0 + 16, y, TestTiles.Counter, 20);
|
||||
}
|
||||
},
|
||||
p => IsStatic(p.Bounds.X + 8, p.Bounds.Y + 11, TestTiles.Counter, 20) &&
|
||||
IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Floor, 20)
|
||||
)
|
||||
{
|
||||
Target = new Point3D(649, 262, 20)
|
||||
};
|
||||
|
||||
public static IReadOnlyList<Prefab> All { get; } =
|
||||
[Coastline, DeepSea, MapEdgeSea, Town, Causeway, SewerWalkway, InnUpstairs];
|
||||
|
||||
public static Prefab Named(string name)
|
||||
{
|
||||
for (var i = 0; i < All.Count; i++)
|
||||
{
|
||||
if (All[i].Name == name)
|
||||
{
|
||||
return All[i];
|
||||
}
|
||||
}
|
||||
|
||||
throw new ArgumentException($"No prefab named {name}", nameof(name));
|
||||
}
|
||||
|
||||
/// <summary>Stamps every prefab once, then refuses terrain writes outside a <see cref="TestTerrain.Scope"/>.</summary>
|
||||
public static void StampAll()
|
||||
{
|
||||
if (_stamped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < All.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < All.Count; j++)
|
||||
{
|
||||
if (Overlaps(All[i].Bounds, All[j].Bounds))
|
||||
{
|
||||
throw new InvalidOperationException($"Prefabs {All[i].Name} and {All[j].Name} overlap");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < All.Count; i++)
|
||||
{
|
||||
All[i].Stamp();
|
||||
}
|
||||
|
||||
_stamped = true;
|
||||
TestTerrain.Sealed = true;
|
||||
}
|
||||
|
||||
// A ring of Wall from (x1, y1) to (x2, y2) inclusive, with one open cell at (gapX, gapY).
|
||||
private static void WallRing(int x1, int y1, int x2, int y2, sbyte z, int gapX, int gapY)
|
||||
{
|
||||
for (var x = x1; x <= x2; x++)
|
||||
{
|
||||
for (var y = y1; y <= y2; y++)
|
||||
{
|
||||
var edge = x == x1 || x == x2 || y == y1 || y == y2;
|
||||
if (edge && (x != gapX || y != gapY))
|
||||
{
|
||||
TestTerrain.AddStatic(x, y, TestTiles.Wall, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Overlaps(Rectangle2D a, Rectangle2D b) =>
|
||||
a.Start.X < b.End.X && b.Start.X < a.End.X && a.Start.Y < b.End.Y && b.Start.Y < a.End.Y;
|
||||
|
||||
// Both facets must hold the tile; a scene on only one of them is not present.
|
||||
private static bool IsLand(int x, int y, short id, sbyte z)
|
||||
{
|
||||
var felucca = Map.Felucca.Tiles.GetLandTile(x, y);
|
||||
var trammel = Map.Trammel.Tiles.GetLandTile(x, y);
|
||||
return felucca.ID == id && felucca.Z == z && trammel.ID == id && trammel.Z == z;
|
||||
}
|
||||
|
||||
private static bool IsStatic(int x, int y, ushort id, sbyte z) =>
|
||||
HasStatic(Map.Felucca, x, y, id, z) && HasStatic(Map.Trammel, x, y, id, z);
|
||||
|
||||
private static bool HasStatic(Map map, int x, int y, ushort id, sbyte z)
|
||||
{
|
||||
var cell = map.Tiles.GetStaticBlock(x >> 3, y >> 3)[x & 7][y & 7];
|
||||
for (var i = 0; i < cell.Length; i++)
|
||||
{
|
||||
if (cell[i].ID == id && cell[i].Z == z)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
108
Projects/Server.Tests/Terrain/SyntheticTileData.cs
Normal file
108
Projects/Server.Tests/Terrain/SyntheticTileData.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
/// <summary>
|
||||
/// Writes <see cref="TestTiles"/> as a pre-HS tiledata.mul, the smallest layout <see cref="TileData"/> reads:
|
||||
/// 0x4000 land records of 26 bytes and 0x4000 item records of 37 bytes, with a 4-byte header before every 32.
|
||||
/// The file must stay under 1,644,544 bytes or TileData.Load picks the 7.0 layout.
|
||||
/// </summary>
|
||||
public static class SyntheticTileData
|
||||
{
|
||||
private const int LandCount = 0x4000;
|
||||
private const int ItemCount = 0x4000;
|
||||
private const int NameLength = 20;
|
||||
|
||||
public static byte[] Build(Action<TileFlag[]> mutateItemFlags = null)
|
||||
{
|
||||
var landFlags = new TileFlag[LandCount];
|
||||
var landNames = new string[LandCount];
|
||||
for (var i = 0; i < TestTiles.Land.Count; i++)
|
||||
{
|
||||
var tile = TestTiles.Land[i];
|
||||
landFlags[tile.Id] = tile.Flags;
|
||||
landNames[tile.Id] = tile.Name;
|
||||
}
|
||||
|
||||
var itemFlags = new TileFlag[ItemCount];
|
||||
var itemNames = new string[ItemCount];
|
||||
var itemHeights = new byte[ItemCount];
|
||||
for (var i = 0; i < TestTiles.Items.Count; i++)
|
||||
{
|
||||
var tile = TestTiles.Items[i];
|
||||
itemFlags[tile.Id] = tile.Flags;
|
||||
itemNames[tile.Id] = tile.Name;
|
||||
itemHeights[tile.Id] = tile.Height;
|
||||
}
|
||||
|
||||
mutateItemFlags?.Invoke(itemFlags);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream);
|
||||
|
||||
for (var i = 0; i < LandCount; i++)
|
||||
{
|
||||
if ((i & 0x1F) == 0)
|
||||
{
|
||||
writer.Write(0); // header
|
||||
}
|
||||
|
||||
writer.Write((uint)landFlags[i]);
|
||||
writer.Write((short)0); // texture
|
||||
WriteName(writer, landNames[i]);
|
||||
}
|
||||
|
||||
for (var i = 0; i < ItemCount; i++)
|
||||
{
|
||||
if ((i & 0x1F) == 0)
|
||||
{
|
||||
writer.Write(0); // header
|
||||
}
|
||||
|
||||
writer.Write((uint)itemFlags[i]);
|
||||
writer.Write((byte)0); // weight
|
||||
writer.Write((byte)0); // quality
|
||||
writer.Write((ushort)0); // animation
|
||||
writer.Write((byte)0);
|
||||
writer.Write((byte)0); // quantity
|
||||
writer.Write(0);
|
||||
writer.Write((byte)0);
|
||||
writer.Write((byte)0); // value
|
||||
writer.Write(itemHeights[i]);
|
||||
WriteName(writer, itemNames[i]);
|
||||
}
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Writes tiledata.mul into <paramref name="directory"/> and returns its path.</summary>
|
||||
public static string WriteTo(string directory, Action<TileFlag[]> mutateItemFlags = null)
|
||||
{
|
||||
var path = Path.Combine(directory, "tiledata.mul");
|
||||
File.WriteAllBytes(path, Build(mutateItemFlags));
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Makes <paramref name="directory"/> the only data directory and loads its tiledata.mul.</summary>
|
||||
public static void LoadInto(string directory)
|
||||
{
|
||||
ServerConfiguration.DataDirectories.Clear();
|
||||
ServerConfiguration.DataDirectories.Add(directory);
|
||||
TileData.Load();
|
||||
}
|
||||
|
||||
private static void WriteName(BinaryWriter writer, string name)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[NameLength];
|
||||
buffer.Clear();
|
||||
if (name != null)
|
||||
{
|
||||
Encoding.ASCII.GetBytes(name, buffer);
|
||||
}
|
||||
|
||||
writer.Write(buffer);
|
||||
}
|
||||
}
|
||||
27
Projects/Server.Tests/Terrain/SyntheticWorld.cs
Normal file
27
Projects/Server.Tests/Terrain/SyntheticWorld.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.IO;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
/// <summary>
|
||||
/// Boots a test host on generated data instead of client files, so every machine and CI see the same world.
|
||||
/// </summary>
|
||||
public static class SyntheticWorld
|
||||
{
|
||||
/// <summary>Where the generated data files are written: the test host's output, overwritten every run.</summary>
|
||||
public static string DataDirectory => Path.Combine(Core.BaseDirectory, "SyntheticData");
|
||||
|
||||
/// <summary>
|
||||
/// Writes and loads the generated tiledata.mul from <see cref="DataDirectory"/>, which becomes the only data
|
||||
/// directory, and seeds the multi footprints. Run before anything reads <see cref="TileData"/>.
|
||||
/// </summary>
|
||||
public static void LoadData()
|
||||
{
|
||||
Directory.CreateDirectory(DataDirectory);
|
||||
SyntheticTileData.WriteTo(DataDirectory);
|
||||
SyntheticTileData.LoadInto(DataDirectory);
|
||||
TestMultis.Seed();
|
||||
}
|
||||
|
||||
/// <summary>Stamps the prefab scenes. Run once Felucca and Trammel are registered.</summary>
|
||||
public static void StampTerrain() => Prefabs.StampAll();
|
||||
}
|
||||
63
Projects/Server.Tests/Terrain/TestMultis.cs
Normal file
63
Projects/Server.Tests/Terrain/TestMultis.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
/// <summary>
|
||||
/// Invented footprints for the multi IDs content looks up through <see cref="MultiData"/>
|
||||
/// (HouseFoundation, BaseBoat and the pathing tests). Only the IDs are real; the shapes are not.
|
||||
/// Needs tile data loaded first: <see cref="MultiComponentList"/> masks IDs with TileData.MaxItemValue.
|
||||
/// </summary>
|
||||
public static class TestMultis
|
||||
{
|
||||
public const int GuildHouse = 0x74;
|
||||
public const int Tower = 0x7A;
|
||||
public const int SmallBoatNorth = 0x0;
|
||||
|
||||
// MultiComponentList drops every entry after the first whose flags are 0.
|
||||
private const TileFlag Visible = TileFlag.Background;
|
||||
|
||||
private const short HouseFloorZ = 1;
|
||||
|
||||
public static void Seed()
|
||||
{
|
||||
MultiData.SetComponents(GuildHouse, WalledHouse(3, doorwayOnSouth: true));
|
||||
MultiData.SetComponents(Tower, WalledHouse(4, doorwayOnSouth: false));
|
||||
MultiData.SetComponents(SmallBoatNorth, Boat());
|
||||
}
|
||||
|
||||
// A square of Floor ringed by Wall, (2 * radius + 1) tiles a side, with a one-tile doorway of Floor
|
||||
// in the middle of the south wall, or of the east wall. The floor sits one Z above the multi so it is
|
||||
// clear of flat ground (MultiMaskCache.ComputeFootprintClean) yet still one step up from it.
|
||||
private static MultiComponentList WalledHouse(int radius, bool doorwayOnSouth)
|
||||
{
|
||||
var entries = new List<MultiTileEntry>();
|
||||
for (var x = -radius; x <= radius; x++)
|
||||
{
|
||||
for (var y = -radius; y <= radius; y++)
|
||||
{
|
||||
var edge = x == -radius || x == radius || y == -radius || y == radius;
|
||||
var doorway = doorwayOnSouth ? x == 0 && y == radius : x == radius && y == 0;
|
||||
var id = edge && !doorway ? TestTiles.Wall : TestTiles.Floor;
|
||||
entries.Add(new MultiTileEntry(id, (short)x, (short)y, HouseFloorZ, Visible));
|
||||
}
|
||||
}
|
||||
|
||||
return new MultiComponentList(entries);
|
||||
}
|
||||
|
||||
// A deck of Floor three tiles wide and seven long, with Wall rails down both sides.
|
||||
private static MultiComponentList Boat()
|
||||
{
|
||||
var entries = new List<MultiTileEntry>();
|
||||
for (var x = -2; x <= 2; x++)
|
||||
{
|
||||
for (var y = -3; y <= 3; y++)
|
||||
{
|
||||
var id = x == -2 || x == 2 ? TestTiles.Wall : TestTiles.Floor;
|
||||
entries.Add(new MultiTileEntry(id, (short)x, (short)y, 0, Visible));
|
||||
}
|
||||
}
|
||||
|
||||
return new MultiComponentList(entries);
|
||||
}
|
||||
}
|
||||
215
Projects/Server.Tests/Terrain/TestTerrain.cs
Normal file
215
Projects/Server.Tests/Terrain/TestTerrain.cs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
/// <summary>
|
||||
/// Stamps land and statics into the synthetic terrain. Felucca and Trammel share one terrain, so every write
|
||||
/// goes to both. Once <see cref="Prefabs.StampAll"/> has run, writes are only allowed inside a
|
||||
/// <see cref="Scope"/>, which puts back every block it touched when disposed.
|
||||
/// </summary>
|
||||
public static class TestTerrain
|
||||
{
|
||||
// Blocks this class allocated, keyed by matrix and block. Anything else is the matrix's shared default
|
||||
// block, which must never be written to.
|
||||
private static readonly Dictionary<(TileMatrix, int, int), LandTile[]> _land = [];
|
||||
private static readonly Dictionary<(TileMatrix, int, int), StaticTile[][][]> _statics = [];
|
||||
|
||||
private static TerrainScope _scope;
|
||||
|
||||
internal static bool Sealed { get; set; }
|
||||
|
||||
/// <param name="caller">Filled in by the compiler; names the opener if the scope is left open.</param>
|
||||
/// <param name="callerFile">Filled in by the compiler.</param>
|
||||
public static TerrainScope Scope([CallerMemberName] string caller = null, [CallerFilePath] string callerFile = null)
|
||||
{
|
||||
if (_scope != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A terrain scope opened by {_scope.Opener} is still open; dispose it (a using) first."
|
||||
);
|
||||
}
|
||||
|
||||
// Cached step masks describe the terrain as it was; drop them so nothing serves the old cells.
|
||||
StepCache.Instance.Clear();
|
||||
return _scope = new TerrainScope($"{Path.GetFileNameWithoutExtension(callerFile)}.{caller}");
|
||||
}
|
||||
|
||||
public static void SetLand(int x, int y, short id, sbyte z)
|
||||
{
|
||||
var facets = Facets();
|
||||
for (var i = 0; i < facets.Length; i++)
|
||||
{
|
||||
LandBlock(facets[i].Tiles, x >> 3, y >> 3)[((y & 7) << 3) + (x & 7)] = new LandTile(id, z);
|
||||
}
|
||||
}
|
||||
|
||||
public static void FillLand(Rectangle2D area, short id, sbyte z)
|
||||
{
|
||||
for (var x = area.Start.X; x < area.End.X; x++)
|
||||
{
|
||||
for (var y = area.Start.Y; y < area.End.Y; y++)
|
||||
{
|
||||
SetLand(x, y, id, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void AddStatic(int x, int y, ushort id, sbyte z)
|
||||
{
|
||||
var facets = Facets();
|
||||
for (var i = 0; i < facets.Length; i++)
|
||||
{
|
||||
var column = StaticBlock(facets[i].Tiles, x >> 3, y >> 3)[x & 7];
|
||||
column[y & 7] = [..column[y & 7], new StaticTile(id, z)];
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearStatics(int x, int y)
|
||||
{
|
||||
var facets = Facets();
|
||||
for (var i = 0; i < facets.Length; i++)
|
||||
{
|
||||
StaticBlock(facets[i].Tiles, x >> 3, y >> 3)[x & 7][y & 7] = [];
|
||||
}
|
||||
}
|
||||
|
||||
internal static void EndScope(TerrainScope scope)
|
||||
{
|
||||
foreach (var ((tiles, bx, by), original) in scope.Land)
|
||||
{
|
||||
if (original == null)
|
||||
{
|
||||
_land.Remove((tiles, bx, by));
|
||||
}
|
||||
else
|
||||
{
|
||||
_land[(tiles, bx, by)] = original;
|
||||
}
|
||||
|
||||
tiles.SetLandBlock(bx, by, original);
|
||||
}
|
||||
|
||||
foreach (var ((tiles, bx, by), original) in scope.Statics)
|
||||
{
|
||||
if (original == null)
|
||||
{
|
||||
_statics.Remove((tiles, bx, by));
|
||||
}
|
||||
else
|
||||
{
|
||||
_statics[(tiles, bx, by)] = original;
|
||||
}
|
||||
|
||||
tiles.SetStaticBlock(bx, by, original);
|
||||
}
|
||||
|
||||
_scope = null;
|
||||
StepCache.Instance.Clear();
|
||||
}
|
||||
|
||||
private static Map[] Facets()
|
||||
{
|
||||
if (Sealed && _scope == null)
|
||||
{
|
||||
throw new InvalidOperationException("Stamp terrain inside TestTerrain.Scope() so it is put back afterwards.");
|
||||
}
|
||||
|
||||
// A test may query the cache and then stamp more; masks built before the stamp must not survive it.
|
||||
if (_scope != null)
|
||||
{
|
||||
StepCache.Instance.Clear();
|
||||
}
|
||||
|
||||
return [Map.Felucca, Map.Trammel];
|
||||
}
|
||||
|
||||
private static LandTile[] LandBlock(TileMatrix tiles, int bx, int by)
|
||||
{
|
||||
var key = (tiles, bx, by);
|
||||
_land.TryGetValue(key, out var owned);
|
||||
|
||||
// The scope keeps the block as it was and the stamp goes into a copy, so disposing is a swap back.
|
||||
if (_scope != null && !_scope.Land.ContainsKey(key))
|
||||
{
|
||||
_scope.Land[key] = owned;
|
||||
owned = (LandTile[])owned?.Clone();
|
||||
}
|
||||
else if (owned != null)
|
||||
{
|
||||
return owned;
|
||||
}
|
||||
|
||||
var block = owned ?? new LandTile[64];
|
||||
_land[key] = block;
|
||||
tiles.SetLandBlock(bx, by, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
private static StaticTile[][][] StaticBlock(TileMatrix tiles, int bx, int by)
|
||||
{
|
||||
var key = (tiles, bx, by);
|
||||
_statics.TryGetValue(key, out var owned);
|
||||
|
||||
if (_scope != null && !_scope.Statics.ContainsKey(key))
|
||||
{
|
||||
_scope.Statics[key] = owned;
|
||||
owned = owned == null ? null : CopyStatics(owned);
|
||||
}
|
||||
else if (owned != null)
|
||||
{
|
||||
return owned;
|
||||
}
|
||||
|
||||
var block = owned ?? CopyStatics(tiles.EmptyStaticBlock);
|
||||
_statics[key] = block;
|
||||
tiles.SetStaticBlock(bx, by, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
private static StaticTile[][][] CopyStatics(StaticTile[][][] source)
|
||||
{
|
||||
var copy = new StaticTile[8][][];
|
||||
for (var x = 0; x < 8; x++)
|
||||
{
|
||||
copy[x] = new StaticTile[8][];
|
||||
for (var y = 0; y < 8; y++)
|
||||
{
|
||||
copy[x][y] = source[x][y];
|
||||
}
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bespoke terrain for one test. Dispose it (a <c>using</c>) to put back every block it touched.
|
||||
/// </summary>
|
||||
public sealed class TerrainScope : IDisposable
|
||||
{
|
||||
// The block each key held before this scope first wrote to it; null means it was the default.
|
||||
internal Dictionary<(TileMatrix, int, int), LandTile[]> Land { get; } = [];
|
||||
internal Dictionary<(TileMatrix, int, int), StaticTile[][][]> Statics { get; } = [];
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
internal TerrainScope(string opener) => Opener = opener;
|
||||
|
||||
/// <summary>The test that opened this scope, as Class.Method.</summary>
|
||||
public string Opener { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
TestTerrain.EndScope(this);
|
||||
}
|
||||
}
|
||||
61
Projects/Server.Tests/Terrain/TestTiles.cs
Normal file
61
Projects/Server.Tests/Terrain/TestTiles.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
public readonly record struct TestItemTile(ushort Id, string Name, TileFlag Flags, byte Height);
|
||||
|
||||
public readonly record struct TestLandTile(short Id, string Name, TileFlag Flags);
|
||||
|
||||
/// <summary>
|
||||
/// The invented tiles the generated tiledata.mul holds. Every other entry in the file is zero.
|
||||
/// IDs are arbitrary and carry no meaning from the client, except the content-referenced land below.
|
||||
/// </summary>
|
||||
public static class TestTiles
|
||||
{
|
||||
public const ushort Wall = 0x3000;
|
||||
public const ushort Floor = 0x3001;
|
||||
public const ushort Counter = 0x3002;
|
||||
public const ushort Stair = 0x3003;
|
||||
public const ushort Bridge = 0x3004;
|
||||
public const ushort Roof = 0x3005;
|
||||
public const ushort Door = 0x3006;
|
||||
public const ushort Window = 0x3007;
|
||||
public const ushort Water = 0x3008;
|
||||
public const ushort Rock = 0x3009;
|
||||
public const ushort Plain = 0x300A;
|
||||
|
||||
public const short Grass = 0x3000;
|
||||
public const short RockLand = 0x3001;
|
||||
public const short Dirt = 0x3002;
|
||||
|
||||
// Fishing only treats these land IDs as water (Fishing.cs _waterLandTiles), so they keep their numbers.
|
||||
public const short SeaWater = 0xA8;
|
||||
|
||||
public static IReadOnlyList<TestItemTile> Items { get; } =
|
||||
[
|
||||
new(Wall, "test wall", TileFlag.Wall | TileFlag.Impassable | TileFlag.NoShoot, 20),
|
||||
new(Floor, "test floor", TileFlag.Surface | TileFlag.Background, 0),
|
||||
new(Counter, "test counter", TileFlag.Surface | TileFlag.Impassable, 4),
|
||||
new(Stair, "test stair", TileFlag.Bridge | TileFlag.Surface | TileFlag.StairBack, 5),
|
||||
new(Bridge, "test bridge", TileFlag.Bridge | TileFlag.Surface, 2),
|
||||
new(Roof, "test roof", TileFlag.Roof | TileFlag.Impassable, 0),
|
||||
new(Door, "test door", TileFlag.Door | TileFlag.Impassable | TileFlag.NoShoot, 20),
|
||||
new(Window, "test window", TileFlag.Window | TileFlag.Wall | TileFlag.Impassable, 20),
|
||||
new(Water, "test water", TileFlag.Wet | TileFlag.Impassable | TileFlag.Surface, 0),
|
||||
new(Rock, "test rock", TileFlag.Impassable, 20),
|
||||
new(Plain, "test plain", TileFlag.None, 1)
|
||||
];
|
||||
|
||||
public static IReadOnlyList<TestLandTile> Land { get; } =
|
||||
[
|
||||
new(Grass, "test grass", TileFlag.None),
|
||||
new(RockLand, "test rock", TileFlag.Impassable),
|
||||
new(Dirt, "test dirt", TileFlag.None),
|
||||
new(0xA8, "test water", TileFlag.Wet | TileFlag.Impassable),
|
||||
new(0xA9, "test water", TileFlag.Wet | TileFlag.Impassable),
|
||||
new(0xAA, "test water", TileFlag.Wet | TileFlag.Impassable),
|
||||
new(0xAB, "test water", TileFlag.Wet | TileFlag.Impassable),
|
||||
new(0x136, "test water", TileFlag.Wet | TileFlag.Impassable),
|
||||
new(0x137, "test water", TileFlag.Wet | TileFlag.Impassable)
|
||||
];
|
||||
}
|
||||
|
|
@ -9,11 +9,6 @@ namespace Server.Tests.Maps;
|
|||
[Collection("Sequential Server Tests")]
|
||||
public class CanFitItemTests
|
||||
{
|
||||
private void SkipIfNoTileData()
|
||||
{
|
||||
Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanFitItem_InternalMapReturnsFalse()
|
||||
{
|
||||
|
|
@ -33,8 +28,7 @@ public class CanFitItemTests
|
|||
Assert.False(map.CanFitItem(100, map.Height + 1, 0, 1));
|
||||
}
|
||||
|
||||
// Plain walkable land in the real Felucca map. Without map files every cell is the default land
|
||||
// tile at Z 0 with no flags, which these tests treat the same way: a surface with nothing above it.
|
||||
// The synthetic map's default ground: land ID 0 at Z 0 with no flags, a surface with nothing above it.
|
||||
private const int TestLandX = 1500;
|
||||
private const int TestLandY = 1600;
|
||||
|
||||
|
|
@ -61,12 +55,9 @@ public class CanFitItemTests
|
|||
Assert.False(result, "No surface 50 units above land");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
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;
|
||||
|
|
@ -94,12 +85,9 @@ public class CanFitItemTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
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;
|
||||
|
|
@ -130,11 +118,9 @@ public class CanFitItemTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanFitItem_NonMovableWorldItemSurfaceWorks()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1800;
|
||||
const int y = 1800;
|
||||
|
|
@ -160,11 +146,9 @@ public class CanFitItemTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanFitItem_MovableWorldItemNotValidSurface()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1850;
|
||||
const int y = 1850;
|
||||
|
|
@ -192,11 +176,9 @@ public class CanFitItemTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanFitItem_ImpassableTileBlocksPlacement()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1900;
|
||||
const int y = 1900;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ using Xunit;
|
|||
namespace Server.Tests.Maps;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CanSpawnMobile that don't require TileData (client files).
|
||||
/// For tests that require client files, see CanSpawnMobileTileDataTests.
|
||||
/// Tests for CanSpawnMobile on the default ground.
|
||||
/// For tests over tile flags, see CanSpawnMobileTileDataTests.
|
||||
/// </summary>
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class CanSpawnMobileTests
|
||||
|
|
@ -29,8 +29,8 @@ public class CanSpawnMobileTests
|
|||
Assert.False(map.CanSpawnMobile(100, map.Height + 1, -128, 127, false, false, out _));
|
||||
}
|
||||
|
||||
// Plain walkable land near Britain in the real Felucca map. Without map files every cell is the
|
||||
// default land tile at Z 0 with no flags, which CanSpawnMobile treats the same way: walkable, not water.
|
||||
// The synthetic map's default ground: land ID 0 at Z 0 with no flags, which CanSpawnMobile treats as
|
||||
// walkable, not water.
|
||||
private const int TestLandX = 1500;
|
||||
private const int TestLandY = 1600;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,23 +4,14 @@ using Xunit;
|
|||
namespace Server.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.
|
||||
/// Tests for CanSpawnMobile over tile flags, using tiles from the generated test kit.
|
||||
/// </summary>
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class CanSpawnMobileTileDataTests
|
||||
{
|
||||
private void SkipIfNoTileData()
|
||||
{
|
||||
Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanSpawnMobile_FindsLowestValidSurface()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1200;
|
||||
const int y = 1200;
|
||||
|
|
@ -44,11 +35,9 @@ public class CanSpawnMobileTileDataTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanSpawnMobile_ZRangeSelectsCorrectFloor()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1300;
|
||||
const int y = 1300;
|
||||
|
|
@ -72,11 +61,9 @@ public class CanSpawnMobileTileDataTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanSpawnMobile_LowCeilingBlocksSpawn()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1400;
|
||||
const int y = 1400;
|
||||
|
|
@ -99,11 +86,9 @@ public class CanSpawnMobileTileDataTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanSpawnMobile_HighCeilingAllowsSpawn()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1450;
|
||||
const int y = 1450;
|
||||
|
|
@ -124,11 +109,9 @@ public class CanSpawnMobileTileDataTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanSpawnMobile_BlockerOutsideZRangeIgnored()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1475;
|
||||
const int y = 1475;
|
||||
|
|
@ -150,11 +133,9 @@ public class CanSpawnMobileTileDataTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanSpawnMobile_WaterSurfaceForSwimmingMob()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1550;
|
||||
const int y = 1550;
|
||||
|
|
@ -176,11 +157,9 @@ public class CanSpawnMobileTileDataTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void CanSpawnMobile_WorldItemSurfaceWorks()
|
||||
{
|
||||
SkipIfNoTileData();
|
||||
|
||||
var map = Map.Felucca;
|
||||
const int x = 1650;
|
||||
const int y = 1650;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public class StaticTileEnumeratorTests
|
|||
var map = Map.Felucca;
|
||||
var location = new Point2D(200, 200);
|
||||
|
||||
// Real map files may have statics here; without them there are none. Either way the multi adds nothing.
|
||||
// The synthetic map has no statics here; the multi must add nothing to the statics-only walk.
|
||||
var mapStatics = CollectTiles(map, location, includeStatics: true, includeMultis: false);
|
||||
|
||||
TestMulti multi = null;
|
||||
|
|
|
|||
79
Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs
Normal file
79
Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Maps;
|
||||
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class TileMatrixInjectionTests
|
||||
{
|
||||
// Not registered in Map.Maps, and no data directory holds map96.mul, so this matrix has no file streams.
|
||||
private static Map NewFilelessMap() => new(0x60, 0x60, 0x60, 64, 64, 0, "inject-test", MapRules.TrammelRules);
|
||||
|
||||
[Fact]
|
||||
public void InjectedLandBlock_IsReturned_WithNoMapFile()
|
||||
{
|
||||
var tiles = NewFilelessMap().Tiles;
|
||||
var block = new LandTile[64];
|
||||
block[9] = new LandTile(0x10, 7);
|
||||
|
||||
tiles.SetLandBlock(1, 1, block);
|
||||
|
||||
var tile = tiles.GetLandTile(9, 9);
|
||||
Assert.Equal(0x10, tile.ID);
|
||||
Assert.Equal(7, tile.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UninjectedLand_IsIdZeroAtZZero()
|
||||
{
|
||||
var tile = NewFilelessMap().Tiles.GetLandTile(30, 30);
|
||||
|
||||
Assert.Equal(0, tile.ID);
|
||||
Assert.Equal(0, tile.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectedStaticBlock_IsReturned_WithNoStaticFiles()
|
||||
{
|
||||
var tiles = NewFilelessMap().Tiles;
|
||||
var block = new StaticTile[8][][];
|
||||
for (var x = 0; x < 8; x++)
|
||||
{
|
||||
block[x] = new StaticTile[8][];
|
||||
for (var y = 0; y < 8; y++)
|
||||
{
|
||||
block[x][y] = [];
|
||||
}
|
||||
}
|
||||
|
||||
block[1][1] = [new StaticTile(0x20, 5)];
|
||||
|
||||
tiles.SetStaticBlock(1, 1, block);
|
||||
|
||||
var cell = tiles.GetStaticBlock(1, 1)[1][1];
|
||||
Assert.Single(cell);
|
||||
Assert.Equal(0x20, cell[0].ID);
|
||||
Assert.Equal(5, cell[0].Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearingABlock_RestoresTheDefault()
|
||||
{
|
||||
var tiles = NewFilelessMap().Tiles;
|
||||
var block = new LandTile[64];
|
||||
block[9] = new LandTile(0x10, 7);
|
||||
tiles.SetLandBlock(1, 1, block);
|
||||
|
||||
tiles.SetLandBlock(1, 1, null);
|
||||
|
||||
Assert.Equal(0, tiles.GetLandTile(9, 9).ID);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfRange_StillReturnsDefault()
|
||||
{
|
||||
var tiles = NewFilelessMap().Tiles;
|
||||
|
||||
Assert.Equal(0, tiles.GetLandBlock(-1, 0)[0].ID);
|
||||
Assert.Equal(0, tiles.GetLandBlock(8, 0)[0].ID);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ public class ItemPacketTests : IDisposable
|
|||
{
|
||||
private const int TestItemId = 1;
|
||||
|
||||
// TileData is global and holds real client data when client files are loaded, so each test restores it.
|
||||
// TileData is global, so each test restores it.
|
||||
private readonly ItemData _originalItemData;
|
||||
|
||||
public ItemPacketTests()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class SyntheticTileDataTests
|
||||
{
|
||||
[Fact]
|
||||
public void GeneratedFile_IsPreHsSize()
|
||||
{
|
||||
Assert.Equal(1_036_288, SyntheticTileData.Build().Length);
|
||||
}
|
||||
|
||||
// Inside the test host's output, rewritten every run, so runs leave nothing behind in the temp directory.
|
||||
[Fact]
|
||||
public void DataDirectory_IsTheOnlyDataDirectory_AndLivesInTheTestHost()
|
||||
{
|
||||
Assert.Equal(Path.Combine(Core.BaseDirectory, "SyntheticData"), SyntheticWorld.DataDirectory);
|
||||
Assert.Equal([SyntheticWorld.DataDirectory], ServerConfiguration.DataDirectories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KitIds_FitThePreHsTable()
|
||||
{
|
||||
for (var i = 0; i < TestTiles.Items.Count; i++)
|
||||
{
|
||||
Assert.True(TestTiles.Items[i].Id < 0x4000, $"{TestTiles.Items[i].Name} is outside the pre-HS item table");
|
||||
}
|
||||
|
||||
for (var i = 0; i < TestTiles.Land.Count; i++)
|
||||
{
|
||||
Assert.True(TestTiles.Land[i].Id < 0x4000, $"{TestTiles.Land[i].Name} is outside the land table");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeneratedFile_RoundTripsKitThroughLoad()
|
||||
{
|
||||
SyntheticTileData.LoadInto(SyntheticWorld.DataDirectory);
|
||||
|
||||
for (var i = 0; i < TestTiles.Items.Count; i++)
|
||||
{
|
||||
var tile = TestTiles.Items[i];
|
||||
Assert.Equal(tile.Flags, TileData.ItemTable[tile.Id].Flags);
|
||||
Assert.Equal((int)tile.Height, TileData.ItemTable[tile.Id].Height);
|
||||
Assert.Equal(tile.Name, TileData.ItemTable[tile.Id].Name);
|
||||
}
|
||||
|
||||
for (var i = 0; i < TestTiles.Land.Count; i++)
|
||||
{
|
||||
var tile = TestTiles.Land[i];
|
||||
Assert.Equal(tile.Flags, TileData.LandTable[tile.Id].Flags);
|
||||
}
|
||||
|
||||
Assert.Equal(TileFlag.None, TileData.ItemTable[0x2A0].Flags);
|
||||
Assert.Equal(TileFlag.None, TileData.LandTable[3].Flags);
|
||||
}
|
||||
}
|
||||
33
Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs
Normal file
33
Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class TestMultisTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(TestMultis.GuildHouse)]
|
||||
[InlineData(TestMultis.Tower)]
|
||||
[InlineData(TestMultis.SmallBoatNorth)]
|
||||
public void Seed_RegistersFootprint(int multiID)
|
||||
{
|
||||
Assert.NotEmpty(MultiData.GetComponents(multiID).List);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Footprints_DifferBetweenGuildHouseAndTower()
|
||||
{
|
||||
var house = MultiData.GetComponents(TestMultis.GuildHouse);
|
||||
var tower = MultiData.GetComponents(TestMultis.Tower);
|
||||
Assert.NotEqual(house.Width, tower.Width);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuildHouse_HasAnOpenDoorway()
|
||||
{
|
||||
var house = MultiData.GetComponents(TestMultis.GuildHouse);
|
||||
var doorway = house.Tiles[house.Center.X][house.Height - 1];
|
||||
Assert.Single(doorway);
|
||||
Assert.Equal(TestTiles.Floor, doorway[0].ID & TileData.MaxItemValue);
|
||||
}
|
||||
}
|
||||
125
Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs
Normal file
125
Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Terrain;
|
||||
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class TestTerrainTests
|
||||
{
|
||||
// Default ground outside the prefab region.
|
||||
private const int X = 1400;
|
||||
private const int Y = 1400;
|
||||
|
||||
[Fact]
|
||||
public void StampingOneTile_KeepsTheRestOfTheBlock()
|
||||
{
|
||||
using var scope = TestTerrain.Scope();
|
||||
|
||||
TestTerrain.SetLand(X, Y, TestTiles.Grass, 3);
|
||||
TestTerrain.SetLand(X + 1, Y, TestTiles.Dirt, 4);
|
||||
|
||||
Assert.Equal(TestTiles.Grass, Map.Felucca.Tiles.GetLandTile(X, Y).ID);
|
||||
Assert.Equal(TestTiles.Dirt, Map.Felucca.Tiles.GetLandTile(X + 1, Y).ID);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampingAnUninjectedBlock_DoesNotTouchOtherBlocks()
|
||||
{
|
||||
using var scope = TestTerrain.Scope();
|
||||
|
||||
TestTerrain.SetLand(X, Y, TestTiles.Grass, 3);
|
||||
|
||||
Assert.Equal(0, Map.Felucca.Tiles.GetLandTile(X + 64, Y + 64).ID);
|
||||
Assert.Equal(0, Map.Felucca.Tiles.GetLandTile(X + 1, Y).ID);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stamps_ReachBothFacets()
|
||||
{
|
||||
using var scope = TestTerrain.Scope();
|
||||
|
||||
TestTerrain.SetLand(X, Y, TestTiles.Grass, 3);
|
||||
TestTerrain.AddStatic(X, Y, TestTiles.Wall, 3);
|
||||
|
||||
foreach (var map in (ReadOnlySpan<Map>)[Map.Felucca, Map.Trammel])
|
||||
{
|
||||
Assert.Equal(TestTiles.Grass, map.Tiles.GetLandTile(X, Y).ID);
|
||||
var statics = map.Tiles.GetStaticBlock(X >> 3, Y >> 3)[X & 7][Y & 7];
|
||||
Assert.Single(statics);
|
||||
Assert.Equal(TestTiles.Wall, statics[0].ID);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeDispose_RestoresBothFacets()
|
||||
{
|
||||
using (TestTerrain.Scope())
|
||||
{
|
||||
TestTerrain.SetLand(X, Y, TestTiles.Grass, 3);
|
||||
TestTerrain.AddStatic(X, Y, TestTiles.Wall, 3);
|
||||
}
|
||||
|
||||
foreach (var map in (ReadOnlySpan<Map>)[Map.Felucca, Map.Trammel])
|
||||
{
|
||||
Assert.Equal(0, map.Tiles.GetLandTile(X, Y).ID);
|
||||
Assert.Empty(map.Tiles.GetStaticBlock(X >> 3, Y >> 3)[X & 7][Y & 7]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeDispose_RestoresAPrefabItOverwrote()
|
||||
{
|
||||
Prefabs.StampAll();
|
||||
var coast = Prefabs.Coastline.Anchor;
|
||||
var before = Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y);
|
||||
|
||||
using (TestTerrain.Scope())
|
||||
{
|
||||
TestTerrain.SetLand(coast.X, coast.Y, TestTiles.RockLand, 40);
|
||||
}
|
||||
|
||||
Assert.Equal(before.ID, Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y).ID);
|
||||
Assert.Equal(before.Z, Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y).Z);
|
||||
Prefabs.Coastline.AssertPresent();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedScopes_Throw()
|
||||
{
|
||||
using var scope = TestTerrain.Scope();
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() => TestTerrain.Scope());
|
||||
Assert.Contains($"{nameof(TestTerrainTests)}.{nameof(NestedScopes_Throw)}", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampingOutsideAScope_Throws_OncePrefabsAreStamped()
|
||||
{
|
||||
Prefabs.StampAll();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => TestTerrain.SetLand(X, Y, TestTiles.Grass, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StampAll_PrefabsDoNotOverlap_AndAreAllPresent()
|
||||
{
|
||||
Prefabs.StampAll();
|
||||
|
||||
for (var i = 0; i < Prefabs.All.Count; i++)
|
||||
{
|
||||
Prefabs.All[i].AssertPresent();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AssertPresent_Throws_WhenThePrefabIsGone()
|
||||
{
|
||||
Prefabs.StampAll();
|
||||
var coast = Prefabs.Coastline.Anchor;
|
||||
|
||||
using var scope = TestTerrain.Scope();
|
||||
TestTerrain.SetLand(coast.X, coast.Y, 0, 0);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Prefabs.Coastline.AssertPresent());
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,12 @@ public static class MultiData
|
|||
public static MultiComponentList GetComponents(int multiID) =>
|
||||
_components.TryGetValue(multiID & 0x3FFF, out var mcl) ? mcl : MultiComponentList.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Registers a footprint without multi files, for hosts that generate their own (tests).
|
||||
/// </summary>
|
||||
internal static void SetComponents(int multiID, MultiComponentList components) =>
|
||||
_components[multiID & 0x3FFF] = components;
|
||||
|
||||
private static void LoadUOP(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
|
|
|
|||
|
|
@ -220,11 +220,17 @@ public class TileMatrix
|
|||
|
||||
public StaticTile[][][] GetStaticBlock(int x, int y)
|
||||
{
|
||||
if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight || DataStream == null || IndexStream == null)
|
||||
if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight)
|
||||
{
|
||||
return _emptyStaticBlock;
|
||||
}
|
||||
|
||||
// A map without static files (tests, generated maps) serves only what was injected.
|
||||
if (DataStream == null || IndexStream == null)
|
||||
{
|
||||
return _staticTiles[x]?[y] ?? _emptyStaticBlock;
|
||||
}
|
||||
|
||||
_staticTiles[x] ??= new StaticTile[BlockHeight][][][];
|
||||
|
||||
var tiles = _staticTiles[x][y];
|
||||
|
|
@ -292,11 +298,17 @@ public class TileMatrix
|
|||
|
||||
public LandTile[] GetLandBlock(int x, int y)
|
||||
{
|
||||
if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight || MapStream == null)
|
||||
if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight)
|
||||
{
|
||||
return _invalidLandBlock;
|
||||
}
|
||||
|
||||
// A map without a map file (tests, generated maps) serves only what was injected.
|
||||
if (MapStream == null)
|
||||
{
|
||||
return _landTiles[x]?[y] ?? _invalidLandBlock;
|
||||
}
|
||||
|
||||
_landTiles[x] ??= new LandTile[BlockHeight][];
|
||||
|
||||
var tiles = _landTiles[x][y];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using Server.Items;
|
||||
|
|
@ -7,6 +6,7 @@ using Server.Misc;
|
|||
using Server.Movement;
|
||||
using Server.PathAlgorithms;
|
||||
using Server.Tests.Maps;
|
||||
using Server.Tests.Terrain;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
|
|
@ -24,19 +24,15 @@ namespace Server.Tests;
|
|||
/// single time regardless of how many collection fixtures are constructed. The two stateful
|
||||
/// collections use <c>[CollectionDefinition(DisableParallelization = true)]</c> so they never
|
||||
/// overlap; pure tests still run in parallel.
|
||||
///
|
||||
/// The host runs on generated tile data, terrain and multi footprints (see <see cref="SyntheticWorld"/>),
|
||||
/// never on client files, so every machine and CI run the same world.
|
||||
/// </summary>
|
||||
internal static class TestServerInitializer
|
||||
{
|
||||
private static bool _initialized;
|
||||
private static readonly Lock _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// True if the UO client tile data was found and loaded. When false (e.g. CI, where the
|
||||
/// copyrighted client files are absent), tile/map/multi-dependent tests must skip rather than
|
||||
/// fail. Guard such tests with <c>Skip.If(!TestServerInitializer.TileDataLoaded, ...)</c>.
|
||||
/// </summary>
|
||||
public static bool TileDataLoaded { get; private set; }
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
lock (_lock)
|
||||
|
|
@ -53,10 +49,9 @@ internal static class TestServerInitializer
|
|||
ServerConfiguration.Load(true);
|
||||
ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory);
|
||||
|
||||
// Required for the pathfinding tests (real .mul tile data). Harmless for the rest.
|
||||
var clientFiles = Environment.GetEnvironmentVariable("MODERNUO_TEST_DATA_DIR")
|
||||
?? @"C:\Ultima Online Classic";
|
||||
ServerConfiguration.DataDirectories.Add(clientFiles);
|
||||
// TileData's static cctor short-circuits under xUnit, so the generated tiledata.mul must load
|
||||
// before anything reads LandTable/ItemTable (the multi footprints, MovementImpl, CheckMovement).
|
||||
SyntheticWorld.LoadData();
|
||||
|
||||
AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]);
|
||||
|
||||
|
|
@ -72,16 +67,7 @@ internal static class TestServerInitializer
|
|||
Timer.Init(0);
|
||||
Server.Network.NetState.Configure();
|
||||
TestMapDefinitions.ConfigureTestMapDefinitions();
|
||||
|
||||
// TileData's static cctor short-circuits when running under xUnit
|
||||
// (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable
|
||||
// flags are populated before anything that reads TileData (MultiData, MovementImpl,
|
||||
// CheckMovement). Without this, TileData.MaxItemValue is 0 at MultiData.Configure()
|
||||
// time, causing every MCL tile ID to be masked to 0 and stored as ID=0 in Tiles[x][y].
|
||||
// The copyrighted client files are absent on CI; when tiledata.mul is missing we skip
|
||||
// the tile/map/multi-dependent bootstrap and leave TileDataLoaded false so those tests
|
||||
// skip instead of failing the whole collection from the fixture constructor.
|
||||
TileDataLoaded = TryForceLoadTileData();
|
||||
SyntheticWorld.StampTerrain();
|
||||
|
||||
// Production runs every static Configure() via AssemblyHandler.Invoke("Configure");
|
||||
// the fixture calls a curated subset, so configure the pathfinding singleton here so
|
||||
|
|
@ -89,16 +75,6 @@ internal static class TestServerInitializer
|
|||
// calls Find. ServerConfiguration is already loaded above, so the setting resolves.
|
||||
BitmapAStarAlgorithm.Configure();
|
||||
|
||||
if (TileDataLoaded)
|
||||
{
|
||||
// Multi component lists (multi.mul / MultiCollection.uop). Production invokes this via
|
||||
// AssemblyHandler.Invoke("Configure"); the curated fixture subset must call it so that
|
||||
// BaseMulti.Components (MultiData.GetComponents) returns real footprints instead of
|
||||
// MultiComponentList.Empty. Required by the Multi pathfinding tests. Depends on the
|
||||
// client files, so it only runs when tile data loaded.
|
||||
MultiData.Configure();
|
||||
}
|
||||
|
||||
World.Configure();
|
||||
// Registers the Accounts entity persistence; without it no test can construct an Account.
|
||||
Server.Accounting.Accounts.Configure();
|
||||
|
|
@ -113,55 +89,7 @@ internal static class TestServerInitializer
|
|||
Server.Mobiles.NPCSpeeds.Configure();
|
||||
Server.Engines.Spawners.SpawnerJsonSerializer.Configure();
|
||||
|
||||
if (TileDataLoaded)
|
||||
{
|
||||
VerifyTrammelTileDataLoaded();
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryForceLoadTileData()
|
||||
{
|
||||
var tileDataPath = Core.FindDataFile("tiledata.mul", false);
|
||||
if (string.IsNullOrEmpty(tileDataPath) || !File.Exists(tileDataPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var loadMethod = typeof(TileData).GetMethod(
|
||||
"Load",
|
||||
BindingFlags.Static | BindingFlags.NonPublic
|
||||
);
|
||||
if (loadMethod == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"TileData.Load not found via reflection — engine may have refactored."
|
||||
);
|
||||
}
|
||||
loadMethod.Invoke(null, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void VerifyTrammelTileDataLoaded()
|
||||
{
|
||||
var trammel = Map.Maps[1];
|
||||
if (trammel == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Trammel (mapId=1) was not registered. Check TestMapDefinitions."
|
||||
);
|
||||
}
|
||||
|
||||
var tile = trammel.Tiles.GetLandTile(1500, 1600);
|
||||
if (tile.ID == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Trammel tile data did not load — GetLandTile(1500,1600) returned ID 0. " +
|
||||
$"Verify Distribution/Data/map1*.mul (or map1LegacyMUL.uop) is present at " +
|
||||
$"{Path.Combine(Core.BaseDirectory, "Data")}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared guard for tests that require the copyrighted UO client tile/map data (tiledata.mul and
|
||||
/// friends), which is absent on CI. Call <see cref="SkipIfMissing"/> as the first statement of a
|
||||
/// <c>[SkippableFact]</c>/<c>[SkippableTheory]</c> so the test is skipped — not failed — when the
|
||||
/// data was not loaded. See <see cref="TestServerInitializer.TileDataLoaded"/>.
|
||||
/// </summary>
|
||||
internal static class TileDataRequirement
|
||||
{
|
||||
public static void SkipIfMissing() =>
|
||||
Skip.If(
|
||||
!TestServerInitializer.TileDataLoaded,
|
||||
"Requires UO client tile data (tiledata.mul); absent on CI."
|
||||
);
|
||||
}
|
||||
|
|
@ -20,23 +20,3 @@ public sealed class BareCreatureStub : BaseCreature
|
|||
{
|
||||
public BareCreatureStub(Serial serial) : base(serial) => Body = 0xC9;
|
||||
}
|
||||
|
||||
public static class TestTiles
|
||||
{
|
||||
/// <summary>
|
||||
/// The first item ID whose tile data is an impassable surface, so a test can wall a cell off
|
||||
/// without pinning a specific graphic. 0 when tile data is not loaded.
|
||||
/// </summary>
|
||||
public static ushort FirstImpassableItemId()
|
||||
{
|
||||
for (ushort id = 1; id < TileData.MaxItemValue; id++)
|
||||
{
|
||||
if (TileData.ItemTable[id].ImpassableSurface)
|
||||
{
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using Server.Items;
|
|||
using Server.Mobiles;
|
||||
using Server.Random;
|
||||
using Server.Tests;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
|
@ -12,22 +13,31 @@ namespace UOContent.Tests;
|
|||
[Collection("Sequential UOContent Tests")]
|
||||
public class FishingTests
|
||||
{
|
||||
// Open sea with no land within 30 tiles: deep water once the client's map is loaded.
|
||||
private static readonly Point3D _deep = new(1840, 1760, -5);
|
||||
|
||||
[SkippableTheory]
|
||||
[InlineData(1840, 1760, true)] // land 115 tiles away
|
||||
[InlineData(1660, 1760, true)] // land 14 tiles away
|
||||
[InlineData(1659, 1760, false)] // land 13 tiles away
|
||||
[InlineData(2188, 1000, true)] // land 23 tiles away, on a diagonal
|
||||
[InlineData(1718, 1913, false)] // land 8 tiles away, between the diagonals
|
||||
[InlineData(13, 200, true)] // the edge of the map 14 tiles away
|
||||
[InlineData(12, 200, false)] // the edge of the map 13 tiles away
|
||||
public void FullValidation_NeedsLandAndTheMapEdgeAtLeast14TilesAway(int x, int y, bool deep)
|
||||
[Theory]
|
||||
[InlineData(32, 32, true)] // the middle of the sea: land 32 tiles away
|
||||
[InlineData(13, 32, true)] // land 14 tiles west
|
||||
[InlineData(12, 32, false)] // land 13 tiles west
|
||||
[InlineData(50, 32, true)] // land 14 tiles east
|
||||
[InlineData(51, 32, false)] // land 13 tiles east
|
||||
[InlineData(13, 13, true)] // land 14 tiles away on the diagonal
|
||||
[InlineData(13, 12, false)] // land 13 tiles north
|
||||
public void FullValidation_NeedsLandAtLeast14TilesAway(int dx, int dy, bool deep)
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var sea = Prefabs.DeepSea;
|
||||
sea.AssertPresent();
|
||||
|
||||
Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, x, y));
|
||||
Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, sea.Bounds.X + dx, sea.Bounds.Y + dy));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(13, true)] // the edge of the map 14 tiles away
|
||||
[InlineData(12, false)] // the edge of the map 13 tiles away
|
||||
public void FullValidation_TreatsTheMapEdgeAsLand(int x, bool deep)
|
||||
{
|
||||
var sea = Prefabs.MapEdgeSea;
|
||||
sea.AssertPresent();
|
||||
|
||||
Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, x, sea.Anchor.Y));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -46,7 +56,8 @@ public class FishingTests
|
|||
{
|
||||
// The rate RegenRates sets before AOS, so the hit is not healed before the serpent's five minutes are up.
|
||||
Mobile.DefaultHitsRate = TimeSpan.FromSeconds(11.0);
|
||||
fisher.MoveToWorld(_deep, Map.Felucca);
|
||||
Prefabs.DeepSea.AssertPresent();
|
||||
fisher.MoveToWorld(Prefabs.DeepSea.Anchor, Map.Felucca);
|
||||
|
||||
var bottle = new MessageInABottle(Map.Felucca);
|
||||
Fishing.System.Give(fisher, bottle, false);
|
||||
|
|
@ -93,7 +104,8 @@ public class FishingTests
|
|||
BuiltInRng.Generator = new System.Random(1);
|
||||
fisher.Skills.Fishing.Base = 65.0;
|
||||
fisher.AddItem(new Backpack());
|
||||
fisher.MoveToWorld(_deep, Map.Felucca);
|
||||
Prefabs.DeepSea.AssertPresent();
|
||||
fisher.MoveToWorld(Prefabs.DeepSea.Anchor, Map.Felucca);
|
||||
|
||||
var sos = AddSOS(fisher);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using System;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Mobiles;
|
||||
|
|
@ -139,10 +140,9 @@ public class BitmapAStarAlgorithmTests : IDisposable
|
|||
blocker.Delete();
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void DynamicObstaclePass_RejectsCellOccupiedByImpassableItem()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
|
@ -155,8 +155,7 @@ public class BitmapAStarAlgorithmTests : IDisposable
|
|||
var blockX = 1499;
|
||||
var blockY = 1599;
|
||||
|
||||
var blockerItemId = TestTiles.FirstImpassableItemId();
|
||||
Assert.NotEqual<ushort>(0, blockerItemId);
|
||||
const ushort blockerItemId = TestTiles.Counter;
|
||||
|
||||
map.GetAverageZ(sx, sy, out _, out var startZ, out _);
|
||||
var start = new Point3D(sx, sy, (sbyte)startZ);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Items;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
|
|
@ -12,20 +13,20 @@ public class BoatPathTests
|
|||
|
||||
public BoatPathTests(ITestOutputHelper output) => _output = output;
|
||||
|
||||
// Open water in the south-Britain bay (Trammel), used by the existing swim-bake test.
|
||||
// Open water: the middle of the DeepSea prefab.
|
||||
private const int MapId = 1;
|
||||
private const int WaterX = 1450;
|
||||
private const int WaterY = 1770;
|
||||
private static int WaterX => Prefabs.DeepSea.Anchor.X;
|
||||
private static int WaterY => Prefabs.DeepSea.Anchor.Y;
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void BoatDeck_HasWalkableSurfaceCells()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
Prefabs.DeepSea.AssertPresent();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
|
||||
// SmallBoat North heading = multiID 0x0. A deck is made of surface tiles.
|
||||
var boat = new TestMulti(0x0);
|
||||
var boat = new TestMulti(TestMultis.SmallBoatNorth);
|
||||
map.GetAverageZ(WaterX, WaterY, out _, out var z, out _);
|
||||
boat.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)z), map);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using Server;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Items;
|
||||
|
|
@ -17,15 +18,16 @@ public class FoundationRedesignTests
|
|||
private const int PlaceX = 1500;
|
||||
private const int PlaceY = 1600;
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void RedesignReRegistered_RoutesNewFootprintToLivePath()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
Assert.NotNull(map);
|
||||
|
||||
var foundation = new SwappableFoundation(0x74);
|
||||
var foundation = new SwappableFoundation(TestMultis.GuildHouse);
|
||||
var shared = MultiData.GetComponents(TestMultis.GuildHouse);
|
||||
var seededShape = (shared.Width, shared.Height, shared.List.Length);
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
var loc = new Point3D(PlaceX, PlaceY, (sbyte)z);
|
||||
foundation.MoveToWorld(loc, map);
|
||||
|
|
@ -43,7 +45,7 @@ public class FoundationRedesignTests
|
|||
// the OLD footprint's sector registration. We then swap the MCL and MoveToWorld back,
|
||||
// which fires Map.OnEnter -> AddMulti against the fresh Components (new footprint).
|
||||
foundation.Internalize();
|
||||
foundation.Redesign(MultiData.GetComponents(0x7A));
|
||||
foundation.Redesign(MultiData.GetComponents(TestMultis.Tower));
|
||||
foundation.MoveToWorld(loc, map);
|
||||
|
||||
var after = MultiArt.FindFloorCell(foundation);
|
||||
|
|
@ -54,6 +56,10 @@ public class FoundationRedesignTests
|
|||
var maskAfter = StepCache.Instance.TryGetMask(map, after.Value.X, after.Value.Y, (sbyte)z);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_Multi, maskAfter.HitKind);
|
||||
|
||||
// The redesign swapped this foundation's list; the shared footprint every other GuildHouse reads is untouched.
|
||||
var seeded = MultiData.GetComponents(TestMultis.GuildHouse);
|
||||
Assert.Equal(seededShape, (seeded.Width, seeded.Height, seeded.List.Length));
|
||||
|
||||
_output.WriteLine($"redesign re-registered: before {before.Value.X},{before.Value.Y} after {after.Value.X},{after.Value.Y}");
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using System;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Items;
|
||||
|
|
@ -21,10 +22,9 @@ public class HousePathRoutingTests : IDisposable
|
|||
|
||||
private const int MapId = 1;
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void PathAround_NeverTraversesAWallCell()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
StepCache.Instance.MissPromotionThreshold = 1;
|
||||
var map = Map.Maps[MapId];
|
||||
|
|
@ -36,7 +36,7 @@ public class HousePathRoutingTests : IDisposable
|
|||
const int gx = 1480, gy = 1610;
|
||||
|
||||
map.GetAverageZ(hx, hy, out _, out var hz, out _);
|
||||
var multi = new TestMulti(0x74);
|
||||
var multi = new TestMulti(TestMultis.GuildHouse);
|
||||
multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map);
|
||||
|
||||
var wall = MultiArt.FindWallCell(multi);
|
||||
|
|
@ -72,17 +72,16 @@ public class HousePathRoutingTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Demolish_ReopensCoveredCells()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
Assert.NotNull(map);
|
||||
|
||||
const int hx = 1480, hy = 1620;
|
||||
map.GetAverageZ(hx, hy, out _, out var hz, out _);
|
||||
var multi = new TestMulti(0x74);
|
||||
var multi = new TestMulti(TestMultis.GuildHouse);
|
||||
multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map);
|
||||
|
||||
// Find a wall cell that is blocked purely by the multi — not by underlying static/land
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using System;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.PathAlgorithms;
|
||||
|
|
@ -10,17 +11,15 @@ namespace Server.Tests.Pathfinding;
|
|||
public class MultiCacheUsedTests : IDisposable
|
||||
{
|
||||
private const int MapId = 1; // Trammel
|
||||
private const int GuildHouseId = 0x74;
|
||||
private const int GuildHouseId = TestMultis.GuildHouse;
|
||||
|
||||
private readonly StepCacheStateScope _cacheState = new();
|
||||
|
||||
public void Dispose() => _cacheState.Dispose();
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void PathNearMulti_IncrementsMultiLocalHits()
|
||||
{
|
||||
// Without the client's multi data the house has no footprint for the path to skirt.
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var map = Map.Maps[MapId];
|
||||
map.GetAverageZ(1480, 1620, out _, out var z, out _);
|
||||
var houseLoc = new Point3D(1480, 1620, (sbyte)z);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Items;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Pathfinding;
|
||||
|
|
@ -8,16 +9,16 @@ namespace Server.Tests.Pathfinding;
|
|||
public class MultiEdgeCaseTests
|
||||
{
|
||||
private const int MapId = 1; // Trammel
|
||||
private const int GuildHouseId = 0x74;
|
||||
private const int GuildHouseId = TestMultis.GuildHouse;
|
||||
|
||||
// GuildHouse placement (mirrors MultiMaskSynthesisTests) — open Trammel ground.
|
||||
// GuildHouse placement (mirrors MultiMaskSynthesisTests) — open ground.
|
||||
private const int HouseX = 1480;
|
||||
private const int HouseY = 1620;
|
||||
|
||||
// Open water in the south-Britain bay (mirrors BoatPathTests).
|
||||
private const int BoatMultiId = 0x0; // SmallBoat North heading
|
||||
private const int WaterX = 1450;
|
||||
private const int WaterY = 1770;
|
||||
// Open water: the middle of the DeepSea prefab.
|
||||
private const int BoatMultiId = TestMultis.SmallBoatNorth;
|
||||
private static int WaterX => Prefabs.DeepSea.Anchor.X;
|
||||
private static int WaterY => Prefabs.DeepSea.Anchor.Y;
|
||||
private const sbyte DeckZ = 0; // boat deck floor tiles stand at world Z 0 (not the water avgZ)
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -25,10 +26,9 @@ public class MultiEdgeCaseTests
|
|||
/// <c>GetStaticAndMultiTiles</c> yields tiles from BOTH multis; the synthesizer must still
|
||||
/// agree with CheckMovement everywhere over the union footprint + halo.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void OverlappingMultis_SynthesizerMatchesCheckMovement()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
|
||||
|
|
@ -76,10 +76,10 @@ public class MultiEdgeCaseTests
|
|||
/// blocks the rest — that vacuity is a fixture concern, not a synthesizer divergence (the
|
||||
/// synthesizer agrees with the oracle at every direction either way).
|
||||
/// </remarks>
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void BoatOverWater_SynthesizerMatchesCheckMovement()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
Prefabs.DeepSea.AssertPresent();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
|
||||
|
|
@ -100,10 +100,9 @@ public class MultiEdgeCaseTests
|
|||
/// re-registration pattern HouseFoundation uses on commit) and assert the synthesizer reads
|
||||
/// the LIVE, post-redesign <c>Components</c> — i.e. it matches CheckMovement on the NEW shape.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void RedesignedFoundation_SynthesizerMatchesNewFootprint()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
|
||||
|
|
@ -117,7 +116,7 @@ public class MultiEdgeCaseTests
|
|||
// Internalize() fires Map.OnLeave (removes the OLD footprint's registration); we swap
|
||||
// the MCL and MoveToWorld back, firing Map.OnEnter -> AddMulti against the new shape.
|
||||
foundation.Internalize();
|
||||
foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape)
|
||||
foundation.Redesign(MultiData.GetComponents(TestMultis.Tower)); // Tower footprint (different shape)
|
||||
foundation.MoveToWorld(loc, map);
|
||||
|
||||
MultiTestSupport.AssertSynthesizerMatchesCheckMovement(foundation, map);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Items;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Pathfinding;
|
||||
|
|
@ -9,7 +10,7 @@ namespace Server.Tests.Pathfinding;
|
|||
public class MultiMaskCacheTests : IDisposable
|
||||
{
|
||||
private const int MapId = 1;
|
||||
private const int GuildHouseId = 0x74;
|
||||
private const int GuildHouseId = TestMultis.GuildHouse;
|
||||
private const int PlaceX = 1480;
|
||||
private const int PlaceY = 1620;
|
||||
|
||||
|
|
@ -17,10 +18,9 @@ public class MultiMaskCacheTests : IDisposable
|
|||
|
||||
public void Dispose() => _cacheState.Dispose();
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void TryResolveCoveringMulti_FindsPlacedMulti_AndLocalIndices()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
|
|
@ -90,31 +90,25 @@ public class MultiMaskCacheTests : IDisposable
|
|||
Assert.False(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground - 50)));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void PathThroughHouseInterior_IncrementsMultiMaskCacheHits()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
// (PlaceX,PlaceY)=(1480,1620) is cluttered (footprint overlaps tall map statics → dirty), so it
|
||||
// would never serve the interior cache under the footprint-clean gate. Use a known flat/clear
|
||||
// spot so a house there is footprint-clean and its interior cells serve from the cache.
|
||||
const int CleanX = 1560;
|
||||
const int CleanY = 1616;
|
||||
|
||||
// On flat default ground the house is footprint-clean, so its interior cells serve from the cache.
|
||||
var map = Map.Maps[MapId];
|
||||
map.GetAverageZ(CleanX, CleanY, out _, out var z, out _);
|
||||
var houseLoc = new Point3D(CleanX, CleanY, (sbyte)z);
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
var houseLoc = new Point3D(PlaceX, PlaceY, (sbyte)z);
|
||||
var multi = new TestMulti(GuildHouseId);
|
||||
Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true;
|
||||
var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(CleanX, CleanY + 10, (sbyte)z));
|
||||
var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(PlaceX, PlaceY + 10, (sbyte)z));
|
||||
try
|
||||
{
|
||||
multi.MoveToWorld(houseLoc, map);
|
||||
Assert.True(MultiMaskCache.ComputeFootprintClean(map, multi),
|
||||
"precondition: (1560,1616) must be footprint-clean for the cache to serve");
|
||||
"precondition: the house must be footprint-clean for the cache to serve");
|
||||
StepCache.Instance.Clear();
|
||||
|
||||
var start = new Point3D(CleanX, CleanY + 10, (sbyte)z);
|
||||
var goal = new Point3D(CleanX, CleanY - 10, (sbyte)z);
|
||||
var start = new Point3D(PlaceX, PlaceY + 10, (sbyte)z);
|
||||
var goal = new Point3D(PlaceX, PlaceY - 10, (sbyte)z);
|
||||
// First pass builds the interior cache (live synth); second pass should hit it.
|
||||
Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal);
|
||||
|
||||
|
|
@ -131,11 +125,13 @@ public class MultiMaskCacheTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void ClutteredHouse_DoesNotServeCache_ButStillPaths()
|
||||
{
|
||||
// The clutter is Britain's statics; on a blank map the house has no footprint to dirty.
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
// A rock taller than the house floor, inside the footprint, dirties it.
|
||||
using var terrain = TestTerrain.Scope();
|
||||
TestTerrain.AddStatic(PlaceX + 1, PlaceY + 1, TestTiles.Rock, 0);
|
||||
|
||||
var map = Map.Maps[MapId];
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
var multi = new TestMulti(GuildHouseId);
|
||||
|
|
@ -143,10 +139,10 @@ public class MultiMaskCacheTests : IDisposable
|
|||
var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(PlaceX, PlaceY + 10, (sbyte)z));
|
||||
try
|
||||
{
|
||||
// (1480,1620) overlaps tall map statics → footprint dirty → cache must NOT serve.
|
||||
// The rock pokes through the floor → footprint dirty → cache must NOT serve.
|
||||
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
|
||||
Assert.False(MultiMaskCache.ComputeFootprintClean(map, multi),
|
||||
"precondition: (1480,1620) must be footprint-dirty for this test to be meaningful");
|
||||
"precondition: the rock must make the house footprint-dirty for this test to be meaningful");
|
||||
StepCache.Instance.Clear();
|
||||
|
||||
var start = new Point3D(PlaceX, PlaceY + 10, (sbyte)z);
|
||||
|
|
@ -200,7 +196,7 @@ public class MultiMaskCacheTests : IDisposable
|
|||
|
||||
// Changing ItemID swaps the footprint (e.g. a boat changing heading), so the gate must
|
||||
// reset to recompute cleanliness for the new shape.
|
||||
multi.ItemID = 0x7A; // Tower
|
||||
multi.ItemID = TestMultis.Tower;
|
||||
Assert.Equal(MultiInteriorCacheState.Unknown, multi.PathInteriorCacheState);
|
||||
}
|
||||
finally
|
||||
|
|
@ -209,27 +205,21 @@ public class MultiMaskCacheTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void ComputeFootprintClean_TrueAtNormalPlacement_FalseWhenSunk()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
// (PlaceX,PlaceY) is a cluttered spot whose footprint overlaps tall map statics, so a guild
|
||||
// house there is never footprint-clean. Use a known flat/clear spot for the clean assertion.
|
||||
const int CleanX = 1560;
|
||||
const int CleanY = 1616;
|
||||
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
map.GetAverageZ(CleanX, CleanY, out _, out var ground, out _);
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var ground, out _);
|
||||
|
||||
var normal = new TestMulti(GuildHouseId);
|
||||
var sunk = new TestMulti(GuildHouseId);
|
||||
try
|
||||
{
|
||||
normal.MoveToWorld(new Point3D(CleanX, CleanY, (sbyte)ground), map);
|
||||
normal.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)ground), map);
|
||||
Assert.True(MultiMaskCache.ComputeFootprintClean(map, normal));
|
||||
|
||||
sunk.MoveToWorld(new Point3D(CleanX + 60, CleanY, (sbyte)(ground - 40)), map);
|
||||
sunk.MoveToWorld(new Point3D(PlaceX + 60, PlaceY, (sbyte)(ground - 40)), map);
|
||||
Assert.False(MultiMaskCache.ComputeFootprintClean(map, sunk));
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Pathfinding;
|
||||
|
|
@ -7,14 +8,13 @@ namespace Server.Tests.Pathfinding;
|
|||
public class MultiMaskSynthesisTests
|
||||
{
|
||||
private const int MapId = 1; // Trammel
|
||||
private const int GuildHouseId = 0x74; // static house: walls, door aperture, floor
|
||||
private const int GuildHouseId = TestMultis.GuildHouse; // static house: walls, door aperture, floor
|
||||
private const int PlaceX = 1480;
|
||||
private const int PlaceY = 1620;
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void ComputeMultiMaskAt_MatchesCheckMovement_OverFootprintAndHalo()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using System;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Items;
|
||||
|
|
@ -29,14 +30,12 @@ public class MultiPathInvariantTests : IDisposable
|
|||
return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal);
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[Theory]
|
||||
// start, goal: straddle a house placed between them (house at ~ midpoint).
|
||||
[InlineData(1500, 1600, 1500, 1612)] // N-S across the footprint
|
||||
[InlineData(1494, 1606, 1512, 1606)] // E-W across the footprint
|
||||
public void Find_CacheOn_EqualsCacheOff_WithHousePresent(int sx, int sy, int gx, int gy)
|
||||
{
|
||||
// Without the client's multi data the house has no footprint and nothing stands in the way.
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var map = Map.Maps[MapId];
|
||||
Assert.NotNull(map);
|
||||
|
||||
|
|
@ -44,7 +43,7 @@ public class MultiPathInvariantTests : IDisposable
|
|||
var hx = (sx + gx) / 2;
|
||||
var hy = (sy + gy) / 2;
|
||||
map.GetAverageZ(hx, hy, out _, out var hz, out _);
|
||||
var multi = new TestMulti(0x74);
|
||||
var multi = new TestMulti(TestMultis.GuildHouse);
|
||||
multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map);
|
||||
|
||||
var walker = new WalkerStub();
|
||||
|
|
@ -105,22 +104,20 @@ public class MultiPathInvariantTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Find_CacheOn_EqualsCacheOff_RoutesAroundHouse()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var map = Map.Maps[MapId];
|
||||
Assert.NotNull(map);
|
||||
|
||||
// Open area at (1480,1620,z=20): paths exist in all 8 directions (probe confirmed).
|
||||
// N-S route: start=(1480,1630) goal=(1480,1610). House at (1480,1620) on direct line.
|
||||
// Detour exists E (~1488+) and W (~1472-). Both cache-on and cache-off must agree.
|
||||
// Flat default ground. N-S route: start=(1480,1630) goal=(1480,1610), house at (1480,1620) on
|
||||
// the direct line; the detour runs round its east or west wall. Cache-on and cache-off must agree.
|
||||
const int hx = 1480, hy = 1620;
|
||||
const int sx = 1480, sy = 1630;
|
||||
const int gx = 1480, gy = 1610;
|
||||
|
||||
map.GetAverageZ(hx, hy, out _, out var hz, out _);
|
||||
var multi = new TestMulti(0x74);
|
||||
var multi = new TestMulti(TestMultis.GuildHouse);
|
||||
multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map);
|
||||
|
||||
var walker = new WalkerStub();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using System;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Items;
|
||||
|
|
@ -21,16 +22,15 @@ public class MultiSplitRoutingTests : IDisposable
|
|||
private const int PlaceX = 1500;
|
||||
private const int PlaceY = 1600;
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void PlacedMulti_RoutesFootprintAndHalo_ToFallthroughMulti()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
Assert.NotNull(map);
|
||||
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
var multi = new TestMulti(0x74); // GuildHouse footprint
|
||||
var multi = new TestMulti(TestMultis.GuildHouse); // GuildHouse footprint
|
||||
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
|
||||
|
||||
try
|
||||
|
|
@ -70,7 +70,7 @@ public class MultiSplitRoutingTests : IDisposable
|
|||
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
|
||||
var multi = new TestMulti(0x74);
|
||||
var multi = new TestMulti(TestMultis.GuildHouse);
|
||||
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
|
||||
var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4);
|
||||
Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using CalcMoves = Server.Movement.Movement;
|
||||
|
|
@ -18,15 +19,14 @@ public class MultiWalkabilityTests
|
|||
private const int PlaceX = 1500;
|
||||
private const int PlaceY = 1600;
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void WallCell_CannotBeEnteredFromAnyAdjacentCell()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
|
||||
var multi = new TestMulti(0x74);
|
||||
var multi = new TestMulti(TestMultis.GuildHouse);
|
||||
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
|
||||
|
||||
var walker = new WalkerStub();
|
||||
|
|
@ -82,15 +82,14 @@ public class MultiWalkabilityTests
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void FloorCell_IsStandable()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
StepCache.Instance.Clear();
|
||||
var map = Map.Maps[MapId];
|
||||
map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _);
|
||||
|
||||
var multi = new TestMulti(0x74);
|
||||
var multi = new TestMulti(TestMultis.GuildHouse);
|
||||
multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map);
|
||||
|
||||
var walker = new WalkerStub();
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ internal static class PathingTestSupport
|
|||
public static Map TestMap => Map.Maps[1];
|
||||
|
||||
/// <summary>
|
||||
/// A cell in open Britain countryside — flat, walkable in all directions, no statics. The
|
||||
/// A cell of the synthetic map's default ground — flat, walkable in all directions, no statics. The
|
||||
/// default subject when a test needs a chunk to exist and doesn't care what's in it.
|
||||
/// </summary>
|
||||
public const int PlainX = 1500;
|
||||
public const int PlainY = 1600;
|
||||
|
||||
/// <summary>The height of the default ground at every cell nothing was stamped on.</summary>
|
||||
public const sbyte PlainZ = 0;
|
||||
|
||||
/// <summary>Index of world cell (x, y) within its own chunk.</summary>
|
||||
public static int CellIndex(int x, int y) => ((y & 15) << 4) | (x & 15);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.IO;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Xunit;
|
||||
using static Server.Tests.Pathfinding.PathingTestSupport;
|
||||
|
||||
namespace Server.Tests.Pathfinding;
|
||||
|
||||
|
|
@ -195,7 +196,7 @@ public class StepCacheFileTests : IDisposable
|
|||
cache.Clear();
|
||||
|
||||
var map = Map.Maps[1];
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ);
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-stalehash-{Guid.NewGuid():N}.swb");
|
||||
try
|
||||
|
|
@ -246,7 +247,7 @@ public class StepCacheFileTests : IDisposable
|
|||
};
|
||||
foreach (var (x, y) in coords)
|
||||
{
|
||||
cache.TryGetMask(map, x, y, sourceZ: 10);
|
||||
cache.TryGetMask(map, x, y, sourceZ: PlainZ);
|
||||
}
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-lazy-{Guid.NewGuid():N}.swb");
|
||||
|
|
@ -261,7 +262,7 @@ public class StepCacheFileTests : IDisposable
|
|||
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||
|
||||
// Query one specific chunk: only that chunk lands in the resident set.
|
||||
cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10);
|
||||
cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: PlainZ);
|
||||
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
||||
Assert.Equal(0L, cache.GetStats().BuildsTotal); // resolved from file, not baker
|
||||
}
|
||||
|
|
@ -291,7 +292,7 @@ public class StepCacheFileTests : IDisposable
|
|||
Assert.NotNull(map);
|
||||
|
||||
// Build a chunk and inject a synthetic swim layer onto one cell.
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ);
|
||||
|
||||
var chunk = cache.GetResidentChunk(map.MapID, 1500 >> 4, 1600 >> 4);
|
||||
Assert.NotNull(chunk);
|
||||
|
|
@ -340,10 +341,9 @@ public class StepCacheFileTests : IDisposable
|
|||
/// to <see cref="LazyReader_DoesNotMaterializeUntilQueried"/> which proves the
|
||||
/// default lazy behavior.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void TryOpenLazyReader_WithPreloadFlag_MaterializesAllChunksImmediately()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1;
|
||||
|
|
@ -357,7 +357,7 @@ public class StepCacheFileTests : IDisposable
|
|||
};
|
||||
foreach (var (x, y) in coords)
|
||||
{
|
||||
cache.TryGetMask(map, x, y, sourceZ: 10);
|
||||
cache.TryGetMask(map, x, y, sourceZ: PlainZ);
|
||||
}
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-preload-{Guid.NewGuid():N}.swb");
|
||||
|
|
@ -374,7 +374,7 @@ public class StepCacheFileTests : IDisposable
|
|||
Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker
|
||||
|
||||
// Subsequent query is a clean Hit, not a Miss_NotBuilt.
|
||||
var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10);
|
||||
var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: PlainZ);
|
||||
Assert.Equal(CacheHitKind.Hit, lookup.HitKind);
|
||||
Assert.Equal(coords.Length, cache.GetStats().ResidentChunks);
|
||||
}
|
||||
|
|
@ -394,10 +394,9 @@ public class StepCacheFileTests : IDisposable
|
|||
/// first pathfind through a region to use the cache rather than the slow path — the gate would
|
||||
/// otherwise defer that first touch and defeat the whole point of shipping the bake.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void LazyReaderHit_BypassesMissTrackerOnFirstTouch()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
cache.MissPromotionThreshold = 1; // eager build for save phase
|
||||
|
|
@ -405,7 +404,7 @@ public class StepCacheFileTests : IDisposable
|
|||
var map = Map.Maps[1];
|
||||
|
||||
// Build + save one chunk.
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ);
|
||||
var path = Path.Combine(Path.GetTempPath(), $"step-cache-bypass-{Guid.NewGuid():N}.swb");
|
||||
try
|
||||
{
|
||||
|
|
@ -419,7 +418,7 @@ public class StepCacheFileTests : IDisposable
|
|||
|
||||
// First touch must NOT return Fallthrough_NotBuilt — the lazy reader has the
|
||||
// chunk and serves it before the miss tracker is consulted.
|
||||
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10);
|
||||
var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ);
|
||||
Assert.True(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind);
|
||||
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using System.IO;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Pathfinding;
|
||||
|
|
@ -15,17 +17,14 @@ public class StepCacheFingerprintTests
|
|||
/// different one, marking the bake stale and re-baking on every boot. Hashing the file makes
|
||||
/// the fingerprint a pure function of the client's tile data, immune to those mutations.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Fingerprint_IgnoresRuntimeTileDataMutation()
|
||||
{
|
||||
// Without tiledata.mul there is no file to hash, so the fingerprint is constant either way.
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
|
||||
const int mapId = 1; // Trammel — loaded by the test bootstrap.
|
||||
|
||||
var before = StepCacheFile.ComputeFingerprint(mapId);
|
||||
|
||||
const int probeId = 0x2A0;
|
||||
const int probeId = TestTiles.Wall;
|
||||
var original = TileData.ItemTable[probeId].Flags;
|
||||
try
|
||||
{
|
||||
|
|
@ -43,4 +42,30 @@ public class StepCacheFingerprintTests
|
|||
TileData.ItemTable[probeId].Flags = original;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The other half of the contract: the fingerprint does change when the file does, so a client patch
|
||||
/// that shifts one tile flag invalidates a bake made against the old file.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TileDataFileHash_ChangesWhenOneFlagChanges()
|
||||
{
|
||||
var directory = Directory.CreateTempSubdirectory("modernuo-fingerprint-").FullName;
|
||||
try
|
||||
{
|
||||
var original = SyntheticTileData.WriteTo(Directory.CreateDirectory(Path.Combine(directory, "a")).FullName);
|
||||
var rewritten = SyntheticTileData.WriteTo(Directory.CreateDirectory(Path.Combine(directory, "b")).FullName);
|
||||
var patched = SyntheticTileData.WriteTo(
|
||||
Directory.CreateDirectory(Path.Combine(directory, "c")).FullName,
|
||||
flags => flags[TestTiles.Wall] ^= TileFlag.NoShoot
|
||||
);
|
||||
|
||||
Assert.Equal(StepCacheFile.HashTileDataFile(original), StepCacheFile.HashTileDataFile(rewritten));
|
||||
Assert.NotEqual(StepCacheFile.HashTileDataFile(original), StepCacheFile.HashTileDataFile(patched));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
/// <summary>Builds the plain chunk and hands it back for a test to inject state into.</summary>
|
||||
private static StepChunk BuiltPlainChunk(StepCache cache, Map map)
|
||||
{
|
||||
cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
|
||||
var chunk = cache.GetResidentChunk(map.MapID, PlainX >> 4, PlainY >> 4);
|
||||
Assert.NotNull(chunk);
|
||||
|
|
@ -53,7 +53,7 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
var cache = FreshCache(promotionThreshold: 2);
|
||||
var map = TestMap;
|
||||
|
||||
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
|
||||
Assert.False(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
|
||||
|
|
@ -65,21 +65,19 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
Assert.Equal(1L, stats.FallthroughNotBuilt);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void SecondTouchInsideWindow_PromotesAndServes()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
|
||||
var cache = FreshCache(promotionThreshold: 2);
|
||||
var map = TestMap;
|
||||
|
||||
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
|
||||
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit);
|
||||
|
||||
var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
Assert.True(promoted.IsHit);
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
|
||||
Assert.Equal((byte)0xC1, promoted.WalkMask); // pinned: open plain, walkable N/NE/... per the bake
|
||||
Assert.Equal((sbyte)10, promoted.WalkZ_N);
|
||||
Assert.Equal((byte)0xFF, promoted.WalkMask); // flat default ground: walkable in all eight directions
|
||||
Assert.Equal(PlainZ, promoted.WalkZ_N);
|
||||
|
||||
var stats = cache.GetStats();
|
||||
Assert.Equal(1, stats.ResidentChunks);
|
||||
|
|
@ -88,9 +86,9 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
Assert.Equal(1L, stats.FallthroughNotBuilt);
|
||||
|
||||
// Now resident: a third query is a clean hit, not another miss.
|
||||
var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
Assert.Equal(CacheHitKind.Hit, hit.HitKind);
|
||||
Assert.Equal((byte)0xC1, hit.WalkMask);
|
||||
Assert.Equal((byte)0xFF, hit.WalkMask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -106,10 +104,10 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
|
||||
var map = TestMap;
|
||||
|
||||
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
|
||||
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit);
|
||||
Thread.Sleep(20); // outrun the window
|
||||
|
||||
var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
Assert.False(second.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
|
||||
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||
|
|
@ -121,11 +119,9 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
/// there, so counting probes would cross any threshold on the second cell and gate nothing at
|
||||
/// all — the deferral would be dead code.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void ManyProbesInOneFind_CountAsOneTouch()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
|
||||
var cache = FreshCache(promotionThreshold: 2);
|
||||
var map = TestMap;
|
||||
|
||||
|
|
@ -133,7 +129,7 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
// Eight different cells, all inside the same chunk.
|
||||
var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: 10);
|
||||
var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: PlainZ);
|
||||
Assert.False(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind);
|
||||
}
|
||||
|
|
@ -144,7 +140,7 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
|
||||
// A second Find is the second distinct touch, and crosses the threshold.
|
||||
cache.BeginFindGeneration();
|
||||
var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
|
||||
Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind);
|
||||
Assert.Equal(1, cache.GetStats().ResidentChunks);
|
||||
|
|
@ -161,12 +157,12 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
var map = TestMap;
|
||||
|
||||
cache.BeginFindGeneration();
|
||||
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
|
||||
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit);
|
||||
|
||||
Thread.Sleep(20);
|
||||
|
||||
cache.BeginFindGeneration();
|
||||
var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
|
||||
Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind);
|
||||
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||
|
|
@ -179,8 +175,8 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
var map = TestMap;
|
||||
|
||||
// One touch each, in two different chunks: neither reaches the threshold on its own.
|
||||
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit);
|
||||
Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: 10).IsHit);
|
||||
Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit);
|
||||
Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: PlainZ).IsHit);
|
||||
|
||||
Assert.Equal(0, cache.GetStats().ResidentChunks);
|
||||
Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt);
|
||||
|
|
@ -202,16 +198,14 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
/// A multi's cells fall through, and so does the 1-cell halo around it: a cell's mask encodes
|
||||
/// the edges TO its neighbours, so a wall one cell over has to block them.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void MultiCoveredCell_AndItsHalo_FallThrough()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
|
||||
var cache = FreshCache(promotionThreshold: 1);
|
||||
var map = TestMap;
|
||||
|
||||
// A cell nowhere near a multi still serves from the static cache.
|
||||
Assert.True(cache.TryGetMask(map, PlainX, PlainY, 10).IsHit);
|
||||
Assert.True(cache.TryGetMask(map, PlainX, PlainY, PlainZ).IsHit);
|
||||
|
||||
// Mark an isolated sector as multi-bearing. Sector.HasMultis only tests Count > 0 and the
|
||||
// fallthrough never dereferences the multi, so a single null entry is enough — no real
|
||||
|
|
@ -254,7 +248,7 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
var cache = FreshCache(promotionThreshold: 1);
|
||||
var map = TestMap;
|
||||
|
||||
cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
var before = cache.GetStats().FallthroughSourceZMismatch;
|
||||
|
||||
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 100);
|
||||
|
|
@ -296,8 +290,8 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
offsets[CellIndex(PlainX, PlainY)] = 0;
|
||||
chunk.SetStrata(offsets, OneStratum(zCenter: 42));
|
||||
|
||||
// 10 is more than StepHeight from the only stratum, so nothing can answer.
|
||||
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
// The plain is more than StepHeight below the only stratum (Z 42), so nothing can answer.
|
||||
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
|
||||
Assert.False(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
|
||||
|
|
@ -321,7 +315,7 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
offsets[CellIndex(PlainX, PlainY)] = 0;
|
||||
chunk.SetStrata(offsets, [0]); // a record declaring zero strata
|
||||
|
||||
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10);
|
||||
var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ);
|
||||
|
||||
Assert.False(lookup.IsHit);
|
||||
Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind);
|
||||
|
|
@ -400,7 +394,7 @@ public class StepCacheLifecycleTests : IDisposable
|
|||
// the count comes down and the key list stays in step, so equal touch ticks are fine.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10);
|
||||
cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: PlainZ);
|
||||
}
|
||||
|
||||
cache.EnforceLruCap();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using static Server.Tests.Pathfinding.PathingTestSupport;
|
||||
|
|
@ -29,6 +30,22 @@ public class StepCacheParityTests : IDisposable
|
|||
|
||||
public void Dispose() => _cacheState.Dispose();
|
||||
|
||||
// "Plain" is the synthetic map's default ground (flat, no statics); every other name is a prefab.
|
||||
private static Rectangle2D Region(string scene)
|
||||
{
|
||||
if (scene == "Plain")
|
||||
{
|
||||
return new Rectangle2D(PlainX, PlainY, 32, 32);
|
||||
}
|
||||
|
||||
var prefab = Prefabs.Named(scene);
|
||||
prefab.AssertPresent();
|
||||
return prefab.Bounds;
|
||||
}
|
||||
|
||||
private static Point3D Seed(string scene) =>
|
||||
scene == "Plain" ? new Point3D(PlainX, PlainY, PlainZ) : Prefabs.Named(scene).Anchor;
|
||||
|
||||
// ---- layer 1: the bake agrees with MovementImpl ----
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -36,12 +53,13 @@ public class StepCacheParityTests : IDisposable
|
|||
/// The probe stores raw masks and leaves the diagonal corner-cut to the caller, so the rule has
|
||||
/// to be applied here before the two are comparable.
|
||||
/// </summary>
|
||||
[SkippableTheory]
|
||||
[InlineData("britain_inn_dense", 1480, 1610, 32)]
|
||||
[InlineData("trammel_open_plain", 1500, 1600, 32)]
|
||||
public void ProbeMatchesSlowPath(string label, int xStart, int yStart, int size)
|
||||
[Theory]
|
||||
[InlineData("Town")]
|
||||
[InlineData("Plain")]
|
||||
public void ProbeMatchesSlowPath(string label)
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var region = Region(label);
|
||||
int xStart = region.X, yStart = region.Y, size = region.Width;
|
||||
|
||||
var map = TestMap;
|
||||
Assert.NotNull(map);
|
||||
|
|
@ -101,7 +119,7 @@ public class StepCacheParityTests : IDisposable
|
|||
|
||||
// The dense region must contain a mix. All-walkable or all-blocked would mean the sweep
|
||||
// agreed about nothing interesting.
|
||||
if (label == "britain_inn_dense")
|
||||
if (label == "Town")
|
||||
{
|
||||
Assert.NotEqual(0, walkable);
|
||||
Assert.NotEqual(samples, walkable);
|
||||
|
|
@ -115,18 +133,15 @@ public class StepCacheParityTests : IDisposable
|
|||
/// WetMask everywhere would pass every parity test above — walkers would still agree — while
|
||||
/// leaving every swimming creature unable to move.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public void ProbeBakesWetCells_OnAKnownCoastline()
|
||||
[Fact]
|
||||
public void ProbeBakesWetCells_OnACoastline()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
|
||||
var map = TestMap;
|
||||
Assert.NotNull(map);
|
||||
|
||||
// South Britain into Britain bay: 64x64 straddling the Atlantic shoreline.
|
||||
const int xStart = 1430;
|
||||
const int yStart = 1740;
|
||||
const int size = 64;
|
||||
var coast = Prefabs.Coastline;
|
||||
coast.AssertPresent();
|
||||
int xStart = coast.Bounds.X, yStart = coast.Bounds.Y, size = coast.Bounds.Width;
|
||||
|
||||
var wetCells = 0;
|
||||
for (var x = xStart; x < xStart + size; x++)
|
||||
|
|
@ -142,7 +157,7 @@ public class StepCacheParityTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"south-britain coastline: wetCells={wetCells} of {size * size}");
|
||||
_output.WriteLine($"coastline: wetCells={wetCells} of {size * size}");
|
||||
Assert.True(wetCells > 0, $"swim bake produced zero wet cells across a {size}x{size} coastal region");
|
||||
}
|
||||
|
||||
|
|
@ -157,15 +172,16 @@ public class StepCacheParityTests : IDisposable
|
|||
/// the land average instead would trip the source-Z guard on raised terrain (a causeway, a
|
||||
/// walkway) and report a fallthrough that is correct behaviour rather than a fault.
|
||||
/// </summary>
|
||||
[SkippableTheory]
|
||||
[InlineData("britain_inn_dense", 1480, 1610, 32)]
|
||||
[InlineData("trammel_open_plain", 1500, 1600, 32)]
|
||||
[InlineData("britain_causeway", 1475, 1641, 32)]
|
||||
public void CacheMatchesProbe(string label, int xStart, int yStart, int size)
|
||||
[Theory]
|
||||
[InlineData("Town")]
|
||||
[InlineData("Plain")]
|
||||
[InlineData("Causeway")]
|
||||
public void CacheMatchesProbe(string label)
|
||||
{
|
||||
// A storage fault (crossed cell index, swapped Z array) only shows against varied terrain;
|
||||
// on a blank map every cell bakes the same answer.
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
// on flat ground every cell bakes the same answer, so the plain alone proves nothing.
|
||||
var region = Region(label);
|
||||
int xStart = region.X, yStart = region.Y, size = region.Width;
|
||||
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
|
|
@ -235,7 +251,7 @@ public class StepCacheParityTests : IDisposable
|
|||
|
||||
// Guard the sweep itself: if every cell fell through as multi-Z, the comparison above never
|
||||
// actually ran and a zero disagreement count would mean nothing.
|
||||
if (label == "britain_inn_dense")
|
||||
if (label == "Town")
|
||||
{
|
||||
Assert.True(samples - multiZ > 0, "no cell produced a real cache answer — the sweep proved nothing");
|
||||
}
|
||||
|
|
@ -260,16 +276,18 @@ public class StepCacheParityTests : IDisposable
|
|||
/// Cardinals only: the cache stores raw masks and applies the corner-cut at query time, so a
|
||||
/// raw diagonal bit legitimately differs from MovementImpl's diagonal answer.
|
||||
/// </summary>
|
||||
[SkippableTheory]
|
||||
// Seeds chosen for the terrain classes the standable-surface bake has to get right. Each one
|
||||
[Theory]
|
||||
// Scenes chosen for the terrain classes the standable-surface bake has to get right. Each seed
|
||||
// floods across a wide local area, so a handful covers thousands of states without a map walk.
|
||||
[InlineData("brit_sewer_walkway", 6034, 1476, 5, 2500)] // static walkway over impassable land
|
||||
[InlineData("brit_inn_stairs_to_floors", 1495, 1628, 10, 2500)] // stairs up to multi-Z upper floors
|
||||
[InlineData("brit_town_cobblestones", 1494, 1626, 10, 2500)] // mixed buildings, stairs, raised floors
|
||||
[InlineData("trammel_open_plain", 1500, 1600, 10, 2500)] // flat ground: catches clearance false-positives
|
||||
public void CacheServesReachableWalkStates(string label, int sx, int sy, int sz, int maxStates)
|
||||
[InlineData("SewerWalkway", 2500)] // static walkway over impassable land
|
||||
[InlineData("Town", 2500)] // buildings, and a ramp up to a deck over open ground (two Zs per cell)
|
||||
[InlineData("Causeway", 2500)] // bridge over impassable land
|
||||
[InlineData("Plain", 2500)] // flat ground: catches clearance false-positives
|
||||
public void CacheServesReachableWalkStates(string label, int maxStates)
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
Region(label);
|
||||
var seed = Seed(label);
|
||||
int sx = seed.X, sy = seed.Y, sz = seed.Z;
|
||||
|
||||
var cache = StepCache.Instance;
|
||||
cache.Clear();
|
||||
|
|
|
|||
|
|
@ -51,13 +51,11 @@ public class TreasureMapChestGuardianTests
|
|||
|
||||
// Drives the real dig, one DigTimer tick per call, until the chest surfaces and its guardians
|
||||
// spawn. Levels 0 and 2+ spawn guardians; level 1 spawns none.
|
||||
[SkippableTheory]
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2)]
|
||||
public void Dig_AddsSpawnedGuardiansToTheChest(int level)
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
|
||||
var map = Map.Felucca;
|
||||
var site = new Point3D(SiteX, SiteY, map.GetAverageZ(SiteX, SiteY));
|
||||
var player = CreatePlayerMobile(map, new Point3D(site.X + 1, site.Y, site.Z));
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
|
|
@ -45,10 +46,9 @@ public class ApproachOutcomeTests : IDisposable
|
|||
return t;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void OpenGround_ReportsDirectProgress_ThenArrived()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var map = Map.Maps[1];
|
||||
var (_, ai) = NewFollower(map, 1500, 1600);
|
||||
var target = NewTarget(map, 1497, 1600);
|
||||
|
|
@ -70,15 +70,13 @@ public class ApproachOutcomeTests : IDisposable
|
|||
Assert.Equal(ApproachOutcome.Arrived, ai.LastApproach);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void WalledTarget_ReportsGaveUp()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var map = Map.Maps[1];
|
||||
var (_, ai) = NewFollower(map, 1500, 1601);
|
||||
var target = NewTarget(map, 1500, 1596);
|
||||
var id = TestTiles.FirstImpassableItemId();
|
||||
Assert.NotEqual<ushort>(0, id);
|
||||
const ushort id = TestTiles.Counter;
|
||||
|
||||
// Impassable ring around the target: unreachable.
|
||||
for (var x = 1499; x <= 1501; x++)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
|
|
@ -87,35 +88,40 @@ public class ApproachTargetTests : IDisposable
|
|||
Assert.Equal(buildsBefore, buildsAfter); // greedy fast path: no chunk builds
|
||||
}
|
||||
|
||||
// The desk is a Britain inn static; on a blank map the detour is a straight walk.
|
||||
[SkippableFact]
|
||||
public void BritainInnDesk_PetReachesMaster()
|
||||
// An upstairs room with an L-shaped counter: the pet is south of it, the master north.
|
||||
[Fact]
|
||||
public void InnCounter_PetReachesMaster()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var inn = Prefabs.InnUpstairs;
|
||||
inn.AssertPresent();
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
// Exact in-game repro: pet south of the L-desk, master north of it.
|
||||
var (bc, ai) = NewFollower(map, new Point3D(1493, 1614, 20));
|
||||
var target = NewTarget(map, new Point3D(1494, 1605, 21));
|
||||
var (bc, ai) = NewFollower(map, inn.Anchor);
|
||||
var target = NewTarget(map, inn.Target);
|
||||
|
||||
// The straight line north runs into the counter, so arriving means the pet went round it.
|
||||
var belowCounter = new Point3D(inn.Anchor.X, inn.Target.Y + 6, inn.Anchor.Z);
|
||||
Assert.False(Server.Movement.Movement.CheckMovement(bc, map, belowCounter, Direction.North, out _));
|
||||
|
||||
StepCache.Instance.Clear();
|
||||
|
||||
// 200 ticks is generous for the ~17-step detour at one step per tick.
|
||||
// 200 ticks is generous for the ~20-step detour at one step per tick.
|
||||
var arrived = DriveFollow(ai, bc, target, 2, 200);
|
||||
|
||||
Assert.True(arrived, "pet must navigate around the L-desk to reach the master");
|
||||
Assert.True(arrived, "pet must navigate around the L-shaped counter to reach the master");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void MoveTo_ReachesTargetAroundDesk()
|
||||
[Fact]
|
||||
public void MoveTo_ReachesTargetAroundCounter()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var inn = Prefabs.InnUpstairs;
|
||||
inn.AssertPresent();
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
|
||||
var (bc, ai) = NewFollower(map, new Point3D(1493, 1614, 20));
|
||||
var target = NewTarget(map, new Point3D(1494, 1605, 21));
|
||||
var (bc, ai) = NewFollower(map, inn.Anchor);
|
||||
var target = NewTarget(map, inn.Target);
|
||||
|
||||
StepCache.Instance.Clear();
|
||||
|
||||
|
|
@ -131,7 +137,7 @@ public class ApproachTargetTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
Assert.True(arrived, "MoveTo chaser must reach the target around the desk");
|
||||
Assert.True(arrived, "MoveTo chaser must reach the target around the counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -169,10 +175,9 @@ public class ApproachTargetTests : IDisposable
|
|||
Assert.True(caught, "chaser must catch a target that walks away then stops");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void UnreachableTarget_GivesUp_AndIdles()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1601, out _, out var z, out _);
|
||||
|
|
@ -181,8 +186,7 @@ public class ApproachTargetTests : IDisposable
|
|||
var target = NewTarget(map, new Point3D(1500, 1596, (sbyte)z));
|
||||
|
||||
// Full impassable ring around the target cell -> no path to within range 1.
|
||||
var id = TestTiles.FirstImpassableItemId();
|
||||
Assert.NotEqual<ushort>(0, id);
|
||||
const ushort id = TestTiles.Counter;
|
||||
for (var x = 1499; x <= 1501; x++)
|
||||
{
|
||||
for (var y = 1595; y <= 1597; y++)
|
||||
|
|
@ -223,10 +227,9 @@ public class ApproachTargetTests : IDisposable
|
|||
Assert.True(stayedIdle, "after giving up, the creature must idle, not shuffle");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void WallBetween_RoutesAround_ReachesTarget()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
|
@ -235,8 +238,7 @@ public class ApproachTargetTests : IDisposable
|
|||
var target = NewTarget(map, new Point3D(1500, 1595, (sbyte)z)); // 5 tiles north
|
||||
|
||||
// Impassable wall on Y=1598 from X=1497..1503: blocks the straight line; ends open.
|
||||
var id = TestTiles.FirstImpassableItemId();
|
||||
Assert.NotEqual<ushort>(0, id);
|
||||
const ushort id = TestTiles.Counter;
|
||||
for (var x = 1497; x <= 1503; x++)
|
||||
{
|
||||
PlaceBlocker(map, id, x, 1598);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ public class DetectHiddenTimerTests
|
|||
public override void DetectHidden() => Detections++;
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[Theory]
|
||||
[InlineData(0, 108000, 132000)]
|
||||
[InlineData(1, 108000, 132000)]
|
||||
[InlineData(100, 108000, 132000)]
|
||||
|
|
@ -38,7 +38,6 @@ public class DetectHiddenTimerTests
|
|||
public void DetectHidden_SchedulesBoundedCooldown_AndWaitsBeforeRetry(
|
||||
int intelligence, long minDelay, long maxDelay)
|
||||
{
|
||||
Skip.If(!TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
|
||||
using var clock = new TimerWheelScope();
|
||||
var creature = new DetectingCreature();
|
||||
DetectingAI ai = null;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Server.Tests.Terrain;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
|
|
@ -54,7 +55,6 @@ public class FamiliarAITests : IDisposable
|
|||
|
||||
public FamiliarAITests()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
_clock = new TimerWheelScope();
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ public class FamiliarAITests : IDisposable
|
|||
return e;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Follows_WithoutBacktracking()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -143,7 +143,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.Equal(Point3D.Zero, f.Home);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Orders_AreInert()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -159,7 +159,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(RunUntil(() => f.InRange(p, 1), 3000), "a familiar under a Stay order still follows");
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[Theory]
|
||||
[InlineData(0, true)] // dark wolf
|
||||
[InlineData(1, true)] // vampire bat
|
||||
[InlineData(2, false)] // shadow wisp
|
||||
|
|
@ -193,7 +193,7 @@ public class FamiliarAITests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void DropsTarget_WhenMasterHides()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -208,7 +208,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(RunUntil(() => f.Combatant == null && f.Hidden, 1000));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Leash_NeverEngagesATargetFarFromTheMaster()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -223,7 +223,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(f.InRange(p, 1), "assist must not carry the familiar past the leash");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Retaliates_IfCombatCapable_AndStaysOnComeOrder()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -244,7 +244,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.False(wisp.Warmode);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void KeepUp_SnapsWhenFarOnOpenGround()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -257,14 +257,16 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(f.InRange(p, 1), "familiar must snap adjacent to a master 12 tiles away on open ground");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void KeepUp_DoesNotSnapWhileRouting()
|
||||
{
|
||||
// Britain inn L-desk: master north, familiar south; ~17-step detour.
|
||||
var p = Master(1494, 1605);
|
||||
var f = Familiar(0, p, 1493, 1614);
|
||||
p.MoveToWorld(new Point3D(1494, 1605, 21), _map);
|
||||
f.MoveToWorld(new Point3D(1493, 1614, 20), _map);
|
||||
// An upstairs room with an L-shaped counter: master north, familiar south; ~20-step detour.
|
||||
var inn = Prefabs.InnUpstairs;
|
||||
inn.AssertPresent();
|
||||
var p = Master(inn.Target.X, inn.Target.Y);
|
||||
var f = Familiar(0, p, inn.Anchor.X, inn.Anchor.Y);
|
||||
p.MoveToWorld(inn.Target, _map);
|
||||
f.MoveToWorld(inn.Anchor, _map);
|
||||
Server.Engines.Pathing.Cache.StepCache.Instance.Clear();
|
||||
|
||||
// Observed from the first tick so an early snap cannot hide behind "arrived".
|
||||
|
|
@ -288,15 +290,14 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.False(teleported, "a working detour must not be short-circuited by a snap");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void KeepUp_SnapsAfterGiveUp()
|
||||
{
|
||||
var p = Master(1500, 1596);
|
||||
var f = Familiar(0, p, 1500, 1602);
|
||||
|
||||
// 5x5 ring, open 3x3 interior: a landing tile exists, no route reaches it.
|
||||
var id = TestTiles.FirstImpassableItemId();
|
||||
Assert.NotEqual<ushort>(0, id);
|
||||
const ushort id = TestTiles.Counter;
|
||||
|
||||
for (var x = 1498; x <= 1502; x++)
|
||||
{
|
||||
|
|
@ -321,7 +322,7 @@ public class FamiliarAITests : IDisposable
|
|||
);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void MirrorsHidden_EvenWhileWalking()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -349,7 +350,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(RunUntil(() => !f.Hidden, 500));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Herding_TargetLocation_WinsAndClears()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -363,7 +364,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(f.InRange(goal, 1));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void NonCombatFamiliar_RefusesAnyCombatant()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -374,7 +375,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.Null(wisp.Combatant);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void HiddenCaster_FamiliarRefusesRetaliation()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -394,7 +395,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(wolf.Hidden);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void LeftBehind_StandsDown()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -409,7 +410,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(RunUntil(() => wolf.Combatant == null && !wolf.Warmode, 500));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void Herding_OutranksCombat_AndStandsDown()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -433,7 +434,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.Same(e, p.Combatant);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void AssistsAgainstWhatTheCastersPetIsFighting()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -457,7 +458,7 @@ public class FamiliarAITests : IDisposable
|
|||
);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void DefendsAnAttackedCaster_WhoseOwnCombatantExpired()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -475,7 +476,7 @@ public class FamiliarAITests : IDisposable
|
|||
);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void DropsATarget_ThatNoLongerFightsAnyone()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
@ -494,7 +495,7 @@ public class FamiliarAITests : IDisposable
|
|||
Assert.True(RunUntil(() => f.Combatant == null && f.InRange(p, 1), 4000), "familiar disengages and returns");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void AssistEnd_LeavesNoStaleMoveIntent()
|
||||
{
|
||||
var p = Master(1500, 1600);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
|
||||
private (PlayerMobile owner, PetTestStub pet) Spawn()
|
||||
{
|
||||
Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
|
||||
var z = Map.Felucca.GetAverageZ(1500, 1600);
|
||||
// The constructed PlayerMobile, not PetTestSetup's deserialization-shaped one: the harmful
|
||||
// checks read PermaFlags, which only the full constructor allocates.
|
||||
|
|
@ -48,7 +47,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
return pet;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardFallbackDoesNotAcquireItsOwner()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -61,7 +60,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Null(pet.Combatant);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardDropsStaleCombatAgainstAnotherPetOfItsOwner()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -75,7 +74,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardDoesNotRetaliateAgainstOwnerFriendlyDamage()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -84,7 +83,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardDoesNotRetaliateAgainstSiblingFriendlyDamage()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -93,7 +92,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Null(pet.Combatant);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardReacquiresAnEnemyAttackingItsOwner()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -107,7 +106,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Same(enemy, pet.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardStillDefendsAgainstAnotherPlayersPet()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -120,7 +119,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Same(enemy, pet.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardIgnoresSiblingInOwnersAggressorHistory()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -131,7 +130,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Null(pet.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardIgnoresOwnControlledSummonAttackingOwner()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -146,7 +145,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Null(pet.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardDefendsAgainstOwnUncontrolledSummonAttackingOwner()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -160,7 +159,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Same(vortex, pet.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void FriendlyDamageDoesNotInterruptGuardCombat()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -173,7 +172,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Same(enemy, pet.Combatant);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void ExplicitAttackStillAcquiresTheCommandedTarget()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -183,7 +182,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Same(sibling, pet.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void BardProvocationStillOverridesGuard()
|
||||
{
|
||||
var (owner, pet) = Spawn();
|
||||
|
|
@ -193,7 +192,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Same(owner, pet.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[Theory]
|
||||
[InlineData(OrderType.Follow)]
|
||||
[InlineData(OrderType.Come)]
|
||||
[InlineData(OrderType.Stay)]
|
||||
|
|
@ -205,7 +204,7 @@ public class GuardFriendlyFireTests : IDisposable
|
|||
Assert.Null(pet.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void GuardingMageDoesNotTurnOnOwnerWhenEnemyDisappears()
|
||||
{
|
||||
var (owner, _) = Spawn();
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ namespace UOContent.Tests.Mobiles.AI;
|
|||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class HerdingTests
|
||||
{
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void HerdedCreature_ReachesTheTile_AndClearsTargetLocation()
|
||||
{
|
||||
TileDataRequirement.SkipIfMissing();
|
||||
var map = Map.Maps[1];
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
|
|
|
|||
|
|
@ -36,10 +36,9 @@ public class SummonAcquireRulesTests : IDisposable
|
|||
return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void PreAosFeluccaVortexPrefersAnyoneOverItsCaster()
|
||||
{
|
||||
Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
|
||||
Core.Expansion = Expansion.UOR;
|
||||
|
||||
// The caster outranks the bystander on the vortex's int-over-distance ranking.
|
||||
|
|
@ -51,12 +50,11 @@ public class SummonAcquireRulesTests : IDisposable
|
|||
Assert.Same(bystander, vortex.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableTheory]
|
||||
[Theory]
|
||||
[InlineData(Expansion.UOR, 1)] // Trammel
|
||||
[InlineData(Expansion.AOS, 0)] // Felucca
|
||||
public void VortexSparesItsCasterOutsidePreAosFelucca(Expansion expansion, int mapIndex)
|
||||
{
|
||||
Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
|
||||
Core.Expansion = expansion;
|
||||
|
||||
var caster = Player(Map.Maps[mapIndex], 1500, 100);
|
||||
|
|
@ -66,10 +64,9 @@ public class SummonAcquireRulesTests : IDisposable
|
|||
Assert.Null(vortex.FocusMob);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
[Fact]
|
||||
public void MasterlessSummonDoesNotBreakAcquisition()
|
||||
{
|
||||
Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data.");
|
||||
Core.Expansion = Expansion.AOS;
|
||||
|
||||
var player = Player(Map.Felucca, 1500, 100);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
using System;
|
||||
using Server.Engines.Pathing.Cache;
|
||||
using Server.Tests.Terrain;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Pathfinding;
|
||||
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class TerrainScopeCacheTests : IDisposable
|
||||
{
|
||||
// Default ground, clear of every prefab.
|
||||
private const int X = 1300;
|
||||
private const int Y = 1300;
|
||||
|
||||
private readonly StepCacheStateScope _cacheState = new();
|
||||
|
||||
public void Dispose() => _cacheState.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void TerrainStampedInAScope_IsNotServedStaleFromTheStepCache()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.MissPromotionThreshold = 1;
|
||||
var map = Map.Trammel;
|
||||
|
||||
var open = cache.TryGetMask(map, X, Y, sourceZ: 0);
|
||||
Assert.True(open.IsHit);
|
||||
Assert.True(open.IsWalkable(Direction.North));
|
||||
|
||||
using (TestTerrain.Scope())
|
||||
{
|
||||
TestTerrain.AddStatic(X, Y - 1, TestTiles.Wall, 0);
|
||||
|
||||
Assert.False(cache.TryGetMask(map, X, Y, sourceZ: 0).IsWalkable(Direction.North));
|
||||
}
|
||||
|
||||
Assert.True(cache.TryGetMask(map, X, Y, sourceZ: 0).IsWalkable(Direction.North));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerrainStampedAfterAQueryInsideAScope_IsNotServedStale()
|
||||
{
|
||||
var cache = StepCache.Instance;
|
||||
cache.MissPromotionThreshold = 1;
|
||||
var map = Map.Trammel;
|
||||
|
||||
using var scope = TestTerrain.Scope();
|
||||
Assert.True(cache.TryGetMask(map, X, Y, sourceZ: 0).IsWalkable(Direction.North));
|
||||
|
||||
TestTerrain.AddStatic(X, Y - 1, TestTiles.Wall, 0);
|
||||
|
||||
Assert.False(cache.TryGetMask(map, X, Y, sourceZ: 0).IsWalkable(Direction.North));
|
||||
}
|
||||
}
|
||||
|
|
@ -24,4 +24,8 @@
|
|||
<Target Name="CopyData" AfterTargets="AfterBuild">
|
||||
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />
|
||||
</Target>
|
||||
<!-- The test host writes its generated tile data here at runtime (SyntheticWorld), so Clean does not track it. -->
|
||||
<Target Name="CleanSyntheticData" AfterTargets="Clean">
|
||||
<RemoveDir Directories="$(OutDir)\SyntheticData" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -216,16 +216,22 @@ internal static class StepCacheFile
|
|||
var path = Core.FindDataFile("tiledata.mul", false);
|
||||
if (path != null)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var hasher = HashUtility.CreateXxHash3();
|
||||
hasher.Append(fs);
|
||||
_tileDataFileFingerprint = hasher.GetCurrentHashAsUInt64();
|
||||
_tileDataFileFingerprint = HashTileDataFile(path);
|
||||
}
|
||||
|
||||
_tileDataFileFingerprintComputed = true;
|
||||
return _tileDataFileFingerprint;
|
||||
}
|
||||
|
||||
/// <summary>XxHash3 of the bytes of the tiledata file at <paramref name="path"/>.</summary>
|
||||
internal static ulong HashTileDataFile(string path)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var hasher = HashUtility.CreateXxHash3();
|
||||
hasher.Append(fs);
|
||||
return hasher.GetCurrentHashAsUInt64();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the map's chunks to <paramref name="path"/>: header, then one record per chunk, then
|
||||
/// the index trailer. IndexOffset isn't known until the records are down, so it goes in as a
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue