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
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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)
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
79
Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs
Normal file
79
Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
33
Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs
Normal file
33
Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
125
Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs
Normal file
125
Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue