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