diff --git a/Projects/Server.Tests/Fixtures/ServerFixture.cs b/Projects/Server.Tests/Fixtures/ServerFixture.cs index e2795848f..cdc3aade5 100644 --- a/Projects/Server.Tests/Fixtures/ServerFixture.cs +++ b/Projects/Server.Tests/Fixtures/ServerFixture.cs @@ -1,41 +1,28 @@ +using Server.Tests.Terrain; using Xunit; namespace Server.Tests; /// -/// 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 ). /// [CollectionDefinition("Sequential Server Tests", DisableParallelization = true)] public class ServerFixture : ICollectionFixture { - /// - /// True if TileData was successfully loaded from client files. - /// - public static bool TileDataLoaded => TestServerInitializer.TileDataLoaded; + /// A tile with the Surface flag. + public static ushort SurfaceTileId => TestTiles.Floor; - /// - /// A tile ID known to have the Surface flag. 0 if not found. - /// - public static ushort SurfaceTileId => TestServerInitializer.SurfaceTileId; + /// A tile with the Impassable flag. + public static ushort ImpassableTileId => TestTiles.Rock; - /// - /// A tile ID known to have the Impassable flag. 0 if not found. - /// - public static ushort ImpassableTileId => TestServerInitializer.ImpassableTileId; + /// A tile with the Wet flag (water). + public static ushort WetTileId => TestTiles.Water; - /// - /// A tile ID known to have the Wet flag (water). 0 if not found. - /// - public static ushort WetTileId => TestServerInitializer.WetTileId; - - /// - /// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found. - /// - public static ushort SurfaceImpassableTileId => TestServerInitializer.SurfaceImpassableTileId; + /// A tile with both Surface and Impassable flags (tables, furniture). + 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(); } diff --git a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs index 845efb115..9a77377c5 100644 --- a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -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; /// internal static class TestServerInitializer { - private const string DefaultDataDirectory = @"C:\Ultima Online Classic"; private static bool _initialized; private static readonly Lock _lock = new(); - /// - /// True if TileData was successfully loaded from client files. - /// - public static bool TileDataLoaded { get; private set; } - - /// - /// A tile ID known to have the Surface flag. 0 if not found. - /// - public static ushort SurfaceTileId { get; private set; } - - /// - /// A tile ID known to have the Impassable flag. 0 if not found. - /// - public static ushort ImpassableTileId { get; private set; } - - /// - /// A tile ID known to have the Wet flag (water). 0 if not found. - /// - public static ushort WetTileId { get; private set; } - - /// - /// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found. - /// - public static ushort SurfaceImpassableTileId { get; private set; } - /// /// Initializes the test server. Safe to call multiple times - only initializes once. /// - /// If true, attempts to load TileData from client files. - 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; - } } diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 3d1190779..c97b92c94 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -26,4 +26,8 @@ + + + + diff --git a/Projects/Server.Tests/Terrain/Prefabs.cs b/Projects/Server.Tests/Terrain/Prefabs.cs new file mode 100644 index 000000000..677b5f14b --- /dev/null +++ b/Projects/Server.Tests/Terrain/Prefabs.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; + +namespace Server.Tests.Terrain; + +/// +/// A named scene stamped once into the shared synthetic terrain. Tests that rely on one call +/// first, so a missing scene fails instead of passing on flat default ground. +/// +public sealed class Prefab +{ + private readonly Action _stamp; + private readonly Func _isPresent; + + public Prefab(string name, Rectangle2D bounds, Point3D anchor, Action stamp, Func isPresent) + { + Name = name; + Bounds = bounds; + Anchor = anchor; + _stamp = stamp; + _isPresent = isPresent; + } + + public string Name { get; } + + public Rectangle2D Bounds { get; } + + /// The cell a test starts from, at its standing Z. + public Point3D Anchor { get; } + + /// A second named cell, for scenes with a start and a goal. + public Point3D Target { get; init; } + + internal void Stamp() => _stamp(this); + + public void AssertPresent() + { + if (!_isPresent(this)) + { + throw new InvalidOperationException($"Prefab {Name} is not stamped"); + } + } +} + +/// +/// The shared scenes. Each lives in its own part of the region (256, 256)-(1280, 1280), and they never +/// overlap. is the exception: it has to touch the map edge. Bespoke terrain belongs +/// outside that region; only tests about prefabs themselves stamp over one. +/// +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 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)); + } + + /// Stamps every prefab once, then refuses terrain writes outside a . + 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; + } +} diff --git a/Projects/Server.Tests/Terrain/SyntheticTileData.cs b/Projects/Server.Tests/Terrain/SyntheticTileData.cs new file mode 100644 index 000000000..93f0ca873 --- /dev/null +++ b/Projects/Server.Tests/Terrain/SyntheticTileData.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.Text; + +namespace Server.Tests.Terrain; + +/// +/// Writes as a pre-HS tiledata.mul, the smallest layout 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. +/// +public static class SyntheticTileData +{ + private const int LandCount = 0x4000; + private const int ItemCount = 0x4000; + private const int NameLength = 20; + + public static byte[] Build(Action 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(); + } + + /// Writes tiledata.mul into and returns its path. + public static string WriteTo(string directory, Action mutateItemFlags = null) + { + var path = Path.Combine(directory, "tiledata.mul"); + File.WriteAllBytes(path, Build(mutateItemFlags)); + return path; + } + + /// Makes the only data directory and loads its tiledata.mul. + public static void LoadInto(string directory) + { + ServerConfiguration.DataDirectories.Clear(); + ServerConfiguration.DataDirectories.Add(directory); + TileData.Load(); + } + + private static void WriteName(BinaryWriter writer, string name) + { + Span buffer = stackalloc byte[NameLength]; + buffer.Clear(); + if (name != null) + { + Encoding.ASCII.GetBytes(name, buffer); + } + + writer.Write(buffer); + } +} diff --git a/Projects/Server.Tests/Terrain/SyntheticWorld.cs b/Projects/Server.Tests/Terrain/SyntheticWorld.cs new file mode 100644 index 000000000..df246584e --- /dev/null +++ b/Projects/Server.Tests/Terrain/SyntheticWorld.cs @@ -0,0 +1,27 @@ +using System.IO; + +namespace Server.Tests.Terrain; + +/// +/// Boots a test host on generated data instead of client files, so every machine and CI see the same world. +/// +public static class SyntheticWorld +{ + /// Where the generated data files are written: the test host's output, overwritten every run. + public static string DataDirectory => Path.Combine(Core.BaseDirectory, "SyntheticData"); + + /// + /// Writes and loads the generated tiledata.mul from , which becomes the only data + /// directory, and seeds the multi footprints. Run before anything reads . + /// + public static void LoadData() + { + Directory.CreateDirectory(DataDirectory); + SyntheticTileData.WriteTo(DataDirectory); + SyntheticTileData.LoadInto(DataDirectory); + TestMultis.Seed(); + } + + /// Stamps the prefab scenes. Run once Felucca and Trammel are registered. + public static void StampTerrain() => Prefabs.StampAll(); +} diff --git a/Projects/Server.Tests/Terrain/TestMultis.cs b/Projects/Server.Tests/Terrain/TestMultis.cs new file mode 100644 index 000000000..147e772bb --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestMultis.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; + +namespace Server.Tests.Terrain; + +/// +/// Invented footprints for the multi IDs content looks up through +/// (HouseFoundation, BaseBoat and the pathing tests). Only the IDs are real; the shapes are not. +/// Needs tile data loaded first: masks IDs with TileData.MaxItemValue. +/// +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(); + 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(); + 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); + } +} diff --git a/Projects/Server.Tests/Terrain/TestTerrain.cs b/Projects/Server.Tests/Terrain/TestTerrain.cs new file mode 100644 index 000000000..713a1cc22 --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestTerrain.cs @@ -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; + +/// +/// Stamps land and statics into the synthetic terrain. Felucca and Trammel share one terrain, so every write +/// goes to both. Once has run, writes are only allowed inside a +/// , which puts back every block it touched when disposed. +/// +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; } + + /// Filled in by the compiler; names the opener if the scope is left open. + /// Filled in by the compiler. + 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; + } +} + +/// +/// Bespoke terrain for one test. Dispose it (a using) to put back every block it touched. +/// +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; + + /// The test that opened this scope, as Class.Method. + public string Opener { get; } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + TestTerrain.EndScope(this); + } +} diff --git a/Projects/Server.Tests/Terrain/TestTiles.cs b/Projects/Server.Tests/Terrain/TestTiles.cs new file mode 100644 index 000000000..22a03269b --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestTiles.cs @@ -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); + +/// +/// 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. +/// +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 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 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) + ]; +} diff --git a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs index 5531e0530..ace2dfcf4 100644 --- a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs @@ -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; diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs index 23bc9d173..0fd8b41fa 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs @@ -3,8 +3,8 @@ using Xunit; namespace Server.Tests.Maps; /// -/// 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. /// [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; diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs index 38d757463..a3daaf04c 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs @@ -4,23 +4,14 @@ using Xunit; namespace Server.Tests.Maps; /// -/// 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. /// [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; diff --git a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs index 9c6e01a36..70fc9e006 100644 --- a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs @@ -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; diff --git a/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs b/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs new file mode 100644 index 000000000..443e142eb --- /dev/null +++ b/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs @@ -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); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs index f9d5cb948..d2736a723 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs @@ -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() diff --git a/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs b/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs new file mode 100644 index 000000000..12e057204 --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs @@ -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); + } +} diff --git a/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs b/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs new file mode 100644 index 000000000..afb35b784 --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs @@ -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); + } +} diff --git a/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs b/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs new file mode 100644 index 000000000..8d04ae98f --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs @@ -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.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.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(() => TestTerrain.Scope()); + Assert.Contains($"{nameof(TestTerrainTests)}.{nameof(NestedScopes_Throw)}", error.Message); + } + + [Fact] + public void StampingOutsideAScope_Throws_OncePrefabsAreStamped() + { + Prefabs.StampAll(); + + Assert.Throws(() => 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(() => Prefabs.Coastline.AssertPresent()); + } +} diff --git a/Projects/Server/Client/MultiData.cs b/Projects/Server/Client/MultiData.cs index 43498614d..d45998bfc 100644 --- a/Projects/Server/Client/MultiData.cs +++ b/Projects/Server/Client/MultiData.cs @@ -52,6 +52,12 @@ public static class MultiData public static MultiComponentList GetComponents(int multiID) => _components.TryGetValue(multiID & 0x3FFF, out var mcl) ? mcl : MultiComponentList.Empty; + /// + /// Registers a footprint without multi files, for hosts that generate their own (tests). + /// + 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); diff --git a/Projects/Server/TileMatrix/TileMatrix.cs b/Projects/Server/TileMatrix/TileMatrix.cs index 333cea53e..70303a8d7 100644 --- a/Projects/Server/TileMatrix/TileMatrix.cs +++ b/Projects/Server/TileMatrix/TileMatrix.cs @@ -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]; diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index bca909a3e..ddded5fdd 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -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 [CollectionDefinition(DisableParallelization = true)] so they never /// overlap; pure tests still run in parallel. +/// +/// The host runs on generated tile data, terrain and multi footprints (see ), +/// never on client files, so every machine and CI run the same world. /// internal static class TestServerInitializer { private static bool _initialized; private static readonly Lock _lock = new(); - /// - /// 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 Skip.If(!TestServerInitializer.TileDataLoaded, ...). - /// - 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")}." - ); - } - } } diff --git a/Projects/UOContent.Tests/Fixtures/TileDataRequirement.cs b/Projects/UOContent.Tests/Fixtures/TileDataRequirement.cs deleted file mode 100644 index 7b9af1f27..000000000 --- a/Projects/UOContent.Tests/Fixtures/TileDataRequirement.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Xunit; - -namespace Server.Tests; - -/// -/// Shared guard for tests that require the copyrighted UO client tile/map data (tiledata.mul and -/// friends), which is absent on CI. Call as the first statement of a -/// [SkippableFact]/[SkippableTheory] so the test is skipped — not failed — when the -/// data was not loaded. See . -/// -internal static class TileDataRequirement -{ - public static void SkipIfMissing() => - Skip.If( - !TestServerInitializer.TileDataLoaded, - "Requires UO client tile data (tiledata.mul); absent on CI." - ); -} diff --git a/Projects/UOContent.Tests/Helpers/AiTestStubs.cs b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs index 9d954c062..6e69523b8 100644 --- a/Projects/UOContent.Tests/Helpers/AiTestStubs.cs +++ b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs @@ -20,23 +20,3 @@ public sealed class BareCreatureStub : BaseCreature { public BareCreatureStub(Serial serial) : base(serial) => Body = 0xC9; } - -public static class TestTiles -{ - /// - /// 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. - /// - public static ushort FirstImpassableItemId() - { - for (ushort id = 1; id < TileData.MaxItemValue; id++) - { - if (TileData.ItemTable[id].ImpassableSurface) - { - return id; - } - } - - return 0; - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs index ae8fe35d9..328123d7a 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index ed8b8d006..ad63c7982 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -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(0, blockerItemId); + const ushort blockerItemId = TestTiles.Counter; map.GetAverageZ(sx, sy, out _, out var startZ, out _); var start = new Point3D(sx, sy, (sbyte)startZ); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs index cf63d4858..32a0c2a85 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs @@ -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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs index 5284df402..1f28950dc 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs @@ -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 diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs index 5ecb0cdc1..f2ae9a751 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs @@ -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 diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs index 50a27ee44..09bbbae78 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs @@ -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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs index e7e368c38..6c3a6c324 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs @@ -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) /// @@ -25,10 +26,9 @@ public class MultiEdgeCaseTests /// GetStaticAndMultiTiles yields tiles from BOTH multis; the synthesizer must still /// agree with CheckMovement everywhere over the union footprint + halo. /// - [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). /// - [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 Components — i.e. it matches CheckMovement on the NEW shape. /// - [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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs index 2b2adf93c..8bad6655b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs @@ -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 diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs index b415ec3d6..1ad7839e0 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs @@ -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 _); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs index 9ec598916..723ae5988 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs @@ -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(); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs index 8f20d04f4..343892de0 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs @@ -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"); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs index 1f993d477..d69cffa8b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs @@ -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(); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs index de0fdaaae..aa6a065cb 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs @@ -16,12 +16,15 @@ internal static class PathingTestSupport public static Map TestMap => Map.Maps[1]; /// - /// 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. /// public const int PlainX = 1500; public const int PlainY = 1600; + /// The height of the default ground at every cell nothing was stamped on. + public const sbyte PlainZ = 0; + /// Index of world cell (x, y) within its own chunk. public static int CellIndex(int x, int y) => ((y & 15) << 4) | (x & 15); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs index 4094c093f..4584b4464 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -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 which proves the /// default lazy behavior. /// - [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. /// - [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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs index 6d2bc6f7a..1c9a4c5a6 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs @@ -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. /// - [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; } } + + /// + /// 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. + /// + [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); + } + } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index fd90aedf9..abe9a87d2 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -33,7 +33,7 @@ public class StepCacheLifecycleTests : IDisposable /// Builds the plain chunk and hands it back for a test to inject state into. 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); } /// @@ -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. /// - [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. /// - [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(); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs index 769945af1..12d4b0ed4 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs @@ -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 ---- /// @@ -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. /// - [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. /// - [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. /// - [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. /// - [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(); diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs index 633025042..7a3145d70 100644 --- a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs @@ -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)); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs index 55965375b..ff7211755 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs @@ -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(0, id); + const ushort id = TestTiles.Counter; // Impassable ring around the target: unreachable. for (var x = 1499; x <= 1501; x++) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index e330268f0..aa8e7d27b 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -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(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(0, id); + const ushort id = TestTiles.Counter; for (var x = 1497; x <= 1503; x++) { PlaceBlocker(map, id, x, 1598); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs index 84898b7f1..7bf6ffb5c 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs @@ -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; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs index 27b4ee0a0..5b366ddab 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs @@ -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(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); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs index ee3bfe7cd..0161ae2dd 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs @@ -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(); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs index 6c88649fa..13119ca03 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs @@ -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 _); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs index d80dbd5bc..1a2f6d737 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs @@ -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); diff --git a/Projects/UOContent.Tests/Tests/Terrain/TerrainScopeCacheTests.cs b/Projects/UOContent.Tests/Tests/Terrain/TerrainScopeCacheTests.cs new file mode 100644 index 000000000..d4db67c68 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Terrain/TerrainScopeCacheTests.cs @@ -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)); + } +} diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 85ec80f45..1d7f20e68 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs index 538807448..faa3a982c 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -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; } + /// XxHash3 of the bytes of the tiledata file at . + 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(); + } + /// /// Writes the map's chunks to : 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