ModernUO/Projects/Server.Tests/Terrain/Prefabs.cs
Kamron Batman 54c0040ac8
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
2026-10-18 22:07:40 -07:00

306 lines
11 KiB
C#

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