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:
parent
00134cac33
commit
54c0040ac8
51 changed files with 1517 additions and 601 deletions
306
Projects/Server.Tests/Terrain/Prefabs.cs
Normal file
306
Projects/Server.Tests/Terrain/Prefabs.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
108
Projects/Server.Tests/Terrain/SyntheticTileData.cs
Normal file
108
Projects/Server.Tests/Terrain/SyntheticTileData.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
27
Projects/Server.Tests/Terrain/SyntheticWorld.cs
Normal file
27
Projects/Server.Tests/Terrain/SyntheticWorld.cs
Normal 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();
|
||||
}
|
||||
63
Projects/Server.Tests/Terrain/TestMultis.cs
Normal file
63
Projects/Server.Tests/Terrain/TestMultis.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
215
Projects/Server.Tests/Terrain/TestTerrain.cs
Normal file
215
Projects/Server.Tests/Terrain/TestTerrain.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
61
Projects/Server.Tests/Terrain/TestTiles.cs
Normal file
61
Projects/Server.Tests/Terrain/TestTiles.cs
Normal 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)
|
||||
];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue