test: run tile-dependent tests on synthetic terrain instead of client files (#2725)

## Summary

About 114 tests skipped on CI because they needed the official client files (`tiledata.mul`, map and multi files). They gave CI no signal, and locally they ran against whichever client a developer had installed. The test hosts now build their own world instead, so every test runs on CI and runs the same on every machine. CI simulation: Server.Tests 837 passed, UOContent.Tests 1131 passed, **0 skipped**.

- **Generated tile data.** The fixtures write a pre-HS `tiledata.mul` from a small invented tile kit (`Server.Tests/Terrain/TestTiles`) and load it through the real `TileData.Load()`. The test hosts no longer read any client directory (`MODERNUO_CLIENT_PATH`, `MODERNUO_TEST_DATA_DIR` and `C:\Ultima Online Classic` are gone from test code). The file is written to `SyntheticData` in each test project's output, overwritten every run and removed by `dotnet clean`.
- **Injected terrain.** `TestTerrain` stamps land and statics into Felucca and Trammel, which share one terrain. Writes after startup must go in a `TestTerrain.Scope()`, which puts back every block it touched and keeps the step cache fresh.
- **Prefab scenes.** Seven named scenes (town, causeway, sewer walkway, coastline, deep sea, map-edge sea, an upstairs room with an L-shaped counter) replace the Britain coordinates the pathing, AI and fishing tests relied on. Each scene-based test asserts its scene is present first, so a missing scene fails instead of passing on flat ground. Stamping all seven takes 3-5 ms.
- **Multi footprints.** Contrived footprints for the three multi IDs content looks up (0x74, 0x7A, 0x0) are seeded through a new internal `MultiData.SetComponents`.
- **Fingerprint.** `StepCacheFile.HashTileDataFile` is extracted so a test can show that a one-flag change to the file changes the fingerprint, while runtime table mutation does not.

### Engine changes

- `TileMatrix.GetLandBlock`/`GetStaticBlock`: on a map without its files, return the block injected with `SetLandBlock`/`SetStaticBlock`, or the default when nothing was injected. Maps with files take the same path as before.
- `MultiData.SetComponents` (internal): registers a footprint without multi files.

### Test changes worth a look

- Values that were properties of real Britain terrain (probes at Z 10, a pinned plain walk mask of 0xC1) are re-pinned to the synthetic plain (Z 0, 0xFF).
- No tests were deleted. The multi walkability and boat-deck tests assert movement behaviour over a footprint, not what a real multi looks like, so they run on the contrived footprints.
- Maps 0 and 1 keep file indexes 0 and 1, and every stamp writes both, rather than sharing one file index. On a map without files the getters never consult the shared-file cache, and its patch bits would hide one facet's injection from the other.

## Test plan

- [x] `dotnet test -p:SolutionDir=... --filter "Category!=Manual"` with the client env vars pointed at an empty directory: 837 + 1131 passed, 0 skipped, 0 failed
- [x] Mutation check: removing the step-cache clear from `TestTerrain` makes the stale-cache tests fail
- [x] CI green on all platforms
This commit is contained in:
Kamron Batman 2026-10-18 22:07:40 -07:00 • committed by GitHub
parent 00134cac33
commit 54c0040ac8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1517 additions and 601 deletions

View file

@ -1,41 +1,28 @@
using Server.Tests.Terrain;
using Xunit;
namespace Server.Tests;
/// <summary>
/// Fixture for all server tests. Attempts to load TileData from client files if available.
/// Configure client path via MODERNUO_CLIENT_PATH environment variable or place files at C:\Ultima Online Classic.
/// Fixture for all server tests. The host runs on generated tile data and terrain (see <see cref="SyntheticWorld"/>).
/// </summary>
[CollectionDefinition("Sequential Server Tests", DisableParallelization = true)]
public class ServerFixture : ICollectionFixture<ServerFixture>
{
/// <summary>
/// True if TileData was successfully loaded from client files.
/// </summary>
public static bool TileDataLoaded => TestServerInitializer.TileDataLoaded;
/// <summary>A tile with the Surface flag.</summary>
public static ushort SurfaceTileId => TestTiles.Floor;
/// <summary>
/// A tile ID known to have the Surface flag. 0 if not found.
/// </summary>
public static ushort SurfaceTileId => TestServerInitializer.SurfaceTileId;
/// <summary>A tile with the Impassable flag.</summary>
public static ushort ImpassableTileId => TestTiles.Rock;
/// <summary>
/// A tile ID known to have the Impassable flag. 0 if not found.
/// </summary>
public static ushort ImpassableTileId => TestServerInitializer.ImpassableTileId;
/// <summary>A tile with the Wet flag (water).</summary>
public static ushort WetTileId => TestTiles.Water;
/// <summary>
/// A tile ID known to have the Wet flag (water). 0 if not found.
/// </summary>
public static ushort WetTileId => TestServerInitializer.WetTileId;
/// <summary>
/// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found.
/// </summary>
public static ushort SurfaceImpassableTileId => TestServerInitializer.SurfaceImpassableTileId;
/// <summary>A tile with both Surface and Impassable flags (tables, furniture).</summary>
public static ushort SurfaceImpassableTileId => TestTiles.Counter;
// Global init runs exactly once via the shared, guarded TestServerInitializer. The single
// bootstrap owns global state for the lifetime of the test host, so there is no
// per-collection teardown — matching UOContent.Tests' TestServerInitializer pattern.
public ServerFixture() => TestServerInitializer.Initialize(loadTileData: true);
public ServerFixture() => TestServerInitializer.Initialize();
}

View file

@ -1,9 +1,9 @@
using System;
using System.IO;
using System.Reflection;
using System.Threading;
using Server.Items;
using Server.Tests.Maps;
using Server.Tests.Terrain;
namespace Server.Tests;
@ -13,51 +13,18 @@ namespace Server.Tests;
/// </summary>
internal static class TestServerInitializer
{
private const string DefaultDataDirectory = @"C:\Ultima Online Classic";
private static bool _initialized;
private static readonly Lock _lock = new();
/// <summary>
/// True if TileData was successfully loaded from client files.
/// </summary>
public static bool TileDataLoaded { get; private set; }
/// <summary>
/// A tile ID known to have the Surface flag. 0 if not found.
/// </summary>
public static ushort SurfaceTileId { get; private set; }
/// <summary>
/// A tile ID known to have the Impassable flag. 0 if not found.
/// </summary>
public static ushort ImpassableTileId { get; private set; }
/// <summary>
/// A tile ID known to have the Wet flag (water). 0 if not found.
/// </summary>
public static ushort WetTileId { get; private set; }
/// <summary>
/// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found.
/// </summary>
public static ushort SurfaceImpassableTileId { get; private set; }
/// <summary>
/// Initializes the test server. Safe to call multiple times - only initializes once.
/// </summary>
/// <param name="loadTileData">If true, attempts to load TileData from client files.</param>
public static void Initialize(bool loadTileData = false)
public static void Initialize()
{
lock (_lock)
{
if (_initialized)
{
// Already initialized, but check if we need to load TileData now
if (loadTileData && !TileDataLoaded && TileData.MaxItemValue == 0)
{
TryLoadTileData();
DetectTileIds();
}
return;
}
@ -66,11 +33,7 @@ internal static class TestServerInitializer
// Load Configurations
ServerConfiguration.Load(true);
// Try to load TileData if requested
if (loadTileData)
{
TryLoadTileData();
}
SyntheticWorld.LoadData();
// Load an empty assembly list into the resolver
ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory);
@ -93,6 +56,7 @@ internal static class TestServerInitializer
// Configure / Initialize
TestMapDefinitions.ConfigureTestMapDefinitions();
SyntheticWorld.StampTerrain();
// Configure the world
World.Configure();
@ -102,90 +66,9 @@ internal static class TestServerInitializer
World.ExitSerializationThreads();
// Detect tile IDs if TileData was loaded
if (loadTileData)
{
DetectTileIds();
}
DecayScheduler.Configure();
_initialized = true;
}
}
private static void TryLoadTileData()
{
var dataDir = GetDataDirectory();
if (string.IsNullOrEmpty(dataDir) || !Directory.Exists(dataDir))
{
return;
}
ServerConfiguration.DataDirectories.Add(dataDir);
var tileDataPath = Core.FindDataFile("tiledata.mul", false);
if (File.Exists(tileDataPath))
{
TileData.Load();
}
}
private static void DetectTileIds()
{
if (TileData.MaxItemValue == 0)
{
return;
}
SurfaceTileId = FindTileWithFlag(TileFlag.Surface);
ImpassableTileId = FindTileWithFlag(TileFlag.Impassable);
WetTileId = FindTileWithFlag(TileFlag.Wet);
SurfaceImpassableTileId = FindTileWithFlags(TileFlag.Surface | TileFlag.Impassable);
TileDataLoaded = SurfaceTileId > 0 && ImpassableTileId > 0 && WetTileId > 0;
}
private static ushort FindTileWithFlag(TileFlag flag)
{
for (ushort i = 1; i <= TileData.MaxItemValue && i < 0xFFFF; i++)
{
var data = TileData.ItemTable[i];
if ((data.Flags & flag) != 0)
{
return i;
}
}
return 0;
}
private static ushort FindTileWithFlags(TileFlag flags)
{
for (ushort i = 1; i <= TileData.MaxItemValue && i < 0xFFFF; i++)
{
var data = TileData.ItemTable[i];
if ((data.Flags & flags) == flags)
{
return i;
}
}
return 0;
}
private static string GetDataDirectory()
{
// Check environment variable first
var envDir = System.Environment.GetEnvironmentVariable("MODERNUO_CLIENT_PATH");
if (!string.IsNullOrEmpty(envDir) && Directory.Exists(envDir))
{
return envDir;
}
// Fall back to default directory
if (Directory.Exists(DefaultDataDirectory))
{
return DefaultDataDirectory;
}
return null;
}
}

