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:
Kamron Batman 2026-10-18 22:07:40 -07:00 • committed by GitHub
parent 00134cac33
commit 54c0040ac8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1517 additions and 601 deletions

View file

@ -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();
}

View file

@ -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;
}
}

View file

@ -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>

View 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;
}
}

View 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);
}
}

View 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();
}

View 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);
}
}

View 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);
}
}

View 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)
];
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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);
}
}

View file

@ -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()

View file

@ -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);
}
}

View 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);
}
}

View 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());
}
}

View file

@ -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);

View file

@ -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];

View file

@ -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")}."
);
}
}
}

View file

@ -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."
);
}

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -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

View file

@ -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 _);

View file

@ -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();

View file

@ -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");

View file

@ -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();

View file

@ -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);

View file

@ -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);

View file

@ -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);
}
}
}

View file

@ -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();

View file

@ -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();

View file

@ -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));

View file

@ -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++)

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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();

View file

@ -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 _);

View file

@ -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);

View file

@ -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));
}
}

View file

@ -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>

View file

@ -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