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

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