View file

@ -26,4 +26,8 @@
<Target Name="CopyData" AfterTargets="AfterBuild">
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />
</Target>
<!-- The test host writes its generated tile data here at runtime (SyntheticWorld), so Clean does not track it. -->
<Target Name="CleanSyntheticData" AfterTargets="Clean">
<RemoveDir Directories="$(OutDir)\SyntheticData" />
</Target>
</Project>

View file

@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
namespace Server.Tests.Terrain;
/// <summary>
/// A named scene stamped once into the shared synthetic terrain. Tests that rely on one call
/// <see cref="AssertPresent"/> first, so a missing scene fails instead of passing on flat default ground.
/// </summary>
public sealed class Prefab
{
private readonly Action<Prefab> _stamp;
private readonly Func<Prefab, bool> _isPresent;
public Prefab(string name, Rectangle2D bounds, Point3D anchor, Action<Prefab> stamp, Func<Prefab, bool> isPresent)
{
Name = name;
Bounds = bounds;
Anchor = anchor;
_stamp = stamp;
_isPresent = isPresent;
}
public string Name { get; }
public Rectangle2D Bounds { get; }
/// <summary>The cell a test starts from, at its standing Z.</summary>
public Point3D Anchor { get; }
/// <summary>A second named cell, for scenes with a start and a goal.</summary>
public Point3D Target { get; init; }
internal void Stamp() => _stamp(this);
public void AssertPresent()
{
if (!_isPresent(this))
{
throw new InvalidOperationException($"Prefab {Name} is not stamped");
}
}
}
/// <summary>
/// The shared scenes. Each lives in its own part of the region (256, 256)-(1280, 1280), and they never
/// overlap. <see cref="MapEdgeSea"/> is the exception: it has to touch the map edge. Bespoke terrain belongs
/// outside that region; only tests about prefabs themselves stamp over one.
/// </summary>
public static class Prefabs
{
private static bool _stamped;
// Sea to the west at Z -5, grass to the east at Z 0. Anchor: the first grass cell east of the water.
public static Prefab Coastline { get; } = new(
"Coastline",
new Rectangle2D(256, 256, 32, 32),
new Point3D(272, 272, 0),
p =>
{
TestTerrain.FillLand(new Rectangle2D(p.Bounds.X, p.Bounds.Y, 16, 32), TestTiles.SeaWater, -5);
TestTerrain.FillLand(new Rectangle2D(p.Bounds.X + 16, p.Bounds.Y, 16, 32), TestTiles.Grass, 0);
},
p => IsLand(p.Anchor.X, p.Anchor.Y, TestTiles.Grass, 0) && IsLand(p.Anchor.X - 1, p.Anchor.Y, TestTiles.SeaWater, -5)
);
// Open water 64 tiles square, ringed by the default land. Anchor: the middle.
public static Prefab DeepSea { get; } = new(
"DeepSea",
new Rectangle2D(320, 256, 64, 64),
new Point3D(352, 288, -5),
p => TestTerrain.FillLand(p.Bounds, TestTiles.SeaWater, -5),
p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.SeaWater, -5) &&
IsLand(p.Bounds.End.X - 1, p.Bounds.End.Y - 1, TestTiles.SeaWater, -5) &&
IsLand(p.Anchor.X, p.Anchor.Y, TestTiles.SeaWater, -5)
);
// Open water against the west edge of the map. Anchor: 13 tiles in from the edge.
public static Prefab MapEdgeSea { get; } = new(
"MapEdgeSea",
new Rectangle2D(0, 176, 48, 48),
new Point3D(13, 200, -5),
p => TestTerrain.FillLand(p.Bounds, TestTiles.SeaWater, -5),
p => IsLand(0, p.Anchor.Y, TestTiles.SeaWater, -5) && IsLand(p.Bounds.End.X - 1, p.Anchor.Y, TestTiles.SeaWater, -5)
);
// Two walled buildings on open ground: one with a doorway and a counter inside, one with a window, and a
// ramp climbing two Z a tile to a walled deck at Z 20 over open ground, so its cells stand at two Zs.
// Anchor: the ground just outside the first building's doorway.
public static Prefab Town { get; } = new(
"Town",
new Rectangle2D(448, 256, 32, 32),
new Point3D(455, 271, 0),
p =>
{
int x0 = p.Bounds.X, y0 = p.Bounds.Y;
WallRing(x0 + 2, y0 + 2, x0 + 13, y0 + 13, 0, x0 + 7, y0 + 13);
for (var x = x0 + 4; x <= x0 + 10; x++)
{
TestTerrain.AddStatic(x, y0 + 6, TestTiles.Counter, 0);
}
WallRing(x0 + 24, y0 + 20, x0 + 29, y0 + 25, 0, x0 + 26, y0 + 20);
TestTerrain.ClearStatics(x0 + 29, y0 + 22);
TestTerrain.AddStatic(x0 + 29, y0 + 22, TestTiles.Window, 0);
// The ramp runs north up x0 + 17 from y0 + 20 (Z 2) to y0 + 11 (Z 20), onto the deck.
for (var i = 0; i < 10; i++)
{
TestTerrain.AddStatic(x0 + 17, y0 + 20 - i, TestTiles.Floor, (sbyte)(2 + 2 * i));
}
for (var x = x0 + 15; x <= x0 + 20; x++)
{
for (var y = y0 + 2; y <= y0 + 10; y++)
{
var edge = x == x0 + 15 || x == x0 + 20 || y == y0 + 2 || y == y0 + 10;
var ramp = x == x0 + 17 && y == y0 + 10;
TestTerrain.AddStatic(x, y, edge && !ramp ? TestTiles.Wall : TestTiles.Floor, 20);
}
}
},
p => IsStatic(p.Bounds.X + 2, p.Bounds.Y + 2, TestTiles.Wall, 0) &&
IsStatic(p.Bounds.X + 17, p.Bounds.Y + 5, TestTiles.Floor, 20)
);
// Impassable rock crossed by a T of bridge tiles. Anchor: on the bridge.
public static Prefab Causeway { get; } = new(
"Causeway",
new Rectangle2D(512, 256, 32, 32),
new Point3D(516, 272, 1),
p =>
{
int x0 = p.Bounds.X, y0 = p.Bounds.Y;
TestTerrain.FillLand(p.Bounds, TestTiles.RockLand, 0);
for (var x = x0; x < x0 + 32; x++)
{
for (var y = y0 + 15; y <= y0 + 17; y++)
{
TestTerrain.AddStatic(x, y, TestTiles.Bridge, 0);
}
}
for (var y = y0; y < y0 + 15; y++)
{
for (var x = x0 + 15; x <= x0 + 17; x++)
{
TestTerrain.AddStatic(x, y, TestTiles.Bridge, 0);
}
}
},
p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.RockLand, 0) && IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Bridge, 0)
);
// Impassable rock under a square loop of walkway three tiles wide at Z 5, the only ground to stand on.
// Anchor: on the walkway.
public static Prefab SewerWalkway { get; } = new(
"SewerWalkway",
new Rectangle2D(576, 256, 32, 32),
new Point3D(579, 271, 5),
p =>
{
int x0 = p.Bounds.X, y0 = p.Bounds.Y;
TestTerrain.FillLand(p.Bounds, TestTiles.RockLand, 0);
for (var x = x0 + 2; x <= x0 + 29; x++)
{
for (var y = y0 + 2; y <= y0 + 29; y++)
{
var onLoop = x <= x0 + 4 || x >= x0 + 27 || y <= y0 + 4 || y >= y0 + 27;
if (onLoop)
{
TestTerrain.AddStatic(x, y, TestTiles.Floor, 5);
}
}
}
},
p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.RockLand, 0) && IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Floor, 5)
);
// A walled upper floor at Z 20 with an L-shaped counter between the south and north halves; the only way
// round is past the east end. Anchor: south of the counter. Target: north of it.
public static Prefab InnUpstairs { get; } = new(
"InnUpstairs",
new Rectangle2D(640, 256, 24, 24),
new Point3D(648, 271, 20),
p =>
{
int x0 = p.Bounds.X, y0 = p.Bounds.Y;
for (var x = x0; x < x0 + 24; x++)
{
for (var y = y0; y < y0 + 24; y++)
{
var edge = x == x0 || x == x0 + 23 || y == y0 || y == y0 + 23;
TestTerrain.AddStatic(x, y, edge ? TestTiles.Wall : TestTiles.Floor, 20);
}
}
for (var x = x0 + 1; x <= x0 + 16; x++)
{
TestTerrain.AddStatic(x, y0 + 11, TestTiles.Counter, 20);
}
for (var y = y0 + 12; y <= y0 + 14; y++)
{
TestTerrain.AddStatic(x0 + 16, y, TestTiles.Counter, 20);
}
},
p => IsStatic(p.Bounds.X + 8, p.Bounds.Y + 11, TestTiles.Counter, 20) &&
IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Floor, 20)
)
{
Target = new Point3D(649, 262, 20)
};
public static IReadOnlyList<Prefab> All { get; } =
[Coastline, DeepSea, MapEdgeSea, Town, Causeway, SewerWalkway, InnUpstairs];
public static Prefab Named(string name)
{
for (var i = 0; i < All.Count; i++)
{
if (All[i].Name == name)
{
return All[i];
}
}
throw new ArgumentException($"No prefab named {name}", nameof(name));
}
/// <summary>Stamps every prefab once, then refuses terrain writes outside a <see cref="TestTerrain.Scope"/>.</summary>
public static void StampAll()
{
if (_stamped)
{
return;
}
for (var i = 0; i < All.Count; i++)
{
for (var j = i + 1; j < All.Count; j++)
{
if (Overlaps(All[i].Bounds, All[j].Bounds))
{
throw new InvalidOperationException($"Prefabs {All[i].Name} and {All[j].Name} overlap");
}
}
}
for (var i = 0; i < All.Count; i++)
{
All[i].Stamp();
}
_stamped = true;
TestTerrain.Sealed = true;
}
// A ring of Wall from (x1, y1) to (x2, y2) inclusive, with one open cell at (gapX, gapY).
private static void WallRing(int x1, int y1, int x2, int y2, sbyte z, int gapX, int gapY)
{
for (var x = x1; x <= x2; x++)
{
for (var y = y1; y <= y2; y++)
{
var edge = x == x1 || x == x2 || y == y1 || y == y2;
if (edge && (x != gapX || y != gapY))
{
TestTerrain.AddStatic(x, y, TestTiles.Wall, z);
}
}
}
}
private static bool Overlaps(Rectangle2D a, Rectangle2D b) =>
a.Start.X < b.End.X && b.Start.X < a.End.X && a.Start.Y < b.End.Y && b.Start.Y < a.End.Y;
// Both facets must hold the tile; a scene on only one of them is not present.
private static bool IsLand(int x, int y, short id, sbyte z)
{
var felucca = Map.Felucca.Tiles.GetLandTile(x, y);
var trammel = Map.Trammel.Tiles.GetLandTile(x, y);
return felucca.ID == id && felucca.Z == z && trammel.ID == id && trammel.Z == z;
}
private static bool IsStatic(int x, int y, ushort id, sbyte z) =>
HasStatic(Map.Felucca, x, y, id, z) && HasStatic(Map.Trammel, x, y, id, z);
private static bool HasStatic(Map map, int x, int y, ushort id, sbyte z)
{
var cell = map.Tiles.GetStaticBlock(x >> 3, y >> 3)[x & 7][y & 7];
for (var i = 0; i < cell.Length; i++)
{
if (cell[i].ID == id && cell[i].Z == z)
{
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,108 @@
using System;
using System.IO;
using System.Text;
namespace Server.Tests.Terrain;
/// <summary>
/// Writes <see cref="TestTiles"/> as a pre-HS tiledata.mul, the smallest layout <see cref="TileData"/> reads:
/// 0x4000 land records of 26 bytes and 0x4000 item records of 37 bytes, with a 4-byte header before every 32.
/// The file must stay under 1,644,544 bytes or TileData.Load picks the 7.0 layout.
/// </summary>
public static class SyntheticTileData
{
private const int LandCount = 0x4000;
private const int ItemCount = 0x4000;
private const int NameLength = 20;
public static byte[] Build(Action<TileFlag[]> mutateItemFlags = null)
{
var landFlags = new TileFlag[LandCount];
var landNames = new string[LandCount];
for (var i = 0; i < TestTiles.Land.Count; i++)
{
var tile = TestTiles.Land[i];
landFlags[tile.Id] = tile.Flags;
landNames[tile.Id] = tile.Name;
}
var itemFlags = new TileFlag[ItemCount];
var itemNames = new string[ItemCount];
var itemHeights = new byte[ItemCount];
for (var i = 0; i < TestTiles.Items.Count; i++)
{
var tile = TestTiles.Items[i];
itemFlags[tile.Id] = tile.Flags;
itemNames[tile.Id] = tile.Name;
itemHeights[tile.Id] = tile.Height;
}
mutateItemFlags?.Invoke(itemFlags);
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
for (var i = 0; i < LandCount; i++)
{
if ((i & 0x1F) == 0)
{
writer.Write(0); // header
}
writer.Write((uint)landFlags[i]);
writer.Write((short)0); // texture
WriteName(writer, landNames[i]);
}
for (var i = 0; i < ItemCount; i++)
{
if ((i & 0x1F) == 0)
{
writer.Write(0); // header
}
writer.Write((uint)itemFlags[i]);
writer.Write((byte)0); // weight
writer.Write((byte)0); // quality
writer.Write((ushort)0); // animation
writer.Write((byte)0);
writer.Write((byte)0); // quantity
writer.Write(0);
writer.Write((byte)0);
writer.Write((byte)0); // value
writer.Write(itemHeights[i]);
WriteName(writer, itemNames[i]);
}
writer.Flush();
return stream.ToArray();
}
/// <summary>Writes tiledata.mul into <paramref name="directory"/> and returns its path.</summary>
public static string WriteTo(string directory, Action<TileFlag[]> mutateItemFlags = null)
{
var path = Path.Combine(directory, "tiledata.mul");
File.WriteAllBytes(path, Build(mutateItemFlags));
return path;
}
/// <summary>Makes <paramref name="directory"/> the only data directory and loads its tiledata.mul.</summary>
public static void LoadInto(string directory)
{
ServerConfiguration.DataDirectories.Clear();
ServerConfiguration.DataDirectories.Add(directory);
TileData.Load();
}
private static void WriteName(BinaryWriter writer, string name)
{
Span<byte> buffer = stackalloc byte[NameLength];
buffer.Clear();
if (name != null)
{
Encoding.ASCII.GetBytes(name, buffer);
}
writer.Write(buffer);
}
}

View file

@ -0,0 +1,27 @@
using System.IO;
namespace Server.Tests.Terrain;
/// <summary>
/// Boots a test host on generated data instead of client files, so every machine and CI see the same world.
/// </summary>
public static class SyntheticWorld
{
/// <summary>Where the generated data files are written: the test host's output, overwritten every run.</summary>
public static string DataDirectory => Path.Combine(Core.BaseDirectory, "SyntheticData");
/// <summary>
/// Writes and loads the generated tiledata.mul from <see cref="DataDirectory"/>, which becomes the only data
/// directory, and seeds the multi footprints. Run before anything reads <see cref="TileData"/>.
/// </summary>
public static void LoadData()
{
Directory.CreateDirectory(DataDirectory);
SyntheticTileData.WriteTo(DataDirectory);
SyntheticTileData.LoadInto(DataDirectory);
TestMultis.Seed();
}
/// <summary>Stamps the prefab scenes. Run once Felucca and Trammel are registered.</summary>
public static void StampTerrain() => Prefabs.StampAll();
}

View file

@ -0,0 +1,63 @@
using System.Collections.Generic;
namespace Server.Tests.Terrain;
/// <summary>
/// Invented footprints for the multi IDs content looks up through <see cref="MultiData"/>
/// (HouseFoundation, BaseBoat and the pathing tests). Only the IDs are real; the shapes are not.
/// Needs tile data loaded first: <see cref="MultiComponentList"/> masks IDs with TileData.MaxItemValue.
/// </summary>
public static class TestMultis
{
public const int GuildHouse = 0x74;
public const int Tower = 0x7A;
public const int SmallBoatNorth = 0x0;
// MultiComponentList drops every entry after the first whose flags are 0.
private const TileFlag Visible = TileFlag.Background;
private const short HouseFloorZ = 1;
public static void Seed()
{
MultiData.SetComponents(GuildHouse, WalledHouse(3, doorwayOnSouth: true));
MultiData.SetComponents(Tower, WalledHouse(4, doorwayOnSouth: false));
MultiData.SetComponents(SmallBoatNorth, Boat());
}
// A square of Floor ringed by Wall, (2 * radius + 1) tiles a side, with a one-tile doorway of Floor
// in the middle of the south wall, or of the east wall. The floor sits one Z above the multi so it is
// clear of flat ground (MultiMaskCache.ComputeFootprintClean) yet still one step up from it.
private static MultiComponentList WalledHouse(int radius, bool doorwayOnSouth)
{
var entries = new List<MultiTileEntry>();
for (var x = -radius; x <= radius; x++)
{
for (var y = -radius; y <= radius; y++)
{
var edge = x == -radius || x == radius || y == -radius || y == radius;
var doorway = doorwayOnSouth ? x == 0 && y == radius : x == radius && y == 0;
var id = edge && !doorway ? TestTiles.Wall : TestTiles.Floor;
entries.Add(new MultiTileEntry(id, (short)x, (short)y, HouseFloorZ, Visible));
}
}
return new MultiComponentList(entries);
}
// A deck of Floor three tiles wide and seven long, with Wall rails down both sides.
private static MultiComponentList Boat()
{
var entries = new List<MultiTileEntry>();
for (var x = -2; x <= 2; x++)
{
for (var y = -3; y <= 3; y++)
{
var id = x == -2 || x == 2 ? TestTiles.Wall : TestTiles.Floor;
entries.Add(new MultiTileEntry(id, (short)x, (short)y, 0, Visible));
}
}
return new MultiComponentList(entries);
}
}

View file

@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using Server.Engines.Pathing.Cache;
namespace Server.Tests.Terrain;
/// <summary>
/// Stamps land and statics into the synthetic terrain. Felucca and Trammel share one terrain, so every write
/// goes to both. Once <see cref="Prefabs.StampAll"/> has run, writes are only allowed inside a
/// <see cref="Scope"/>, which puts back every block it touched when disposed.
/// </summary>
public static class TestTerrain
{
// Blocks this class allocated, keyed by matrix and block. Anything else is the matrix's shared default
// block, which must never be written to.
private static readonly Dictionary<(TileMatrix, int, int), LandTile[]> _land = [];
private static readonly Dictionary<(TileMatrix, int, int), StaticTile[][][]> _statics = [];
private static TerrainScope _scope;
internal static bool Sealed { get; set; }
/// <param name="caller">Filled in by the compiler; names the opener if the scope is left open.</param>
/// <param name="callerFile">Filled in by the compiler.</param>
public static TerrainScope Scope([CallerMemberName] string caller = null, [CallerFilePath] string callerFile = null)
{
if (_scope != null)
{
throw new InvalidOperationException(
$"A terrain scope opened by {_scope.Opener} is still open; dispose it (a using) first."
);
}
// Cached step masks describe the terrain as it was; drop them so nothing serves the old cells.
StepCache.Instance.Clear();
return _scope = new TerrainScope($"{Path.GetFileNameWithoutExtension(callerFile)}.{caller}");
}
public static void SetLand(int x, int y, short id, sbyte z)
{
var facets = Facets();
for (var i = 0; i < facets.Length; i++)
{
LandBlock(facets[i].Tiles, x >> 3, y >> 3)[((y & 7) << 3) + (x & 7)] = new LandTile(id, z);
}
}
public static void FillLand(Rectangle2D area, short id, sbyte z)
{
for (var x = area.Start.X; x < area.End.X; x++)
{
for (var y = area.Start.Y; y < area.End.Y; y++)
{
SetLand(x, y, id, z);
}
}
}
public static void AddStatic(int x, int y, ushort id, sbyte z)
{
var facets = Facets();
for (var i = 0; i < facets.Length; i++)
{
var column = StaticBlock(facets[i].Tiles, x >> 3, y >> 3)[x & 7];
column[y & 7] = [..column[y & 7], new StaticTile(id, z)];
}
}
public static void ClearStatics(int x, int y)
{
var facets = Facets();
for (var i = 0; i < facets.Length; i++)
{
StaticBlock(facets[i].Tiles, x >> 3, y >> 3)[x & 7][y & 7] = [];
}
}
internal static void EndScope(TerrainScope scope)
{
foreach (var ((tiles, bx, by), original) in scope.Land)
{
if (original == null)
{
_land.Remove((tiles, bx, by));
}
else
{
_land[(tiles, bx, by)] = original;
}
tiles.SetLandBlock(bx, by, original);
}
foreach (var ((tiles, bx, by), original) in scope.Statics)
{
if (original == null)
{
_statics.Remove((tiles, bx, by));
}
else
{
_statics[(tiles, bx, by)] = original;
}
tiles.SetStaticBlock(bx, by, original);
}
_scope = null;
StepCache.Instance.Clear();
}
private static Map[] Facets()
{
if (Sealed && _scope == null)
{
throw new InvalidOperationException("Stamp terrain inside TestTerrain.Scope() so it is put back afterwards.");
}
// A test may query the cache and then stamp more; masks built before the stamp must not survive it.
if (_scope != null)
{
StepCache.Instance.Clear();
}
return [Map.Felucca, Map.Trammel];
}
private static LandTile[] LandBlock(TileMatrix tiles, int bx, int by)
{
var key = (tiles, bx, by);
_land.TryGetValue(key, out var owned);
// The scope keeps the block as it was and the stamp goes into a copy, so disposing is a swap back.
if (_scope != null && !_scope.Land.ContainsKey(key))
{
_scope.Land[key] = owned;
owned = (LandTile[])owned?.Clone();
}
else if (owned != null)
{
return owned;
}
var block = owned ?? new LandTile[64];
_land[key] = block;
tiles.SetLandBlock(bx, by, block);
return block;
}
private static StaticTile[][][] StaticBlock(TileMatrix tiles, int bx, int by)
{
var key = (tiles, bx, by);
_statics.TryGetValue(key, out var owned);
if (_scope != null && !_scope.Statics.ContainsKey(key))
{
_scope.Statics[key] = owned;
owned = owned == null ? null : CopyStatics(owned);
}
else if (owned != null)
{
return owned;
}
var block = owned ?? CopyStatics(tiles.EmptyStaticBlock);
_statics[key] = block;
tiles.SetStaticBlock(bx, by, block);
return block;
}
private static StaticTile[][][] CopyStatics(StaticTile[][][] source)
{
var copy = new StaticTile[8][][];
for (var x = 0; x < 8; x++)
{
copy[x] = new StaticTile[8][];
for (var y = 0; y < 8; y++)
{
copy[x][y] = source[x][y];
}
}
return copy;
}
}
/// <summary>
/// Bespoke terrain for one test. Dispose it (a <c>using</c>) to put back every block it touched.
/// </summary>
public sealed class TerrainScope : IDisposable
{
// The block each key held before this scope first wrote to it; null means it was the default.
internal Dictionary<(TileMatrix, int, int), LandTile[]> Land { get; } = [];
internal Dictionary<(TileMatrix, int, int), StaticTile[][][]> Statics { get; } = [];
private bool _disposed;
internal TerrainScope(string opener) => Opener = opener;
/// <summary>The test that opened this scope, as Class.Method.</summary>
public string Opener { get; }
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
TestTerrain.EndScope(this);
}
}

View file

@ -0,0 +1,61 @@
using System.Collections.Generic;
namespace Server.Tests.Terrain;
public readonly record struct TestItemTile(ushort Id, string Name, TileFlag Flags, byte Height);
public readonly record struct TestLandTile(short Id, string Name, TileFlag Flags);
/// <summary>
/// The invented tiles the generated tiledata.mul holds. Every other entry in the file is zero.
/// IDs are arbitrary and carry no meaning from the client, except the content-referenced land below.
/// </summary>
public static class TestTiles
{
public const ushort Wall = 0x3000;
public const ushort Floor = 0x3001;
public const ushort Counter = 0x3002;
public const ushort Stair = 0x3003;
public const ushort Bridge = 0x3004;
public const ushort Roof = 0x3005;
public const ushort Door = 0x3006;
public const ushort Window = 0x3007;
public const ushort Water = 0x3008;
public const ushort Rock = 0x3009;
public const ushort Plain = 0x300A;
public const short Grass = 0x3000;
public const short RockLand = 0x3001;
public const short Dirt = 0x3002;
// Fishing only treats these land IDs as water (Fishing.cs _waterLandTiles), so they keep their numbers.
public const short SeaWater = 0xA8;
public static IReadOnlyList<TestItemTile> Items { get; } =
[
new(Wall, "test wall", TileFlag.Wall | TileFlag.Impassable | TileFlag.NoShoot, 20),
new(Floor, "test floor", TileFlag.Surface | TileFlag.Background, 0),
new(Counter, "test counter", TileFlag.Surface | TileFlag.Impassable, 4),
new(Stair, "test stair", TileFlag.Bridge | TileFlag.Surface | TileFlag.StairBack, 5),
new(Bridge, "test bridge", TileFlag.Bridge | TileFlag.Surface, 2),
new(Roof, "test roof", TileFlag.Roof | TileFlag.Impassable, 0),
new(Door, "test door", TileFlag.Door | TileFlag.Impassable | TileFlag.NoShoot, 20),
new(Window, "test window", TileFlag.Window | TileFlag.Wall | TileFlag.Impassable, 20),
new(Water, "test water", TileFlag.Wet | TileFlag.Impassable | TileFlag.Surface, 0),
new(Rock, "test rock", TileFlag.Impassable, 20),
new(Plain, "test plain", TileFlag.None, 1)
];
public static IReadOnlyList<TestLandTile> Land { get; } =
[
new(Grass, "test grass", TileFlag.None),
new(RockLand, "test rock", TileFlag.Impassable),
new(Dirt, "test dirt", TileFlag.None),
new(0xA8, "test water", TileFlag.Wet | TileFlag.Impassable),
new(0xA9, "test water", TileFlag.Wet | TileFlag.Impassable),
new(0xAA, "test water", TileFlag.Wet | TileFlag.Impassable),
new(0xAB, "test water", TileFlag.Wet | TileFlag.Impassable),
new(0x136, "test water", TileFlag.Wet | TileFlag.Impassable),
new(0x137, "test water", TileFlag.Wet | TileFlag.Impassable)
];
}

View file

@ -9,11 +9,6 @@ namespace Server.Tests.Maps;
[Collection("Sequential Server Tests")]
public class CanFitItemTests
{
private void SkipIfNoTileData()
{
Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required");
}
[Fact]
public void CanFitItem_InternalMapReturnsFalse()
{
@ -33,8 +28,7 @@ public class CanFitItemTests
Assert.False(map.CanFitItem(100, map.Height + 1, 0, 1));
}
// Plain walkable land in the real Felucca map. Without map files every cell is the default land
// tile at Z 0 with no flags, which these tests treat the same way: a surface with nothing above it.
// The synthetic map's default ground: land ID 0 at Z 0 with no flags, a surface with nothing above it.
private const int TestLandX = 1500;
private const int TestLandY = 1600;
@ -61,12 +55,9 @@ public class CanFitItemTests
Assert.False(result, "No surface 50 units above land");
}
[SkippableFact]
[Fact]
public void CanFitItem_SurfaceImpassableMultiIsValidSurface()
{
SkipIfNoTileData();
Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData");
var map = Map.Felucca;
const int x = 1700;
const int y = 1700;
@ -94,12 +85,9 @@ public class CanFitItemTests
}
}
[SkippableFact]
[Fact]
public void CanFitItem_CanFitDoesNotAllowSurfaceImpassable()
{
SkipIfNoTileData();
Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData");
var map = Map.Felucca;
const int x = 1750;
const int y = 1750;
@ -130,11 +118,9 @@ public class CanFitItemTests
}
}
[SkippableFact]
[Fact]
public void CanFitItem_NonMovableWorldItemSurfaceWorks()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1800;
const int y = 1800;
@ -160,11 +146,9 @@ public class CanFitItemTests
}
}
[SkippableFact]
[Fact]
public void CanFitItem_MovableWorldItemNotValidSurface()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1850;
const int y = 1850;
@ -192,11 +176,9 @@ public class CanFitItemTests
}
}
[SkippableFact]
[Fact]
public void CanFitItem_ImpassableTileBlocksPlacement()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1900;
const int y = 1900;

