## 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
215 lines
6.4 KiB
C#
215 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|