View file

@ -3,8 +3,8 @@ using Xunit;
namespace Server.Tests.Maps;
/// <summary>
/// Tests for CanSpawnMobile that don't require TileData (client files).
/// For tests that require client files, see CanSpawnMobileTileDataTests.
/// Tests for CanSpawnMobile on the default ground.
/// For tests over tile flags, see CanSpawnMobileTileDataTests.
/// </summary>
[Collection("Sequential Server Tests")]
public class CanSpawnMobileTests
@ -29,8 +29,8 @@ public class CanSpawnMobileTests
Assert.False(map.CanSpawnMobile(100, map.Height + 1, -128, 127, false, false, out _));
}
// Plain walkable land near Britain in the real Felucca map. Without map files every cell is the
// default land tile at Z 0 with no flags, which CanSpawnMobile treats the same way: walkable, not water.
// The synthetic map's default ground: land ID 0 at Z 0 with no flags, which CanSpawnMobile treats as
// walkable, not water.
private const int TestLandX = 1500;
private const int TestLandY = 1600;

View file

@ -4,23 +4,14 @@ using Xunit;
namespace Server.Tests.Maps;
/// <summary>
/// Tests for CanSpawnMobile that require TileData (client files) to be loaded.
/// These tests will be skipped if client files are not available.
/// Configure client path via MODERNUO_CLIENT_PATH environment variable or place files at C:\Ultima Online Classic.
/// Tests for CanSpawnMobile over tile flags, using tiles from the generated test kit.
/// </summary>
[Collection("Sequential Server Tests")]
public class CanSpawnMobileTileDataTests
{
private void SkipIfNoTileData()
{
Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required");
}
[SkippableFact]
[Fact]
public void CanSpawnMobile_FindsLowestValidSurface()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1200;
const int y = 1200;
@ -44,11 +35,9 @@ public class CanSpawnMobileTileDataTests
}
}
[SkippableFact]
[Fact]
public void CanSpawnMobile_ZRangeSelectsCorrectFloor()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1300;
const int y = 1300;
@ -72,11 +61,9 @@ public class CanSpawnMobileTileDataTests
}
}
[SkippableFact]
[Fact]
public void CanSpawnMobile_LowCeilingBlocksSpawn()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1400;
const int y = 1400;
@ -99,11 +86,9 @@ public class CanSpawnMobileTileDataTests
}
}
[SkippableFact]
[Fact]
public void CanSpawnMobile_HighCeilingAllowsSpawn()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1450;
const int y = 1450;
@ -124,11 +109,9 @@ public class CanSpawnMobileTileDataTests
}
}
[SkippableFact]
[Fact]
public void CanSpawnMobile_BlockerOutsideZRangeIgnored()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1475;
const int y = 1475;
@ -150,11 +133,9 @@ public class CanSpawnMobileTileDataTests
}
}
[SkippableFact]
[Fact]
public void CanSpawnMobile_WaterSurfaceForSwimmingMob()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1550;
const int y = 1550;
@ -176,11 +157,9 @@ public class CanSpawnMobileTileDataTests
}
}
[SkippableFact]
[Fact]
public void CanSpawnMobile_WorldItemSurfaceWorks()
{
SkipIfNoTileData();
var map = Map.Felucca;
const int x = 1650;
const int y = 1650;

View file

@ -13,7 +13,7 @@ public class StaticTileEnumeratorTests
var map = Map.Felucca;
var location = new Point2D(200, 200);
// Real map files may have statics here; without them there are none. Either way the multi adds nothing.
// The synthetic map has no statics here; the multi must add nothing to the statics-only walk.
var mapStatics = CollectTiles(map, location, includeStatics: true, includeMultis: false);
TestMulti multi = null;

View file

@ -0,0 +1,79 @@
using Xunit;
namespace Server.Tests.Maps;
[Collection("Sequential Server Tests")]
public class TileMatrixInjectionTests
{
// Not registered in Map.Maps, and no data directory holds map96.mul, so this matrix has no file streams.
private static Map NewFilelessMap() => new(0x60, 0x60, 0x60, 64, 64, 0, "inject-test", MapRules.TrammelRules);
[Fact]
public void InjectedLandBlock_IsReturned_WithNoMapFile()
{
var tiles = NewFilelessMap().Tiles;
var block = new LandTile[64];
block[9] = new LandTile(0x10, 7);
tiles.SetLandBlock(1, 1, block);
var tile = tiles.GetLandTile(9, 9);
Assert.Equal(0x10, tile.ID);
Assert.Equal(7, tile.Z);
}
[Fact]
public void UninjectedLand_IsIdZeroAtZZero()
{
var tile = NewFilelessMap().Tiles.GetLandTile(30, 30);
Assert.Equal(0, tile.ID);
Assert.Equal(0, tile.Z);
}
[Fact]
public void InjectedStaticBlock_IsReturned_WithNoStaticFiles()
{
var tiles = NewFilelessMap().Tiles;
var block = new StaticTile[8][][];
for (var x = 0; x < 8; x++)
{
block[x] = new StaticTile[8][];
for (var y = 0; y < 8; y++)
{
block[x][y] = [];
}
}
block[1][1] = [new StaticTile(0x20, 5)];
tiles.SetStaticBlock(1, 1, block);
var cell = tiles.GetStaticBlock(1, 1)[1][1];
Assert.Single(cell);
Assert.Equal(0x20, cell[0].ID);
Assert.Equal(5, cell[0].Z);
}
[Fact]
public void ClearingABlock_RestoresTheDefault()
{
var tiles = NewFilelessMap().Tiles;
var block = new LandTile[64];
block[9] = new LandTile(0x10, 7);
tiles.SetLandBlock(1, 1, block);
tiles.SetLandBlock(1, 1, null);
Assert.Equal(0, tiles.GetLandTile(9, 9).ID);
}
[Fact]
public void OutOfRange_StillReturnsDefault()
{
var tiles = NewFilelessMap().Tiles;
Assert.Equal(0, tiles.GetLandBlock(-1, 0)[0].ID);
Assert.Equal(0, tiles.GetLandBlock(8, 0)[0].ID);
}
}

View file

@ -9,7 +9,7 @@ public class ItemPacketTests : IDisposable
{
private const int TestItemId = 1;
// TileData is global and holds real client data when client files are loaded, so each test restores it.
// TileData is global, so each test restores it.
private readonly ItemData _originalItemData;
public ItemPacketTests()

View file

@ -0,0 +1,59 @@
using System.IO;
using Xunit;
namespace Server.Tests.Terrain;
[Collection("Sequential Server Tests")]
public class SyntheticTileDataTests
{
[Fact]
public void GeneratedFile_IsPreHsSize()
{
Assert.Equal(1_036_288, SyntheticTileData.Build().Length);
}
// Inside the test host's output, rewritten every run, so runs leave nothing behind in the temp directory.
[Fact]
public void DataDirectory_IsTheOnlyDataDirectory_AndLivesInTheTestHost()
{
Assert.Equal(Path.Combine(Core.BaseDirectory, "SyntheticData"), SyntheticWorld.DataDirectory);
Assert.Equal([SyntheticWorld.DataDirectory], ServerConfiguration.DataDirectories);
}
[Fact]
public void KitIds_FitThePreHsTable()
{
for (var i = 0; i < TestTiles.Items.Count; i++)
{
Assert.True(TestTiles.Items[i].Id < 0x4000, $"{TestTiles.Items[i].Name} is outside the pre-HS item table");
}
for (var i = 0; i < TestTiles.Land.Count; i++)
{
Assert.True(TestTiles.Land[i].Id < 0x4000, $"{TestTiles.Land[i].Name} is outside the land table");
}
}
[Fact]
public void GeneratedFile_RoundTripsKitThroughLoad()
{
SyntheticTileData.LoadInto(SyntheticWorld.DataDirectory);
for (var i = 0; i < TestTiles.Items.Count; i++)
{
var tile = TestTiles.Items[i];
Assert.Equal(tile.Flags, TileData.ItemTable[tile.Id].Flags);
Assert.Equal((int)tile.Height, TileData.ItemTable[tile.Id].Height);
Assert.Equal(tile.Name, TileData.ItemTable[tile.Id].Name);
}
for (var i = 0; i < TestTiles.Land.Count; i++)
{
var tile = TestTiles.Land[i];
Assert.Equal(tile.Flags, TileData.LandTable[tile.Id].Flags);
}
Assert.Equal(TileFlag.None, TileData.ItemTable[0x2A0].Flags);
Assert.Equal(TileFlag.None, TileData.LandTable[3].Flags);
}
}

View file

@ -0,0 +1,33 @@
using Xunit;
namespace Server.Tests.Terrain;
[Collection("Sequential Server Tests")]
public class TestMultisTests
{
[Theory]
[InlineData(TestMultis.GuildHouse)]
[InlineData(TestMultis.Tower)]
[InlineData(TestMultis.SmallBoatNorth)]
public void Seed_RegistersFootprint(int multiID)
{
Assert.NotEmpty(MultiData.GetComponents(multiID).List);
}
[Fact]
public void Footprints_DifferBetweenGuildHouseAndTower()
{
var house = MultiData.GetComponents(TestMultis.GuildHouse);
var tower = MultiData.GetComponents(TestMultis.Tower);
Assert.NotEqual(house.Width, tower.Width);
}
[Fact]
public void GuildHouse_HasAnOpenDoorway()
{
var house = MultiData.GetComponents(TestMultis.GuildHouse);
var doorway = house.Tiles[house.Center.X][house.Height - 1];
Assert.Single(doorway);
Assert.Equal(TestTiles.Floor, doorway[0].ID & TileData.MaxItemValue);
}
}

View file

@ -0,0 +1,125 @@
using System;
using Xunit;
namespace Server.Tests.Terrain;
[Collection("Sequential Server Tests")]
public class TestTerrainTests
{
// Default ground outside the prefab region.
private const int X = 1400;
private const int Y = 1400;
[Fact]
public void StampingOneTile_KeepsTheRestOfTheBlock()
{
using var scope = TestTerrain.Scope();
TestTerrain.SetLand(X, Y, TestTiles.Grass, 3);
TestTerrain.SetLand(X + 1, Y, TestTiles.Dirt, 4);
Assert.Equal(TestTiles.Grass, Map.Felucca.Tiles.GetLandTile(X, Y).ID);
Assert.Equal(TestTiles.Dirt, Map.Felucca.Tiles.GetLandTile(X + 1, Y).ID);
}
[Fact]
public void StampingAnUninjectedBlock_DoesNotTouchOtherBlocks()
{
using var scope = TestTerrain.Scope();
TestTerrain.SetLand(X, Y, TestTiles.Grass, 3);
Assert.Equal(0, Map.Felucca.Tiles.GetLandTile(X + 64, Y + 64).ID);
Assert.Equal(0, Map.Felucca.Tiles.GetLandTile(X + 1, Y).ID);
}
[Fact]
public void Stamps_ReachBothFacets()
{
using var scope = TestTerrain.Scope();
TestTerrain.SetLand(X, Y, TestTiles.Grass, 3);
TestTerrain.AddStatic(X, Y, TestTiles.Wall, 3);
foreach (var map in (ReadOnlySpan<Map>)[Map.Felucca, Map.Trammel])
{
Assert.Equal(TestTiles.Grass, map.Tiles.GetLandTile(X, Y).ID);
var statics = map.Tiles.GetStaticBlock(X >> 3, Y >> 3)[X & 7][Y & 7];
Assert.Single(statics);
Assert.Equal(TestTiles.Wall, statics[0].ID);
}
}
[Fact]
public void ScopeDispose_RestoresBothFacets()
{
using (TestTerrain.Scope())
{
TestTerrain.SetLand(X, Y, TestTiles.Grass, 3);
TestTerrain.AddStatic(X, Y, TestTiles.Wall, 3);
}
foreach (var map in (ReadOnlySpan<Map>)[Map.Felucca, Map.Trammel])
{
Assert.Equal(0, map.Tiles.GetLandTile(X, Y).ID);
Assert.Empty(map.Tiles.GetStaticBlock(X >> 3, Y >> 3)[X & 7][Y & 7]);
}
}
[Fact]
public void ScopeDispose_RestoresAPrefabItOverwrote()
{
Prefabs.StampAll();
var coast = Prefabs.Coastline.Anchor;
var before = Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y);
using (TestTerrain.Scope())
{
TestTerrain.SetLand(coast.X, coast.Y, TestTiles.RockLand, 40);
}
Assert.Equal(before.ID, Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y).ID);
Assert.Equal(before.Z, Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y).Z);
Prefabs.Coastline.AssertPresent();
}
[Fact]
public void NestedScopes_Throw()
{
using var scope = TestTerrain.Scope();
var error = Assert.Throws<InvalidOperationException>(() => TestTerrain.Scope());
Assert.Contains($"{nameof(TestTerrainTests)}.{nameof(NestedScopes_Throw)}", error.Message);
}
[Fact]
public void StampingOutsideAScope_Throws_OncePrefabsAreStamped()
{
Prefabs.StampAll();
Assert.Throws<InvalidOperationException>(() => TestTerrain.SetLand(X, Y, TestTiles.Grass, 0));
}
[Fact]
public void StampAll_PrefabsDoNotOverlap_AndAreAllPresent()
{
Prefabs.StampAll();
for (var i = 0; i < Prefabs.All.Count; i++)
{
Prefabs.All[i].AssertPresent();
}
}
[Fact]
public void AssertPresent_Throws_WhenThePrefabIsGone()
{
Prefabs.StampAll();
var coast = Prefabs.Coastline.Anchor;
using var scope = TestTerrain.Scope();
TestTerrain.SetLand(coast.X, coast.Y, 0, 0);
Assert.Throws<InvalidOperationException>(() => Prefabs.Coastline.AssertPresent());
}
}