diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index eb74c39a4..d9219a87f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -57,7 +57,7 @@ jobs: # blame-hang kills a stuck test host after 10 minutes and reports the in-flight # tests plus a process dump instead of hanging until the job timeout. run: | - dotnet test --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full + dotnet test --filter "Category!=Manual" --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full if [ -z "$(find ./TestResults -name '*.trx' 2>/dev/null)" ]; then echo "::error::No test result files were produced - no test projects ran. Failing to avoid masking failures." exit 1 @@ -167,7 +167,7 @@ jobs: # blame-hang kills a stuck test host after 10 minutes and reports the in-flight # tests plus a process dump instead of hanging until the job timeout. run: | - dotnet test --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full + dotnet test --filter "Category!=Manual" --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full if [ -z "$(find ./TestResults -name '*.trx' 2>/dev/null)" ]; then echo "::error::No test result files were produced - no test projects ran. Failing to avoid masking failures." exit 1 diff --git a/Projects/Server.Tests/Fixtures/ServerFixture.cs b/Projects/Server.Tests/Fixtures/ServerFixture.cs index e2795848f..cdc3aade5 100644 --- a/Projects/Server.Tests/Fixtures/ServerFixture.cs +++ b/Projects/Server.Tests/Fixtures/ServerFixture.cs @@ -1,41 +1,28 @@ +using Server.Tests.Terrain; using Xunit; namespace Server.Tests; /// -/// 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 ). /// [CollectionDefinition("Sequential Server Tests", DisableParallelization = true)] public class ServerFixture : ICollectionFixture { - /// - /// True if TileData was successfully loaded from client files. - /// - public static bool TileDataLoaded => TestServerInitializer.TileDataLoaded; + /// A tile with the Surface flag. + public static ushort SurfaceTileId => TestTiles.Floor; - /// - /// A tile ID known to have the Surface flag. 0 if not found. - /// - public static ushort SurfaceTileId => TestServerInitializer.SurfaceTileId; + /// A tile with the Impassable flag. + public static ushort ImpassableTileId => TestTiles.Rock; - /// - /// A tile ID known to have the Impassable flag. 0 if not found. - /// - public static ushort ImpassableTileId => TestServerInitializer.ImpassableTileId; + /// A tile with the Wet flag (water). + public static ushort WetTileId => TestTiles.Water; - /// - /// A tile ID known to have the Wet flag (water). 0 if not found. - /// - public static ushort WetTileId => TestServerInitializer.WetTileId; - - /// - /// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found. - /// - public static ushort SurfaceImpassableTileId => TestServerInitializer.SurfaceImpassableTileId; + /// A tile with both Surface and Impassable flags (tables, furniture). + 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(); } diff --git a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs index 845efb115..9a77377c5 100644 --- a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -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; /// internal static class TestServerInitializer { - private const string DefaultDataDirectory = @"C:\Ultima Online Classic"; private static bool _initialized; private static readonly Lock _lock = new(); - /// - /// True if TileData was successfully loaded from client files. - /// - public static bool TileDataLoaded { get; private set; } - - /// - /// A tile ID known to have the Surface flag. 0 if not found. - /// - public static ushort SurfaceTileId { get; private set; } - - /// - /// A tile ID known to have the Impassable flag. 0 if not found. - /// - public static ushort ImpassableTileId { get; private set; } - - /// - /// A tile ID known to have the Wet flag (water). 0 if not found. - /// - public static ushort WetTileId { get; private set; } - - /// - /// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found. - /// - public static ushort SurfaceImpassableTileId { get; private set; } - /// /// Initializes the test server. Safe to call multiple times - only initializes once. /// - /// If true, attempts to load TileData from client files. - 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; - } } diff --git a/Projects/Server.Tests/Helpers/EntityCleanup.cs b/Projects/Server.Tests/Helpers/EntityCleanup.cs new file mode 100644 index 000000000..61917b61e --- /dev/null +++ b/Projects/Server.Tests/Helpers/EntityCleanup.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Server.Tests; + +/// +/// Deletes tracked entities on dispose, so a test that fails mid-way does not leave them in the shared World. +/// Null and already-deleted entities are skipped. +/// +internal sealed class EntityCleanup : IDisposable +{ + private readonly List _entities = []; + + public T Track(T entity) where T : IEntity + { + _entities.Add(entity); + return entity; + } + + public void Dispose() + { + // Reverse order deletes children before the containers or owners that hold them. + for (var i = _entities.Count - 1; i >= 0; i--) + { + if (_entities[i] is { Deleted: false } entity) + { + entity.Delete(); + } + } + + _entities.Clear(); + } +} diff --git a/Projects/Server.Tests/Helpers/MockAccount.cs b/Projects/Server.Tests/Helpers/MockAccount.cs index 8d8973d86..2725d3438 100644 --- a/Projects/Server.Tests/Helpers/MockAccount.cs +++ b/Projects/Server.Tests/Helpers/MockAccount.cs @@ -20,15 +20,27 @@ public class MockAccount : IAccount public string Username { get; } public string Email { get; set; } public AccessLevel AccessLevel { get; set; } - public int Length { get; } + public int Length { get; private set; } public int Limit { get; set; } = 6; // Default to 6 character slots - public int Count { get; } + public int Count { get; private set; } private readonly Dictionary _mobiles = new(); public Mobile this[int index] { get => _mobiles.GetValueOrDefault(index); - set => _mobiles[index] = value; + set + { + var old = _mobiles.GetValueOrDefault(index); + _mobiles[index] = value; + + Length = Math.Max(Length, index + 1); + Count += (value != null ? 1 : 0) - (old != null ? 1 : 0); + + if (value != null) + { + value.Account = this; + } + } } public DateTime Created { get; set; } diff --git a/Projects/Server.Tests/Helpers/NetTestHelpers.cs b/Projects/Server.Tests/Helpers/NetTestHelpers.cs new file mode 100644 index 000000000..3f3cef3fc --- /dev/null +++ b/Projects/Server.Tests/Helpers/NetTestHelpers.cs @@ -0,0 +1,152 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Net.Sockets; +using System.Network; +using System.Runtime.CompilerServices; +using System.Threading; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Shared plumbing for tests that drive a real over loopback. +/// +public static class NetTestHelpers +{ + public const byte PingPacketId = 0x73; + + /// The payload byte of the last ping delivered to the handler from . + public static byte LastPing { get; private set; } + + /// + /// Restores the handler that was in a packet slot before a test replaced it. The handler table is + /// process-global, so a handler left behind would silently answer for that id in every later test. + /// + public readonly struct HandlerRegistration : IDisposable + { + private readonly int _packetId; + private readonly PacketHandler _previous; + + internal HandlerRegistration(int packetId, PacketHandler previous) + { + _packetId = packetId; + _previous = previous; + } + + public void Dispose() => IncomingPackets.Handlers[_packetId] = _previous; + } + + public static unsafe HandlerRegistration RegisterHandler( + int packetId, int length, delegate* onReceive + ) + { + var previous = IncomingPackets.GetHandler(packetId); + IncomingPackets.Register(packetId, length, false, onReceive); + return new HandlerRegistration(packetId, previous); + } + + /// Registers a two-byte ping whose payload byte is recorded in . + public static unsafe HandlerRegistration RegisterPing() + { + LastPing = 0; + return RegisterHandler(PingPacketId, 2, &RecordPing); + } + + private static void RecordPing(NetState state, SpanReader reader) => LastPing = reader.ReadByte(); + + public static byte[] Pattern(int length, int seed) + { + var data = new byte[length]; + new System.Random(seed).NextBytes(data); + return data; + } + + /// + /// Pre-auth buffers are off only where the platform floor leaves no room below the base size + /// (the Windows Server 2012/2016 mapping path, or a 64 KiB-page kernel). Anywhere else a zero + /// initial size is a configuration regression and fails rather than skips. + /// + public static void RequirePreAuthBuffers() + { + var floor = IORingBuffer.MinimumSize; + Skip.If( + floor >= NetState.RecvBufferSize || floor >= NetState.SendBufferSize, + $"Pre-auth buffers are off on this platform: the {floor}-byte mapping floor leaves no room below the base buffers (recv {NetState.RecvBufferSize}, send {NetState.SendBufferSize})" + ); + + Assert.True( + NetState.InitialRecvBufferSize > 0 && NetState.InitialSendBufferSize > 0, + $"Pre-auth buffers are off although the {floor}-byte platform floor allows them (initial recv {NetState.InitialRecvBufferSize}, send {NetState.InitialSendBufferSize})" + ); + } + + /// Runs the network slice until holds, failing with the condition that timed out. + public static void SliceUntil( + Func done, int timeoutMs = 5000, [CallerArgumentExpression(nameof(done))] string condition = null + ) + { + var deadline = Stopwatch.StartNew(); + while (!done()) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms waiting for {condition}"); + } + + NetState.Slice(); + Thread.Sleep(5); + } + } + + /// Reads exactly bytes from the peer, slicing the server while it waits. + public static byte[] ReadAll(Socket client, int length, int timeoutMs = 10000) + { + var received = new byte[length]; + var total = 0; + var deadline = Stopwatch.StartNew(); + while (total < length) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms with {total} of {length} bytes received by the peer"); + } + + NetState.Slice(); + + // Poll takes microseconds, not milliseconds + if (client.Poll(50_000, SelectMode.SelectRead)) + { + var read = client.Receive(received, total, length - total, SocketFlags.None); + Assert.True(read != 0, $"Peer saw EOF after {total} of {length} bytes"); + total += read; + } + } + + return received; + } + + /// Waits for the server's FIN to reach the peer, failing if any data arrives first. + public static void WaitForEof(Socket client, int timeoutMs = 10000) + { + var scratch = new byte[64]; + var deadline = Stopwatch.StartNew(); + while (true) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms waiting for the peer to see EOF"); + } + + NetState.Slice(); + + if (client.Poll(50_000, SelectMode.SelectRead)) + { + var read = client.Receive(scratch); + Assert.True(read == 0, $"Peer received {read} unexpected bytes while waiting for EOF"); + return; + } + } + } +} diff --git a/Projects/Server.Tests/Helpers/PacketAssert.cs b/Projects/Server.Tests/Helpers/PacketAssert.cs new file mode 100644 index 000000000..ca8efb5ee --- /dev/null +++ b/Projects/Server.Tests/Helpers/PacketAssert.cs @@ -0,0 +1,31 @@ +using System; +using Server.Network; + +namespace Server.Tests.Network; + +public static class PacketAssert +{ + /// + /// Asserts that writes exactly the bytes of the reference + /// packet to a fresh test NetState negotiated with . + /// + public static void Sends(Packet expected, Action send, ProtocolChanges changes = ProtocolChanges.None) => + Sends(_ => expected, send, changes); + + /// + /// As , for a reference packet that depends on + /// the negotiated NetState. It is built after is applied and before the send. + /// + public static void Sends( + Func expected, Action send, ProtocolChanges changes = ProtocolChanges.None + ) + { + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.ProtocolChanges = changes; + + var expectedBytes = expected(ns).Compile(); + send(ns); + + AssertThat.Equal(ns.SendBuffer.GetReadSpan(), expectedBytes); + } +} diff --git a/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs b/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs new file mode 100644 index 000000000..dbdb05524 --- /dev/null +++ b/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace Server.Tests; + +/// +/// A private save pipeline: a chunk source with its own serialization workers, optionally installed as +/// World's workers, optionally a scratch directory and an extra assembly visible to the type loader. +/// Dispose unregisters every tracked persistence, exits the workers and restores every static it swapped. +/// +internal sealed class PersistencePipelineScope : IDisposable +{ + private readonly List _persistences = []; + private readonly SerializationThreadWorker[] _previousWorkers; + private readonly Assembly[] _previousAssemblies; + private readonly DateTime _previousSaveStart; + private readonly bool _installedWorkers; + private readonly bool _swappedAssemblies; + + /// Workers to create, each with an allocated heap. + /// Install the workers as World's, which WriteSnapshot reads segments from. + /// An assembly the loader must resolve types from by hash. + /// Creates a unique scratch directory under the temp path when set. + public PersistencePipelineScope( + int workerCount, + bool installWorkers = false, + Assembly visibleAssembly = null, + string directoryPrefix = null + ) + { + _previousSaveStart = World.SaveStartTime; + + Source = new SerializationChunkSource(); + Workers = new SerializationThreadWorker[workerCount]; + for (var i = 0; i < Workers.Length; i++) + { + Workers[i] = new SerializationThreadWorker(i, Source); + Workers[i].AllocateHeap(); + } + + if (installWorkers) + { + _installedWorkers = true; + _previousWorkers = World._threadWorkers; + World._threadWorkers = Workers; + } + + if (visibleAssembly != null) + { + _swappedAssemblies = true; + _previousAssemblies = AssemblyHandler.Assemblies; + AssemblyHandler.Assemblies = [.. _previousAssemblies ?? [], visibleAssembly]; + } + + if (directoryPrefix != null) + { + Directory = Path.Combine(Path.GetTempPath(), $"{directoryPrefix}-{Guid.NewGuid():N}"); + System.IO.Directory.CreateDirectory(Directory); + } + } + + public SerializationChunkSource Source { get; } + + public SerializationThreadWorker[] Workers { get; } + + /// The scratch directory, or null when none was requested. + public string Directory { get; } + + /// Tracks a persistence so it leaves the static registry on dispose. + public T Track(T persistence) where T : Persistence + { + _persistences.Add(persistence); + return persistence; + } + + public void WakeWorkers() + { + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Wake(); + } + } + + public void SleepWorkers() + { + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Sleep(); + } + } + + /// Mirrors World.PauseSerializationThreads: publish the partial chunk, then wait for the drain. + public void FlushAndSleepWorkers() + { + Source.Flush(); + SleepWorkers(); + } + + public void Dispose() + { + World.SaveStartTime = _previousSaveStart; + + for (var i = 0; i < _persistences.Count; i++) + { + _persistences[i]?.Unregister(); + } + + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Exit(); + } + + if (_installedWorkers) + { + World._threadWorkers = _previousWorkers; + } + + if (_swappedAssemblies) + { + AssemblyHandler.Assemblies = _previousAssemblies; + } + + if (Directory != null) + { + System.IO.Directory.Delete(Directory, true); + } + } +} diff --git a/Projects/Server.Tests/Helpers/SimulatedClockScope.cs b/Projects/Server.Tests/Helpers/SimulatedClockScope.cs new file mode 100644 index 000000000..cb4b5acfd --- /dev/null +++ b/Projects/Server.Tests/Helpers/SimulatedClockScope.cs @@ -0,0 +1,42 @@ +using System; +using Server.Items; + +namespace Server.Tests; + +/// +/// Lets a test move the game clock () and restores it on dispose, so a failed +/// assert cannot leave every later test running in the future. +/// +internal sealed class SimulatedClockScope : IDisposable +{ + private static readonly TimeSpan _decayTickStep = TimeSpan.FromMilliseconds(256); + + public SimulatedClockScope() => Start = Core._now; + + public DateTime Start { get; } + + public DateTime Now + { + get => Core._now; + set => Core._now = value; + } + + /// + /// Steps the clock from through , driving the decay + /// scheduler each step without the shared timer wheel, and stops early once + /// is deleted. + /// + public void AdvanceDecay(TimeSpan duration, Item item) + { + var start = Core._now; + var deadline = start + duration; + + for (var now = start; now <= deadline && !item.Deleted; now += _decayTickStep) + { + Core._now = now; + DecayScheduler.ProcessTick(now); + } + } + + public void Dispose() => Core._now = Start; +} diff --git a/Projects/Server.Tests/Helpers/TestClientHarness.cs b/Projects/Server.Tests/Helpers/TestClientHarness.cs new file mode 100644 index 000000000..aa9dde5d0 --- /dev/null +++ b/Projects/Server.Tests/Helpers/TestClientHarness.cs @@ -0,0 +1,82 @@ +using System; +using System.Buffers; +using Server.Network; +using Server.Tests.Network; + +namespace Server.Tests; + +/// +/// A logged-in test client (NetState + Mobile) and byte-pattern probes over what it was sent. +/// The probes search the raw send buffer, so they only see packets queued since the NetState was created. +/// +internal static class TestClientHarness +{ + public static (NetState, Mobile) CreateClient(Point3D location) + { + var ns = PacketTestUtilities.CreateTestNetState(); + ns.Account = new MockAccount(); + + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + ns.Mobile = mobile; + mobile.NetState = ns; + mobile.MoveToWorld(location, Map.Felucca); + return (ns, mobile); + } + + public static void DisposeClient(NetState ns, Mobile mobile) + { + ns.Mobile = null; + ns.Dispose(); + mobile.Delete(); + } + + public static bool ReceivedRemove(NetState ns, Serial serial) => CountRemoves(ns, serial) > 0; + + public static int CountRemoves(NetState ns, Serial serial) + { + Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; + OutgoingEntityPackets.CreateRemoveEntity(expected, serial); + + var span = ns.SendBuffer.GetReadSpan(); + var count = 0; + + while (true) + { + var index = span.IndexOf(expected); + + if (index < 0) + { + break; + } + + count++; + span = span[(index + expected.Length)..]; + } + + return count; + } + + public static bool ReceivedWorldItem(NetState ns, Item item) + { + Span expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength]; + var length = OutgoingItemPackets.CreateWorldItem(expected, item); + return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0; + } + + public static bool ReceivedContentUpdate(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0x25, serial); + + public static bool ReceivedEquipUpdate(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0x2E, serial); + + public static bool ReceivedOplInfo(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0xDC, serial); + + // Each of these packets is fixed-length with the serial immediately after the packet ID. + private static bool ReceivedPacketForSerial(NetState ns, byte packetId, Serial serial) + { + Span expected = stackalloc byte[5]; + var writer = new SpanWriter(expected); + writer.Write(packetId); + writer.Write(serial); + return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; + } +} diff --git a/Projects/Server.Tests/Helpers/TestMulti.cs b/Projects/Server.Tests/Helpers/TestMulti.cs new file mode 100644 index 000000000..b7363c3f7 --- /dev/null +++ b/Projects/Server.Tests/Helpers/TestMulti.cs @@ -0,0 +1,17 @@ +using Server.Items; + +namespace Server.Tests; + +/// +/// A multi with caller-supplied components. The test host never loads MultiData, so a multi built +/// from client data would have no components; without a list this one is empty the same way. +/// +internal sealed class TestMulti : BaseMulti +{ + private readonly MultiComponentList _components; + + public TestMulti(MultiComponentList components = null) : base(0x1) => + _components = components ?? MultiComponentList.Empty; + + public override MultiComponentList Components => _components; +} diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 8ae939ce1..c97b92c94 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,10 +5,10 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -26,4 +26,8 @@ + + + + diff --git a/Projects/Server.Tests/Terrain/Prefabs.cs b/Projects/Server.Tests/Terrain/Prefabs.cs new file mode 100644 index 000000000..677b5f14b --- /dev/null +++ b/Projects/Server.Tests/Terrain/Prefabs.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; + +namespace Server.Tests.Terrain; + +/// +/// A named scene stamped once into the shared synthetic terrain. Tests that rely on one call +/// first, so a missing scene fails instead of passing on flat default ground. +/// +public sealed class Prefab +{ + private readonly Action _stamp; + private readonly Func _isPresent; + + public Prefab(string name, Rectangle2D bounds, Point3D anchor, Action stamp, Func isPresent) + { + Name = name; + Bounds = bounds; + Anchor = anchor; + _stamp = stamp; + _isPresent = isPresent; + } + + public string Name { get; } + + public Rectangle2D Bounds { get; } + + /// The cell a test starts from, at its standing Z. + public Point3D Anchor { get; } + + /// A second named cell, for scenes with a start and a goal. + public Point3D Target { get; init; } + + internal void Stamp() => _stamp(this); + + public void AssertPresent() + { + if (!_isPresent(this)) + { + throw new InvalidOperationException($"Prefab {Name} is not stamped"); + } + } +} + +/// +/// The shared scenes. Each lives in its own part of the region (256, 256)-(1280, 1280), and they never +/// overlap. is the exception: it has to touch the map edge. Bespoke terrain belongs +/// outside that region; only tests about prefabs themselves stamp over one. +/// +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 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)); + } + + /// Stamps every prefab once, then refuses terrain writes outside a . + 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; + } +} diff --git a/Projects/Server.Tests/Terrain/SyntheticTileData.cs b/Projects/Server.Tests/Terrain/SyntheticTileData.cs new file mode 100644 index 000000000..93f0ca873 --- /dev/null +++ b/Projects/Server.Tests/Terrain/SyntheticTileData.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.Text; + +namespace Server.Tests.Terrain; + +/// +/// Writes as a pre-HS tiledata.mul, the smallest layout 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. +/// +public static class SyntheticTileData +{ + private const int LandCount = 0x4000; + private const int ItemCount = 0x4000; + private const int NameLength = 20; + + public static byte[] Build(Action 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(); + } + + /// Writes tiledata.mul into and returns its path. + public static string WriteTo(string directory, Action mutateItemFlags = null) + { + var path = Path.Combine(directory, "tiledata.mul"); + File.WriteAllBytes(path, Build(mutateItemFlags)); + return path; + } + + /// Makes the only data directory and loads its tiledata.mul. + public static void LoadInto(string directory) + { + ServerConfiguration.DataDirectories.Clear(); + ServerConfiguration.DataDirectories.Add(directory); + TileData.Load(); + } + + private static void WriteName(BinaryWriter writer, string name) + { + Span buffer = stackalloc byte[NameLength]; + buffer.Clear(); + if (name != null) + { + Encoding.ASCII.GetBytes(name, buffer); + } + + writer.Write(buffer); + } +} diff --git a/Projects/Server.Tests/Terrain/SyntheticWorld.cs b/Projects/Server.Tests/Terrain/SyntheticWorld.cs new file mode 100644 index 000000000..df246584e --- /dev/null +++ b/Projects/Server.Tests/Terrain/SyntheticWorld.cs @@ -0,0 +1,27 @@ +using System.IO; + +namespace Server.Tests.Terrain; + +/// +/// Boots a test host on generated data instead of client files, so every machine and CI see the same world. +/// +public static class SyntheticWorld +{ + /// Where the generated data files are written: the test host's output, overwritten every run. + public static string DataDirectory => Path.Combine(Core.BaseDirectory, "SyntheticData"); + + /// + /// Writes and loads the generated tiledata.mul from , which becomes the only data + /// directory, and seeds the multi footprints. Run before anything reads . + /// + public static void LoadData() + { + Directory.CreateDirectory(DataDirectory); + SyntheticTileData.WriteTo(DataDirectory); + SyntheticTileData.LoadInto(DataDirectory); + TestMultis.Seed(); + } + + /// Stamps the prefab scenes. Run once Felucca and Trammel are registered. + public static void StampTerrain() => Prefabs.StampAll(); +} diff --git a/Projects/Server.Tests/Terrain/TestMultis.cs b/Projects/Server.Tests/Terrain/TestMultis.cs new file mode 100644 index 000000000..147e772bb --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestMultis.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; + +namespace Server.Tests.Terrain; + +/// +/// Invented footprints for the multi IDs content looks up through +/// (HouseFoundation, BaseBoat and the pathing tests). Only the IDs are real; the shapes are not. +/// Needs tile data loaded first: masks IDs with TileData.MaxItemValue. +/// +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(); + 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(); + 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); + } +} diff --git a/Projects/Server.Tests/Terrain/TestTerrain.cs b/Projects/Server.Tests/Terrain/TestTerrain.cs new file mode 100644 index 000000000..713a1cc22 --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestTerrain.cs @@ -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; + +/// +/// Stamps land and statics into the synthetic terrain. Felucca and Trammel share one terrain, so every write +/// goes to both. Once has run, writes are only allowed inside a +/// , which puts back every block it touched when disposed. +/// +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; } + + /// Filled in by the compiler; names the opener if the scope is left open. + /// Filled in by the compiler. + 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; + } +} + +/// +/// Bespoke terrain for one test. Dispose it (a using) to put back every block it touched. +/// +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; + + /// The test that opened this scope, as Class.Method. + public string Opener { get; } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + TestTerrain.EndScope(this); + } +} diff --git a/Projects/Server.Tests/Terrain/TestTiles.cs b/Projects/Server.Tests/Terrain/TestTiles.cs new file mode 100644 index 000000000..22a03269b --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestTiles.cs @@ -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); + +/// +/// 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. +/// +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 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 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) + ]; +} diff --git a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs index d4777b758..71ce89b3d 100644 --- a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs @@ -56,25 +56,6 @@ public class RawInterpolatedStringHandlerTests handler.Clear(); } - [Fact] - public void TestNoFormatPreservesCase() - { - var handler = new RawInterpolatedStringHandler(0, 1); - handler.AppendFormatted("Hello WORLD"); - Assert.Equal("Hello WORLD", handler.Text.ToString()); - handler.Clear(); - } - - [Fact] - public void TestUnicodeLowercase() - { - // ToLowerInvariant on Greek letter - var handler = new RawInterpolatedStringHandler(0, 1); - handler.AppendFormatted("ΑΒΓ", format: "L"); - Assert.Equal("αβγ", handler.Text.ToString()); - handler.Clear(); - } - [Fact] public void TestLowercaseFormatSurrogatePairAtChunkBoundary() { diff --git a/Projects/Server.Tests/Tests/Buffers/SpanReaderTests.cs b/Projects/Server.Tests/Tests/Buffers/SpanReaderTests.cs index 8d6e24e6d..6fa212acd 100644 --- a/Projects/Server.Tests/Tests/Buffers/SpanReaderTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/SpanReaderTests.cs @@ -497,60 +497,6 @@ public class SpanReaderTests Assert.Equal(3, reader.Position); } - [Fact] - public void TestLength() - { - ReadOnlySpan buffer = [0x01, 0x02, 0x03, 0x04, 0x05]; - var reader = new SpanReader(buffer); - - Assert.Equal(5, reader.Length); - } - - [Fact] - public void TestPosition() - { - ReadOnlySpan buffer = [0x01, 0x02, 0x03]; - var reader = new SpanReader(buffer); - - Assert.Equal(0, reader.Position); - - reader.ReadByte(); - Assert.Equal(1, reader.Position); - - reader.ReadUInt16(); - Assert.Equal(3, reader.Position); - } - - [Fact] - public void TestRemaining() - { - ReadOnlySpan buffer = [0x01, 0x02, 0x03, 0x04, 0x05]; - var reader = new SpanReader(buffer); - - Assert.Equal(5, reader.Remaining); - - reader.ReadByte(); - Assert.Equal(4, reader.Remaining); - - reader.ReadUInt16(); - Assert.Equal(2, reader.Remaining); - - reader.ReadUInt16(); - Assert.Equal(0, reader.Remaining); - } - - [Fact] - public void TestBuffer() - { - ReadOnlySpan buffer = [0x01, 0x02, 0x03]; - var reader = new SpanReader(buffer); - - var bufferProperty = reader.Buffer; - - Assert.Equal(3, bufferProperty.Length); - AssertThat.Equal(bufferProperty, buffer); - } - [Fact] public void TestReadStringEmptyFixedLength() { diff --git a/Projects/Server.Tests/Tests/Buffers/SpanWriterTests.cs b/Projects/Server.Tests/Tests/Buffers/SpanWriterTests.cs index 370024370..10fe1b73f 100644 --- a/Projects/Server.Tests/Tests/Buffers/SpanWriterTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/SpanWriterTests.cs @@ -75,19 +75,6 @@ public class SpanWriterTests AssertThat.Equal(writer.Span, [1, 0]); } - [Fact] - public void TestWriteByte() - { - Span buffer = stackalloc byte[2]; - var writer = new SpanWriter(buffer); - - writer.Write((byte)0x12); - writer.Write((byte)0x34); - - Assert.Equal(2, writer.Position); - AssertThat.Equal(writer.Span, [0x12, 0x34]); - } - [Fact] public void TestWriteSByte() { @@ -235,19 +222,6 @@ public class SpanWriterTests AssertThat.Equal(writer.Span, [0x01, 0x02, 0x03, 0x01, 0x02, 0x03]); } - [Fact] - public void TestWriteAsciiChar() - { - Span buffer = stackalloc byte[2]; - var writer = new SpanWriter(buffer); - - writer.WriteAscii('A'); - writer.WriteAscii('B'); - - Assert.Equal(2, writer.Position); - AssertThat.Equal(writer.Span, "AB"u8); - } - [Fact] public void TestWriteAsciiString() { @@ -457,16 +431,6 @@ public class SpanWriterTests writer.Dispose(); } - [Fact] - public void TestEnsureCapacityNoResize() - { - Span buffer = stackalloc byte[10]; - var writer = new SpanWriter(buffer); - - writer.EnsureCapacity(10); - Assert.Equal(10, writer.Capacity); - } - [Fact] public void TestEnsureCapacityNoResizeThrows() { @@ -552,40 +516,4 @@ public class SpanWriterTests writer.Write((byte)0xFF); Assert.Equal(3, writer.BytesWritten); } - - [Fact] - public void TestCapacity() - { - Span buffer = stackalloc byte[10]; - var writer = new SpanWriter(buffer); - - Assert.Equal(10, writer.Capacity); - } - - [Fact] - public void TestRawBuffer() - { - Span buffer = stackalloc byte[10]; - var writer = new SpanWriter(buffer); - - writer.Write((byte)0x01); - - var raw = writer.RawBuffer; - Assert.Equal(10, raw.Length); - Assert.Equal(0x01, raw[0]); - } - - [Fact] - public void TestSpan() - { - Span buffer = stackalloc byte[10]; - var writer = new SpanWriter(buffer); - - writer.Write((byte)0x01); - writer.Write((byte)0x02); - - var span = writer.Span; - Assert.Equal(2, span.Length); - AssertThat.Equal(span, [0x01, 0x02]); - } } diff --git a/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs b/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs index 7299a94c4..d3dfd12f2 100644 --- a/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs @@ -52,19 +52,6 @@ public class ValueStringBuilderTests // writes into the shared buffer, and Append() reconciles via `this = handler._builder`. // Critical to detect if C# compiler codegen changes break this assumption. - [Fact] - public void Interpolation_Stackalloc_NoGrow() - { - // Stackalloc buffer large enough — no Grow needed. - // Validates: copy's Span shares same stackalloc memory, _length is reconciled. - var sb = new ValueStringBuilder(stackalloc char[64]); - sb.Append($"Hello {42} world"); - - Assert.Equal("Hello 42 world", sb.ToString()); - Assert.Equal(14, sb.Length); - sb.Dispose(); - } - [Fact] public void Interpolation_Stackalloc_WithGrow() { @@ -82,20 +69,6 @@ public class ValueStringBuilderTests sb.Dispose(); } - [Fact] - public void Interpolation_Stackalloc_PreExistingContent_NoGrow() - { - // Append plain text first, then interpolation. Buffer large enough. - // Validates: interpolation appends after existing content, doesn't overwrite. - var sb = new ValueStringBuilder(stackalloc char[64]); - sb.Append("prefix-"); - sb.Append($"value={99}"); - - Assert.Equal("prefix-value=99", sb.ToString()); - Assert.Equal(15, sb.Length); - sb.Dispose(); - } - [Fact] public void Interpolation_Stackalloc_PreExistingContent_WithGrow() { @@ -264,20 +237,6 @@ public class ValueStringBuilderTests sb.Dispose(); } - [Fact] - public void Interpolation_Stackalloc_DisposeAfterGrow() - { - // After Grow moves stackalloc to pool, Dispose should return the pooled array. - // Validates: _arrayToReturnToPool is correctly reconciled so Dispose works. - var sb = new ValueStringBuilder(stackalloc char[4]); - sb.Append($"grow beyond stackalloc: {12345}"); - // If _arrayToReturnToPool wasn't reconciled, Dispose would either - // not return the pooled array (leak) or return null (no-op when it shouldn't be). - sb.Dispose(); - // No assertion needed — if _arrayToReturnToPool was wrong, pool corruption - // would surface as test failures elsewhere. The test passing = no crash. - } - [Fact] public void Interpolation_Heap_DoubleGrow() { diff --git a/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs b/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs index 471a5584c..39a73ad7b 100644 --- a/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs +++ b/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs @@ -2,13 +2,13 @@ using Xunit; namespace Server.Tests.Network; -[Collection("Sequential Server Tests")] public class ClientVersionTests { [Theory] [InlineData("7.0.33.1", "67.0.33", false, 0)] [InlineData("7.0.45.65", "67.0.33", false, 1)] [InlineData("6.0.0.0", "67.0.33", false, -1)] + [InlineData("7.0.33.1", "7.0.33.1", true, 0)] public void TestCCAndECStringCtor(string ccVersion, string ecVersion, bool equal, int comparison) { var cc = new ClientVersion(ccVersion); @@ -22,6 +22,7 @@ public class ClientVersionTests [InlineData(7, 0, 33, 1, 67, 0, 33, 0, false, 0)] [InlineData(7, 0, 45, 65, 67, 0, 33, 0, false, 1)] [InlineData(6, 0, 0, 0, 67, 0, 33, 0, false, -1)] + [InlineData(7, 0, 33, 1, 7, 0, 33, 1, true, 0)] public void TestCCAndECCtor( int ccMaj, int ccMin, int ccRev, int ccPatch, int ecMaj, int ecMin, int ecRev, int ecPatch, bool equal, int comparison diff --git a/Projects/Server.Tests/Tests/Collections/ValueLinkListTests.cs b/Projects/Server.Tests/Tests/Collections/ValueLinkListTests.cs index 9683c8712..fa2a18417 100644 --- a/Projects/Server.Tests/Tests/Collections/ValueLinkListTests.cs +++ b/Projects/Server.Tests/Tests/Collections/ValueLinkListTests.cs @@ -16,13 +16,6 @@ public class ValueLinkListTests public TestEntity(int serial) => Serial = serial; } - [Fact] - public void TestDefaultListIsEmpty() - { - var linkList = new ValueLinkList(); - Assert.Equal(0, linkList.Count); - } - [Fact] public void TestAddingElementsFirst() { @@ -397,29 +390,4 @@ public class ValueLinkListTests } ); } - - [Fact] - public void TestThrowsIfModifiedWhileIteratingNested() - { - Assert.Throws( - () => - { - var linkList = new ValueLinkList(); - - var entity1 = new TestEntity(1); - var entity2 = new TestEntity(2); - - linkList.AddFirst(entity1); - linkList.AddLast(entity2); - - foreach (var item in linkList) - { - foreach (var nestedItem in linkList) - { - linkList.Remove(nestedItem); - } - } - } - ); - } } diff --git a/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs index 50d015555..cdf084fb7 100644 --- a/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs +++ b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs @@ -70,10 +70,11 @@ public class ConsoleInputPumpTests cmd => cmd == "ping" ? onPing : null ); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); Assert.True(invokedWith.Task.Wait(Timeout), "command not dispatched"); Assert.Equal("hello", invokedWith.Task.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); } [Fact] @@ -81,7 +82,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => null); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); // Register the prompt, then wait until it is actually pending before feeding a // line — otherwise the line could be consumed as a command before registration. @@ -93,6 +94,7 @@ public class ConsoleInputPumpTests Assert.Equal("the answer", prompt.Result); reader.Complete(); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); } [Fact] @@ -100,7 +102,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => null); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); var prompt = Task.Run(pump.ReadLine); WaitUntil(() => pump.HasPendingPrompt, "prompt did not register"); @@ -108,6 +110,7 @@ public class ConsoleInputPumpTests Assert.True(prompt.Wait(Timeout), "prompt hung on EOF"); Assert.Null(prompt.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); Assert.False(pump.Running); } @@ -116,7 +119,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => throw new InvalidOperationException("boom")); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); // Wait until the reader loop is blocked on its first read, then feed a bad command. WaitUntil(() => reader.ReadCallCount >= 1, "reader did not start"); @@ -133,6 +136,7 @@ public class ConsoleInputPumpTests Assert.True(prompt.Wait(Timeout), "prompt hung after throwing lookup"); Assert.Null(prompt.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); Assert.False(pump.Running); } } diff --git a/Projects/Server.Tests/Tests/Geometry/Point2DTests.cs b/Projects/Server.Tests/Tests/Geometry/Point2DTests.cs index b93a391af..81ae7ad84 100644 --- a/Projects/Server.Tests/Tests/Geometry/Point2DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Point2DTests.cs @@ -16,31 +16,6 @@ public class Point2DTests Assert.Equal($"({int.MaxValue}, {int.MinValue})", new Point2D(int.MaxValue, int.MinValue).ToString()); } - [Fact] - public void TestPoint2DTryFormatSucceeds() - { - var array = new char[128]; - - var p1 = new Point2D(0, 0); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(6, cp1); - Array.Clear(array); - - var p2 = new Point2D(1, 1); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(6, cp2); - Array.Clear(array); - - var p3 = new Point2D(int.MaxValue, int.MaxValue); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(4 + 20, cp3); - Array.Clear(array); - - var p4 = new Point2D(int.MinValue, int.MinValue); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(4 + 22, cp4); - } - [Fact] public void TestPoint2DTryFormatFails() { diff --git a/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs b/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs index 8bdd279f5..cdad2512b 100644 --- a/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs @@ -27,33 +27,6 @@ public class Point3DTests Assert.Equal($"({min}, {min}, {min})", new Point3D(min, min, min).ToString()); } - [Fact] - public void TestPoint3DTryFormatSucceeds() - { - const int max = int.MaxValue; - const int min = int.MinValue; - var array = new char[128]; - - var p1 = new Point3D(0, 0, 0); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(6 + 3, cp1); - Array.Clear(array); - - var p2 = new Point3D(1, 1, 1); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(6 + 3, cp2); - Array.Clear(array); - - var p3 = new Point3D(max, max, max); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(6 + 30, cp3); - Array.Clear(array); - - var p4 = new Point3D(min, min, min); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(6 + 33, cp4); - } - [Fact] public void TestPoint3DTryFormatFails() { @@ -95,7 +68,7 @@ public class Point3DTests Assert.Equal(new Point3D(101, 23, 55), Point3D.Parse(" (101,23 ,55) ", null)); // No parenthesis - Assert.False(Point3D.TryParse("101, 23 55)", null, out p)); + Assert.False(Point3D.TryParse("101, 23, 55)", null, out p)); Assert.Equal(default, p); Assert.Throws(() => Point3D.Parse("101, 23, 55)", null)); diff --git a/Projects/Server.Tests/Tests/Geometry/Rectangle2DTests.cs b/Projects/Server.Tests/Tests/Geometry/Rectangle2DTests.cs index 1c9bb58fd..87c47c786 100644 --- a/Projects/Server.Tests/Tests/Geometry/Rectangle2DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Rectangle2DTests.cs @@ -16,33 +16,6 @@ public class Rectangle2DTests Assert.Equal($"({min}, {min})+({min}, {min})", new Rectangle2D(min, min, min, min).ToString()); } - [Fact] - public void TestRectangle2DTryFormatSucceeds() - { - const int max = int.MaxValue; - const int min = int.MinValue; - var array = new char[128]; - - var p1 = new Rectangle2D(0, 0, 0, 0); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(9 + 4, cp1); - Array.Clear(array); - - var p2 = new Rectangle2D(1, 1, 1, 1); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(9 + 4, cp2); - Array.Clear(array); - - var p3 = new Rectangle2D(max, max, max, max); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(9 + 4 * 10, cp3); - Array.Clear(array); - - var p4 = new Rectangle2D(min, min, min, min); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(9 + 4 * 11, cp4); - } - [Fact] public void TestRectangle2DTryFormatFails() { diff --git a/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs b/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs index 2b030a183..9f1ffdb20 100644 --- a/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs @@ -16,33 +16,6 @@ public class Rectangle3DTests Assert.Equal($"({min}, {min}, {min})+({min}, {min}, {min})", new Rectangle3D(min, min, min, min, min, min).ToString()); } - [Fact] - public void TestRectangle3DTryFormatSucceeds() - { - const int max = int.MaxValue; - const int min = int.MinValue; - var array = new char[128]; - - var p1 = new Rectangle3D(0, 0, 0, 0, 0, 0); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(13 + 6, cp1); - Array.Clear(array); - - var p2 = new Rectangle3D(1, 1, 1, 1, 1, 1); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(13 + 6, cp2); - Array.Clear(array); - - var p3 = new Rectangle3D(max, max, max, max, max, max); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(13 + 6 * 10, cp3); - Array.Clear(array); - - var p4 = new Rectangle3D(min, min, min, min, min, min); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(13 + 6 * 11, cp4); - } - [Fact] public void TestRectangle3DTryFormatFails() { @@ -131,7 +104,7 @@ public class Rectangle3DTests Assert.False(Rectangle3D.TryParse("(101,,10)+(55, 89 1)", null, out p)); Assert.Equal(default, p); - Assert.Throws(() => Rectangle3D.Parse("(,23,)+(,89)", null)); + Assert.Throws(() => Rectangle3D.Parse("(101,,10)+(55, 89 1)", null)); Assert.False(Rectangle3D.TryParse("(,,)+(55,89, 1)", null, out p)); Assert.Equal(default, p); diff --git a/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs b/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs index b9fcc6f7a..a913eb9a9 100644 --- a/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs @@ -39,40 +39,6 @@ public sealed class WorldLocationTests Assert.Equal($"({min}, {min}, {min}) [{_superLongMapName}]", new WorldLocation(min, min, min, map).ToString()); } - [Theory] - [InlineData(null)] - [InlineData("Internal Map")] - [InlineData(_superLongMapName)] - public void TestWorldLocationTryFormatSucceeds(string mapName) - { - var map = mapName != null ? CreateMap(mapName) : null; - var mapNameLength = mapName?.Length ?? _nullMap.Length; - - const int max = int.MaxValue; - const int min = int.MinValue; - var array = new char[128]; - - var p1 = new WorldLocation(0, 0, 0, map); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(9 + 3 + mapNameLength, cp1); - Array.Clear(array); - - var p2 = new WorldLocation(1, 1, 1, map); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(9 + 3 + mapNameLength, cp2); - Array.Clear(array); - - array = new char[256]; // We need a bigger buffer! - var p3 = new WorldLocation(max, max, max, map); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(9 + 30 + mapNameLength, cp3); - Array.Clear(array); - - var p4 = new WorldLocation(min, min, min, map); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(9 + 33 + mapNameLength, cp4); - } - [Theory] [InlineData(null)] [InlineData("Internal Map")] @@ -124,16 +90,16 @@ public sealed class WorldLocationTests Assert.Equal(new WorldLocation(101, 23, 55, null), WorldLocation.Parse(" (101,23 ,55) [(-null-)]", null)); // No Brackets - Assert.False(WorldLocation.TryParse("(101, 23 55) Felucca]", null, out p)); + Assert.False(WorldLocation.TryParse("(101, 23, 55) Felucca]", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("(101, 23, 55) Felucca]", null)); - Assert.False(WorldLocation.TryParse("(101, 23 55) [Felucca", null, out p)); + Assert.False(WorldLocation.TryParse("(101, 23, 55) [Felucca", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("(101, 23, 55) [Felucca", null)); // No parenthesis - Assert.False(WorldLocation.TryParse("101, 23 55) [Felucca]", null, out p)); + Assert.False(WorldLocation.TryParse("101, 23, 55) [Felucca]", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("101, 23, 55) [Felucca]", null)); diff --git a/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs index c529f8a12..33740ec79 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs @@ -6,32 +6,43 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class ContainerBulkRemovalTests { + // Large enough that a quadratic child removal (a front-to-back IndexOf per child) costs + // roughly 2 * 10^8 comparisons and shows up in the test's run time. + private const int ChildCount = 20_000; + [Fact] public void DeletingLargeContainer_DeletesAllChildren_AndTotalsStayConsistent() { var outer = new Container(0xE75); - var pack = new Container(0xE75); - outer.DropItem(pack); - var children = new Item[100_000]; - for (var i = 0; i < children.Length; i++) + try { - children[i] = new Item(0x1234); - pack.DropItem(children[i]); + var pack = new Container(0xE75); + outer.DropItem(pack); + + var children = new Item[ChildCount]; + for (var i = 0; i < children.Length; i++) + { + children[i] = new Item(0x1234); + pack.DropItem(children[i]); + } + + Assert.Equal(children.Length + 1, outer.TotalItems); + + pack.Delete(); + + for (var i = 0; i < children.Length; i++) + { + Assert.True(children[i].Deleted); + } + + Assert.Empty(outer.Items); + Assert.Equal(0, outer.TotalItems); } - - Assert.Equal(children.Length + 1, outer.TotalItems); - - pack.Delete(); - - for (var i = 0; i < children.Length; i++) + finally { - Assert.True(children[i].Deleted); + outer.Delete(); } - - Assert.Empty(outer.Items); - Assert.Equal(0, outer.TotalItems); - outer.Delete(); } // The tail fast path must not change which entry is removed when the item is elsewhere. @@ -39,20 +50,27 @@ public class ContainerBulkRemovalTests public void RemovingFromFrontMiddleAndEnd_RemovesExactlyThatItem() { var pack = new Container(0xE75); - var items = new Item[5]; - for (var i = 0; i < items.Length; i++) + + try { - items[i] = new Item(0x1234); - pack.DropItem(items[i]); + var items = new Item[5]; + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.DropItem(items[i]); + } + + items[0].Delete(); + items[2].Delete(); + items[4].Delete(); + + Assert.Equal(2, pack.Items.Count); + Assert.Same(items[1], pack.Items[0]); + Assert.Same(items[3], pack.Items[1]); + } + finally + { + pack.Delete(); } - - items[0].Delete(); - items[2].Delete(); - items[4].Delete(); - - Assert.Equal(2, pack.Items.Count); - Assert.Same(items[1], pack.Items[0]); - Assert.Same(items[3], pack.Items[1]); - pack.Delete(); } } diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs index 695838185..587753250 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs @@ -1,8 +1,7 @@ -using System; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; @@ -44,19 +43,6 @@ public class ContainerChildRemovalTests private static readonly Point3D _ownerLoc = new(1500, 1500, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - private static Mobile CreateOwnerWith(Container pack) { var owner = new Mobile(World.NewMobile); @@ -68,44 +54,6 @@ public class ContainerChildRemovalTests return owner; } - private static bool ReceivedRemove(NetState ns, Serial serial) - { - Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; - OutgoingEntityPackets.CreateRemoveEntity(expected, serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - - private static int CountRemoves(NetState ns, Serial serial) - { - Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; - OutgoingEntityPackets.CreateRemoveEntity(expected, serial); - - var span = ns.SendBuffer.GetReadSpan(); - var count = 0; - - while (true) - { - var index = span.IndexOf(expected); - - if (index < 0) - { - break; - } - - count++; - span = span[(index + expected.Length)..]; - } - - return count; - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - private static void Cleanup(NetState ns, Mobile client, Mobile owner) { DisposeClient(ns, client); diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs index 5b1cd471c..550574770 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs @@ -66,27 +66,6 @@ public class ContainerChildSkipTests } // A skipped child is not saved, so the container must load without it. - [Fact] - public void SkippingContainer_LoadsWithoutItsSkippedChildren() - { - var pack = new SkippingContainer(); - var item = new Item(0x1234); - pack.DropItem(item); - - var writer = new BufferWriter(true); - pack.Serialize(writer); - var buffer = new byte[writer.Position]; - writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); - - item.Delete(); - pack.Delete(); - - var copy = new SkippingContainer(World.NewItem); - copy.Deserialize(new BufferReader(buffer)); - - Assert.Empty(copy.Items); - } - // A written child serial could resolve at load to an unrelated item that was handed the same serial. [Fact] public void SkippingContainer_DoesNotWriteAliveChildSerial() diff --git a/Projects/Server.Tests/Tests/Items/ContainerTests.cs b/Projects/Server.Tests/Tests/Items/ContainerTests.cs index 6b7634da8..6cef3a040 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerTests.cs @@ -419,15 +419,6 @@ public class ContainerTests Assert.Equal(0, c.GetBestGroupAmount(new[] { typeof(TestReagentA) }, true, HueGrouper)); } - [Fact] - public void TestGetBestGroupAmount_NullGrouper_Throws() - { - var c = MakeContainer(0x2C0); - Assert.Throws( - () => c.GetBestGroupAmount(new[] { typeof(TestReagentA) }, true, null) - ); - } - [Fact] public void TestGetAmount_TypeArray_SumsAcrossTypes() { @@ -470,39 +461,6 @@ public class ContainerTests Assert.Equal(1, s2.Amount); } - [Fact] - public void TestConsumeTotal_LengthMismatch_Throws() - { - var c = MakeContainer(0x300); - Assert.Throws( - () => c.ConsumeTotal(new[] { typeof(TestReagentA) }, new[] { 1, 2 }) - ); - } - - [Fact] - public void TestConsumeTotalGrouped_LengthMismatch_Throws() - { - var c = MakeContainer(0x310); - Assert.Throws( - () => c.ConsumeTotalGrouped( - new[] { typeof(TestReagentA) }, new[] { 1, 2 }, - true, null, HueGrouper - ) - ); - } - - [Fact] - public void TestConsumeTotalGrouped_NullGrouper_Throws() - { - var c = MakeContainer(0x320); - Assert.Throws( - () => c.ConsumeTotalGrouped( - new[] { typeof(TestReagentA) }, new[] { 1 }, - true, null, null - ) - ); - } - // ------------------------------------------------------------------------- // FindItemsByType(Type) and FindItemsByType(ReadOnlySpan) — Phase 10: // these used to allocate a Predicate per call (method-group conversion diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 59d736c6f..85ffd3c84 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -5,11 +5,15 @@ using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class DecayRegistrationTests +public class DecayRegistrationTests : IDisposable { + private readonly EntityCleanup _cleanup = new(); + // The wheel is static and these tests move the clock, so start each from a known schedule. public DecayRegistrationTests() => DecayScheduler.ResetForTests(); + public void Dispose() => _cleanup.Dispose(); + // Stands in for a region that refuses decay, e.g. HouseRegion over a locked down item. private class RefusesDecayItem : Item { @@ -20,156 +24,44 @@ public class DecayRegistrationTests public override bool OnDecay() => false; } - // The item is already dequeued, so a refusal that does not reschedule strands it forever. - [Fact] - public void ItemWhoseDecayIsRefused_StaysTrackedAndSurvives() - { - var start = Core._now; - - try - { - var item = new RefusesDecayItem(); - item.MoveToWorld(new Point3D(106, 100, 0), Map.Felucca); - - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); - - Assert.False(item.Deleted, "A refused decay must not delete the item."); - Assert.True( - DecayScheduler.IsRegistered(item), - "A refused decay must leave the item tracked so it is retried." - ); - - item.Delete(); - } - finally - { - Core._now = start; - } - } - - // Drives the scheduler and the game clock without depending on the shared timer wheel. - private static void AdvanceDecay(DateTime start, TimeSpan duration, Item item) - { - var deadline = start + duration; - - for (var now = start; now <= deadline && !item.Deleted; now += TimeSpan.FromMilliseconds(256)) - { - Core._now = now; - DecayScheduler.ProcessTick(now); - } - } - [Fact] public void ItemOnGround_ActuallyDecaysAfterDecayTime() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - Assert.True(DecayScheduler.IsRegistered(item)); + Assert.True(DecayScheduler.IsRegistered(item)); - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime + TimeSpan.FromMinutes(2), item); - Assert.True(item.Deleted, "Item on the ground must decay once DecayTime elapses."); - } - finally - { - Core._now = start; - } + Assert.True(item.Deleted, "Item on the ground must decay once DecayTime elapses."); } [Fact] public void ItemOnGround_DoesNotDecayBeforeDecayTime() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(102, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(102, 100, 0), Map.Felucca); - AdvanceDecay(start, item.DecayTime - TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "Item must not decay before DecayTime elapses."); - - item.Delete(); - } - finally - { - Core._now = start; - } - } - - [Fact] - public void ItemPlacedInWorld_IsRegisteredForDecay() - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - - Assert.True(item.CanDecay()); - Assert.True(DecayScheduler.IsRegistered(item), "Item on the ground must be tracked for decay."); + Assert.False(item.Deleted, "Item must not decay before DecayTime elapses."); item.Delete(); } // The real client path: Mobile.Lift internalizes, then Mobile.Drop calls MoveToWorld. - [Fact] - public void ItemLiftedThenDroppedToGround_IsRegisteredForDecay() - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - - // Player lifts the item to the cursor. - item.Internalize(); - Assert.Equal(Map.Internal, item.Map); - - // Player drops it back onto the ground. - item.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca); - - Assert.True(item.CanDecay()); - Assert.True( - DecayScheduler.IsRegistered(item), - "Item dropped to the ground after being lifted must be tracked for decay." - ); - - item.Delete(); - } - // Held on the cursor means Map.Internal, which must not be tracked. - [Fact] - public void ItemLiftedToCursor_IsNotRegisteredForDecay() - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - - item.Internalize(); - - Assert.False(item.CanDecay()); - Assert.False(DecayScheduler.IsRegistered(item), "Item held on the cursor must not be tracked for decay."); - - item.Delete(); - } - // A new item is on Map.Internal, so construction must not touch the scheduler. - [Fact] - public void NewItem_IsNotTracked() - { - var item = new Item(0x1234); - - Assert.Equal(DecayScheduler.SlotNone, item.DecaySlot); - Assert.False(DecayScheduler.IsRegistered(item), "A newly constructed item must not be tracked."); - - item.Delete(); - } - // Unregister() trusts DecaySlot; IsRegistered() scans the structures. Assert both to catch drift. [Fact] public void DecaySlot_AgreesWithStructures_AcrossLifecycle() { - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); item.MoveToWorld(new Point3D(103, 100, 0), Map.Felucca); Assert.NotEqual(DecayScheduler.SlotNone, item.DecaySlot); @@ -193,10 +85,10 @@ public class DecayRegistrationTests [Fact] public void ContainerDestroy_ContentsDroppedToGroundAreTracked() { - var pack = new Container(0xE75); + var pack = _cleanup.Track(new Container(0xE75)); pack.MoveToWorld(new Point3D(105, 100, 0), Map.Felucca); - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); pack.AddItem(item); Assert.False(DecayScheduler.IsRegistered(item)); @@ -213,163 +105,127 @@ public class DecayRegistrationTests [Fact] public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); - item.Movable = false; - Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); + item.Movable = false; + Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); - Core._now = start + TimeSpan.FromDays(30); - var flipped = Core._now; + clock.Now = clock.Start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = true; - Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); + Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); - AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); - AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); - Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); - } - finally - { - Core._now = start; - } + clock.AdvanceDecay(TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); } // Same transition through the Visible setter: unhiding a long-hidden item. [Fact] public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); - item.Visible = false; - Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); + item.Visible = false; + Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); - Core._now = start + TimeSpan.FromDays(30); - var flipped = Core._now; + clock.Now = clock.Start + TimeSpan.FromDays(30); - item.Visible = true; + item.Visible = true; - Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); + Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); - AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); - AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); - Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); - } - finally - { - Core._now = start; - } + clock.AdvanceDecay(TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); } - // A refusal restarts the countdown without rewriting LastMoved. + // A refusal restarts the countdown without rewriting LastMoved. The item is already dequeued + // when OnDecay refuses, so a refusal that does not reschedule strands it forever. [Fact] public void RefusedDecay_DoesNotRewriteLastMoved() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new RefusesDecayItem(); - item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); - var lastMoved = item.LastMoved; + var item = _cleanup.Track(new RefusesDecayItem()); + item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); + var lastMoved = item.LastMoved; - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime + TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "A refused decay must not delete the item."); - Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); - Assert.Equal(lastMoved, item.LastMoved); + Assert.False(item.Deleted, "A refused decay must not delete the item."); + Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); + Assert.Equal(lastMoved, item.LastMoved); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // The fresh window must survive a save/load cycle, or a restart mid-window deletes the item. [Fact] public void FreshDecayWindow_SurvivesSerialization() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - var expected = item.ScheduledDecayTime; + var expected = item.ScheduledDecayTime; - var writer = new BufferWriter(new byte[512], true); - item.Serialize(writer); + var writer = new BufferWriter(new byte[512], true); + item.Serialize(writer); - var copy = new Item(item.Serial); - copy.Deserialize(new BufferReader(writer.Buffer)); + var copy = new Item(item.Serial); + copy.Deserialize(new BufferReader(writer.Buffer)); - // The stamp is stored as a delta, so it ages only by the real time between - // write and read - milliseconds here, the downtime in production. - Assert.True( - (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), - "The restarted decay window must survive a save/load cycle." - ); + // The stamp is stored as a delta, so it ages only by the real time between + // write and read - milliseconds here, the downtime in production. + Assert.True( + (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), + "The restarted decay window must survive a save/load cycle." + ); - item.Delete(); - copy.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); + copy.Delete(); } // A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse. [Fact] public void MovingAnItem_ClearsASupersededDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - Core._now += TimeSpan.FromMinutes(1); - item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); + clock.Now += TimeSpan.FromMinutes(1); + item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); - Assert.Equal(default, item.DecayResetTime); - Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); - Assert.True(DecayScheduler.IsRegistered(item)); + Assert.Equal(default, item.DecayResetTime); + Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); + Assert.True(DecayScheduler.IsRegistered(item)); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // Losing decay eligibility makes the stamp meaningless; it must be dropped so the @@ -377,29 +233,22 @@ public class DecayRegistrationTests [Fact] public void ItemBecomingIneligible_DropsTheDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - item.Movable = false; + item.Movable = false; - Assert.Equal(default, item.DecayResetTime); + Assert.Equal(default, item.DecayResetTime); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // Moving a stamped item into a container programmatically (no drop, no SetLastMoved) @@ -407,32 +256,25 @@ public class DecayRegistrationTests [Fact] public void StampedItemAddedToContainer_DropsTheDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var pack = new Container(0xE75); - pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); + var pack = _cleanup.Track(new Container(0xE75)); + pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - pack.AddItem(item); + pack.AddItem(item); - Assert.Equal(default, item.DecayResetTime); + Assert.Equal(default, item.DecayResetTime); - pack.Delete(); - } - finally - { - Core._now = start; - } + pack.Delete(); } // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must @@ -440,7 +282,7 @@ public class DecayRegistrationTests [Fact] public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay() { - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); Assert.False(DecayScheduler.IsRegistered(item)); item.Map = Map.Felucca; @@ -456,10 +298,10 @@ public class DecayRegistrationTests [Fact] public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay() { - var stack = new Item(0x1234) { Stackable = true, Amount = 10 }; + var stack = _cleanup.Track(new Item(0x1234) { Stackable = true, Amount = 10 }); stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca); - var remainder = Mobile.LiftItemDupe(stack, 3); + var remainder = _cleanup.Track(Mobile.LiftItemDupe(stack, 3)); Assert.NotNull(remainder); Assert.Equal(7, remainder.Amount); @@ -473,26 +315,4 @@ public class DecayRegistrationTests stack.Delete(); remainder.Delete(); } - - // Dropping into a container must untrack; taking it back out to the ground must re-track. - [Fact] - public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() - { - var pack = new Container(0xE75); - pack.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - - var item = new Item(0x1234); - pack.AddItem(item); - - Assert.False(DecayScheduler.IsRegistered(item), "Item inside a container must not be tracked for decay."); - - item.Internalize(); - item.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca); - - Assert.True(item.CanDecay()); - Assert.True(DecayScheduler.IsRegistered(item), "Item taken out of a container must be tracked for decay."); - - item.Delete(); - pack.Delete(); - } } diff --git a/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs index 97cffbba9..03b372e14 100644 --- a/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs +++ b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs @@ -1,9 +1,7 @@ -using System; -using System.Buffers; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; @@ -12,51 +10,6 @@ public class LiftRejectResyncTests { private static readonly Point3D _baseLoc = new(2200, 2200, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - - private static bool ReceivedContentUpdate(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0x25); // ContainerContentUpdate packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - - private static bool ReceivedWorldItem(NetState ns, Item item) - { - Span expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength]; - var length = OutgoingItemPackets.CreateWorldItem(expected, item); - return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0; - } - - private static bool ReceivedEquipUpdate(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0x2E); // EquipUpdate packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - [Fact] public void PrivateNestedItem_RejectedLift_DoesNotResyncBystander() { diff --git a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs index b8b4fd419..e7d790b7b 100644 --- a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs +++ b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs @@ -5,8 +5,10 @@ using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class LockedDownContainerDecayTests +public class LockedDownContainerDecayTests : IDisposable { + private readonly EntityCleanup _cleanup = new(); + public LockedDownContainerDecayTests() { DecayScheduler.ResetForTests(); @@ -19,6 +21,8 @@ public class LockedDownContainerDecayTests } } + public void Dispose() => _cleanup.Dispose(); + private class TestChest : Container { public TestChest() : base(0xE43) @@ -35,11 +39,11 @@ public class LockedDownContainerDecayTests public override bool ContentsDecay => false; } - private static Item NewLoot() => new Item(0x1F03) { Movable = true, Visible = true }; + private Item NewLoot() => _cleanup.Track(new Item(0x1F03) { Movable = true, Visible = true }); - private static TestChest PlaceLockedDownChest(int x) + private TestChest PlaceLockedDownChest(int x) { - var chest = new TestChest { Movable = false }; + var chest = _cleanup.Track(new TestChest { Movable = false }); chest.MoveToWorld(new Point3D(x, 100, 0), Map.Felucca); chest.IsLockedDown = true; return chest; @@ -62,7 +66,7 @@ public class LockedDownContainerDecayTests [Fact] public void ItemInOrdinaryContainer_IsNotTracked() { - var chest = new TestChest(); + var chest = _cleanup.Track(new TestChest()); chest.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); var loot = NewLoot(); @@ -77,7 +81,7 @@ public class LockedDownContainerDecayTests [Fact] public void LockingAndReleasingContainer_RegistersAndUnregistersContents() { - var chest = new TestChest { Movable = false }; + var chest = _cleanup.Track(new TestChest { Movable = false }); chest.MoveToWorld(new Point3D(122, 100, 0), Map.Felucca); var loot = NewLoot(); chest.AddItem(loot); @@ -102,7 +106,7 @@ public class LockedDownContainerDecayTests [Fact] public void ContainerThatKeepsContents_DoesNotTrackThem() { - var board = new KeepsContentsChest { Movable = false }; + var board = _cleanup.Track(new KeepsContentsChest { Movable = false }); board.MoveToWorld(new Point3D(123, 100, 0), Map.Felucca); board.IsLockedDown = true; var piece = NewLoot(); @@ -118,7 +122,7 @@ public class LockedDownContainerDecayTests public void NestedContainerContents_AreNotTracked() { var chest = PlaceLockedDownChest(124); - var bag = new TestChest(); + var bag = _cleanup.Track(new TestChest()); chest.AddItem(bag); var loot = NewLoot(); bag.AddItem(loot); @@ -145,29 +149,17 @@ public class LockedDownContainerDecayTests [Fact] public void TrackedContents_DecayOnSchedule() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var chest = PlaceLockedDownChest(126); - var loot = NewLoot(); - chest.AddItem(loot); + var chest = PlaceLockedDownChest(126); + var loot = NewLoot(); + chest.AddItem(loot); - var deadline = start + loot.DecayTime + TimeSpan.FromMinutes(2); - for (var now = start; now <= deadline && !loot.Deleted; now += TimeSpan.FromMilliseconds(256)) - { - Core._now = now; - DecayScheduler.ProcessTick(now); - } + clock.AdvanceDecay(loot.DecayTime + TimeSpan.FromMinutes(2), loot); - Assert.True(loot.Deleted, "Contents of a locked-down container must decay."); - Assert.False(chest.Deleted, "The locked-down container itself must not decay."); + Assert.True(loot.Deleted, "Contents of a locked-down container must decay."); + Assert.False(chest.Deleted, "The locked-down container itself must not decay."); - chest.Delete(); - } - finally - { - Core._now = start; - } + chest.Delete(); } } diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs index 955a87293..aa094365d 100644 --- a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs +++ b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs @@ -24,25 +24,6 @@ public class PlayerConstructedStackingTests private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; - [Theory] - [InlineData(false)] - [InlineData(true)] - public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed) - { - var first = MakeStack((Serial)0x1, 5, playerConstructed); - var second = MakeStack((Serial)0x2, 7, playerConstructed); - - try - { - Assert.True(first.CanStackWith(second)); - } - finally - { - first.Delete(); - second.Delete(); - } - } - [Theory] [InlineData(false)] [InlineData(true)] diff --git a/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs index 4481b08b2..97b67613f 100644 --- a/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs +++ b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs @@ -1,15 +1,24 @@ using System; -using System.Buffers; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class PrivateChildPropertiesTests +public class PrivateChildPropertiesTests : IDisposable { + private readonly bool _oplWasEnabled; + + public PrivateChildPropertiesTests() + { + _oplWasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + } + + public void Dispose() => ObjectPropertyList.Enabled = _oplWasEnabled; + private class PublicContainer : Container { public PublicContainer() : base(0xE75) @@ -21,35 +30,6 @@ public class PrivateChildPropertiesTests private static readonly Point3D _ownerLoc = new(1600, 1600, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - - private static bool ReceivedOplInfo(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0xDC); // OPL info packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - // AddItem/MoveToWorld already queue their own Update (+Properties) delta as a side effect of the // Map setter. Draining it first isolates the properties-only delta the test actually exercises; // otherwise the leftover Update flag routes through the Update branch and the test would pass or @@ -68,9 +48,6 @@ public class PrivateChildPropertiesTests [Fact] public void PrivateChildInOwnerBackpack_PropertiesOnlyUpdate_ReachesOwnerNotBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (ownerNs, owner) = CreateClient(_ownerLoc); var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); @@ -93,16 +70,12 @@ public class PrivateChildPropertiesTests child.Delete(); DisposeClient(ownerNs, owner); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void PrivateGroundContainer_PropertiesOnlyUpdate_ReachesOpenerNotNonOpener() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (openerNs, opener) = CreateClient(_ownerLoc); var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 2, _ownerLoc.Y, 0)); @@ -126,16 +99,12 @@ public class PrivateChildPropertiesTests container.Delete(); DisposeClient(openerNs, opener); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void PublicContainerChild_PropertiesOnlyUpdate_StillReachesBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (bystanderNs, bystander) = CreateClient(_ownerLoc); var pack = new PublicContainer(); @@ -155,16 +124,12 @@ public class PrivateChildPropertiesTests { pack.Delete(); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void GroundItem_PropertiesOnlyUpdate_StillReachesBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (bystanderNs, bystander) = CreateClient(_ownerLoc); var item = new Item(0x1234); @@ -181,7 +146,6 @@ public class PrivateChildPropertiesTests { item.Delete(); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } } diff --git a/Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs b/Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs deleted file mode 100644 index 437b674ff..000000000 --- a/Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Server.Json; -using Xunit; - -namespace Server.Tests.Json; - -public class JsonDiscoverableTypeAttributeTests -{ - [JsonDiscoverableType] - private sealed class DefaultName { } - - [JsonDiscoverableType("custom")] - private sealed class Overridden { } - - [Fact] - public void DefaultDiscriminator_IsNull() - { - var attr = (JsonDiscoverableTypeAttribute)System.Attribute.GetCustomAttribute( - typeof(DefaultName), typeof(JsonDiscoverableTypeAttribute), false); - Assert.NotNull(attr); - Assert.Null(attr.Discriminator); - } - - [Fact] - public void OverrideDiscriminator_IsReturned() - { - var attr = (JsonDiscoverableTypeAttribute)System.Attribute.GetCustomAttribute( - typeof(Overridden), typeof(JsonDiscoverableTypeAttribute), false); - Assert.Equal("custom", attr.Discriminator); - } -} diff --git a/Projects/Server.Tests/Tests/Json/Rectangle3DConverterTests.cs b/Projects/Server.Tests/Tests/Json/Rectangle3DConverterTests.cs index b91c521dc..13e33f09f 100644 --- a/Projects/Server.Tests/Tests/Json/Rectangle3DConverterTests.cs +++ b/Projects/Server.Tests/Tests/Json/Rectangle3DConverterTests.cs @@ -35,12 +35,4 @@ public class Rectangle3DConverterTests Assert.DoesNotContain("z1", json); Assert.DoesNotContain("z2", json); } - - [Fact] - public void HomeRangeBounds_WritesZ() - { - var json = JsonSerializer.Serialize(new Rectangle3D(0, 0, -128, 10, 10, 256), JsonConfig.DefaultOptions); - Assert.Contains("\"z1\":", json); - Assert.Contains("\"z2\":", json); - } } diff --git a/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs b/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs index 154cfe7a6..c4c48b41b 100644 --- a/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs +++ b/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs @@ -1,20 +1,53 @@ +using System; using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class LocalizationEntryTests +public class LocalizationEntryTests : IDisposable { + private const int FormatNumber = 500002; + private const int ParameterNumber = 500003; + + public void Dispose() + { + Localization.Remove("enu", FormatNumber); + Localization.Remove("enu", ParameterNumber); + } + [Fact] public void TestClilocAsParameter() { - Localization.Add("enu", 500002, "This tests ~1_NUMBER~ as parameters."); - Localization.Add("enu", 500003, "clilocs"); + Localization.Add("enu", FormatNumber, "This tests ~1_NUMBER~ as parameters."); + Localization.Add("enu", ParameterNumber, "clilocs"); - string numericFormatter = Localization.Format(500002, "enu", $"{500003:#}"); - string stringParam = Localization.Format(500002, "enu", $"{"#500003"}"); + string numericFormatter = Localization.Format(FormatNumber, "enu", $"{ParameterNumber:#}"); + string stringParam = Localization.Format(FormatNumber, "enu", $"{"#500003"}"); Assert.Equal("This tests clilocs as parameters.", numericFormatter); Assert.Equal("This tests clilocs as parameters.", stringParam); } + + [Fact] + public void RemovingTheLastFallbackEntry_KeepsLaterAddsVisible() + { + Localization.Clear(); + + try + { + Localization.Add("enu", 500004, "first"); + Assert.True(Localization.Remove("enu", 500004)); + + Localization.Add("enu", 500005, "second"); + + Assert.False(Localization.TryGetLocalization(500004, out _)); + Assert.True(Localization.TryGetLocalization(500005, out var entry)); + Assert.Equal("second", entry.Text); + } + finally + { + // Lets the next lookup reload the real tables from disk. + Localization.Clear(); + } + } } diff --git a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs index 57a6431ef..ace2dfcf4 100644 --- a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs @@ -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,6 +28,7 @@ public class CanFitItemTests Assert.False(map.CanFitItem(100, map.Height + 1, 0, 1)); } + // 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; @@ -59,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; @@ -92,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; @@ -128,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; @@ -158,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; @@ -170,23 +156,19 @@ public class CanFitItemTests Item movableItem = null; try { - // Create a movable surface item + // Raised well clear of the land so the movable item's top is the only candidate surface there + var itemZ = map.GetAverageZ(x, y) + 20; + movableItem = new Item(ServerFixture.SurfaceTileId) { Movable = true }; - movableItem.MoveToWorld(new Point3D(x, y, 10), map); + movableItem.MoveToWorld(new Point3D(x, y, itemZ), map); - var itemTop = 10 + movableItem.ItemData.CalcHeight; - // Try placing at item top - should fail unless there's land there too - var avgZ = map.GetAverageZ(x, y); + var itemTop = itemZ + movableItem.ItemData.CalcHeight; + var result = map.CanFitItem(x, y, itemTop, 1); - // If avgZ != itemTop, there's no other surface, so it should fail - if (avgZ != itemTop) - { - var result = map.CanFitItem(x, y, itemTop, 1); - Assert.False(result, "Movable item should not count as valid surface"); - } + Assert.False(result, "Movable item should not count as valid surface"); } finally { @@ -194,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; @@ -220,39 +200,6 @@ public class CanFitItemTests } } - [SkippableFact] - public void CanFitItem_CanPlaceOnTopOfImpassable() - { - SkipIfNoTileData(); - Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData"); - - var map = Map.Felucca; - const int x = 1950; - const int y = 1950; - - TestMulti multi = null; - try - { - // Get the actual tile height from TileData - var tileData = TileData.ItemTable[ServerFixture.SurfaceImpassableTileId]; - var tileHeight = tileData.CalcHeight; - const int tileZ = 10; - - // Create a Surface+Impassable tile at Z=10 - multi = CreateSurfaceImpassableMulti(map, new Point3D(x, y, 0), surfaceZ: tileZ); - - // Place item on top should work - var surfaceTop = tileZ + tileHeight; - var result = map.CanFitItem(x, y, surfaceTop, 1); - - Assert.True(result, $"Should be able to place item on top of Surface+Impassable tile at Z={surfaceTop}"); - } - finally - { - multi?.Delete(); - } - } - private TestMulti CreateSurfaceImpassableMulti(Map map, Point3D location, int surfaceZ) { // Use SurfaceImpassableTileId - a tile that has both Surface and Impassable flags @@ -281,16 +228,4 @@ public class CanFitItemTests multi.MoveToWorld(location, map); return multi; } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(MultiComponentList components) : base(0x1) - { - _components = components ?? MultiComponentList.Empty; - } - - public override MultiComponentList Components => _components; - } } diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs index c90cce9ea..0fd8b41fa 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs @@ -3,8 +3,8 @@ using Xunit; namespace Server.Tests.Maps; /// -/// 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. /// [Collection("Sequential Server Tests")] public class CanSpawnMobileTests @@ -29,7 +29,8 @@ public class CanSpawnMobileTests Assert.False(map.CanSpawnMobile(100, map.Height + 1, -128, 127, false, false, out _)); } - // Use coordinates near Britain which should be walkable land in both test and real data + // 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; @@ -37,10 +38,9 @@ public class CanSpawnMobileTests public void CanSpawnMobile_EmptyLocationWithLandReturnsTrue() { var map = Map.Felucca; - map.GetAverageZ(TestLandX, TestLandY, out _, out var landZ, out _); // Test with full Z range to find land - var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, false, out _); // Should find a valid spawn point (land surface) Assert.True(result, $"Expected to find valid spawn point on land at ({TestLandX}, {TestLandY})"); @@ -53,7 +53,7 @@ public class CanSpawnMobileTests map.GetAverageZ(TestLandX, TestLandY, out _, out var avgZ, out _); // Use a Z range that excludes the land surface (50+ units above land) - var result = map.CanSpawnMobile(TestLandX, TestLandY, avgZ + 50, avgZ + 100, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, avgZ + 50, avgZ + 100, false, false, out _); Assert.False(result, $"Expected no spawn point above land at Z={avgZ}"); } @@ -63,7 +63,7 @@ public class CanSpawnMobileTests { var map = Map.Felucca; // cantWalk=true means the mob can only swim, so land shouldn't be valid - var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, true, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, true, out _); Assert.False(result, "cantWalk=true should not find land as valid surface"); } @@ -83,7 +83,7 @@ public class CanSpawnMobileTests blockingMobile.MoveToWorld(new Point3D(TestLandX, TestLandY, landZ), map); // The mobile should block spawning at its location and Z - var result = map.CanSpawnMobile(TestLandX, TestLandY, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, landZ - 16, landZ + 16, false, false, out _); // Land surface is blocked by mobile at the same Z Assert.False(result, "Mobile should block spawn at same location"); @@ -115,7 +115,7 @@ public class CanSpawnMobileTests gm.MoveToWorld(new Point3D(x, y, landZ), map); // Hidden GM should not block spawning - var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out _); Assert.True(result, "Hidden GM should not block spawn"); } @@ -142,7 +142,7 @@ public class CanSpawnMobileTests blockingMobile.MoveToWorld(new Point3D(x, y, landZ + 50), map); // Land should still be valid (mobile above doesn't block ground level) - var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out _); Assert.True(result, "Mobile at different Z should not block"); } diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs index 87135f080..a3daaf04c 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs @@ -4,56 +4,14 @@ using Xunit; namespace Server.Tests.Maps; /// -/// 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. /// [Collection("Sequential Server Tests")] public class CanSpawnMobileTileDataTests { - private readonly ServerFixture _fixture; - - public CanSpawnMobileTileDataTests(ServerFixture fixture) - { - _fixture = fixture; - } - - private void SkipIfNoTileData() - { - Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); - } - - [SkippableFact] - public void CanSpawnMobile_FindsSurfaceOnMulti() - { - SkipIfNoTileData(); - - var map = Map.Felucca; - const int x = 1100; - const int y = 1100; - - TestMulti multi = null; - try - { - // Create a multi with a floor at Z=20 using a real surface tile - multi = CreateFloorMulti(map, new Point3D(x, y, 0), floorZ: 20); - - var result = map.CanSpawnMobile(x, y, 15, 30, false, false, out var spawnZ); - - Assert.True(result); - Assert.True(spawnZ >= 15 && spawnZ <= 30); - } - finally - { - multi?.Delete(); - } - } - - [SkippableFact] + [Fact] public void CanSpawnMobile_FindsLowestValidSurface() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1200; const int y = 1200; @@ -77,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; @@ -105,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; @@ -122,7 +76,7 @@ public class CanSpawnMobileTileDataTests multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 28); // The low ceiling should block spawning (need 16 units clearance for mobiles) - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.False(result); } @@ -132,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; @@ -147,7 +99,7 @@ public class CanSpawnMobileTileDataTests // Create a multi with floor at Z=20 and ceiling at Z=50 (30 units clearance) multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 50); - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.True(result); } @@ -157,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; @@ -173,7 +123,7 @@ public class CanSpawnMobileTileDataTests multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 100); // The blocker at Z=100 shouldn't affect spawning in range 15-35 - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.True(result); } @@ -183,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; @@ -199,7 +147,7 @@ public class CanSpawnMobileTileDataTests multi = CreateWaterMulti(map, new Point3D(x, y, 0), waterZ: 0); // canSwim=true should find the water surface - var result = map.CanSpawnMobile(x, y, -10, 10, true, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, -10, 10, true, false, out _); Assert.True(result); } @@ -209,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; @@ -228,7 +174,7 @@ public class CanSpawnMobileTileDataTests }; surfaceItem.MoveToWorld(new Point3D(x, y, 10), map); - var result = map.CanSpawnMobile(x, y, 5, 30, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 5, 30, false, false, out _); // Should find either land at Z=0 or the item surface, whichever is lowest Assert.True(result); @@ -239,51 +185,6 @@ public class CanSpawnMobileTileDataTests } } - [SkippableFact] - public void CanSpawnMobile_MalasBuilding_FindsGroundFloor() - { - SkipIfNoTileData(); - - var map = Map.Malas; - const int x = 991; - const int y = 519; - - var result = map.CanSpawnMobile(x, y, -60, -40, false, false, out var spawnZ); - - Assert.True(result); - Assert.True(spawnZ >= -60 && spawnZ <= -40, $"Expected ground floor around -50, got {spawnZ}"); - } - - [SkippableFact] - public void CanSpawnMobile_MalasBuilding_FindsSecondFloor() - { - SkipIfNoTileData(); - - var map = Map.Malas; - const int x = 991; - const int y = 519; - - var result = map.CanSpawnMobile(x, y, -30, 0, false, false, out var spawnZ); - - Assert.True(result); - Assert.True(spawnZ >= -30 && spawnZ <= 0, $"Expected second floor, got {spawnZ}"); - } - - [SkippableFact] - public void CanSpawnMobile_MalasBuilding_LowestFloorPreferred() - { - SkipIfNoTileData(); - - var map = Map.Malas; - const int x = 991; - const int y = 519; - - var result = map.CanSpawnMobile(x, y, -60, 0, false, false, out var spawnZ); - - Assert.True(result); - Assert.True(spawnZ <= -40, $"Expected lowest floor around -50, got {spawnZ}"); - } - private TestMulti CreateFloorMulti(Map map, Point3D location, int floorZ) { var multi = new TestMulti(CreateFloorComponents(floorZ)); @@ -345,16 +246,4 @@ public class CanSpawnMobileTileDataTests new MultiTileEntry(ServerFixture.WetTileId, 0, 0, (short)waterZ, TileFlag.Wet) ]); } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(MultiComponentList components) : base(0x1) - { - _components = components ?? MultiComponentList.Empty; - } - - public override MultiComponentList Components => _components; - } } diff --git a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs index 629ec3961..9588be01c 100644 --- a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs @@ -308,49 +308,6 @@ public class ClientEnumeratorTests } } - [Fact] - public void ClientAtEnumerator_UsesDifferentPoint3DOverloads() - { - var map = Map.Felucca; - var location = new Point3D(800, 800, 5); - - var clients = new (NetState, Mobile)[1]; - try - { - clients[0] = CreateClientWithMobile(map, location); - - // Test Point3D overload - var found1 = new List(); - foreach (var ns in map.GetClientsAt(location)) - { - found1.Add(ns); - } - - // Test (int, int) overload - should find the same client (Z is ignored) - var found2 = new List(); - foreach (var ns in map.GetClientsAt(location.X, location.Y)) - { - found2.Add(ns); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var ns in map.GetClientsAt(new Point2D(location.X, location.Y))) - { - found3.Add(ns); - } - - Assert.Single(found1); - Assert.Equal(clients[0].Item1, found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(clients); - } - } - [Fact] public void ClientEnumerator_GetClientsInRange() { @@ -383,39 +340,6 @@ public class ClientEnumeratorTests } } - [Fact] - public void ClientEnumerator_DeletedMobilesAreSkipped() - { - var map = Map.Felucca; - var rect = new Rectangle2D(1000, 1000, 16, 16); - - var clients = new (NetState, Mobile)[3]; - try - { - clients[0] = CreateClientWithMobile(map, new Point3D(1005, 1005, 0)); - clients[1] = CreateClientWithMobile(map, new Point3D(1006, 1005, 0)); - clients[2] = CreateClientWithMobile(map, new Point3D(1007, 1005, 0)); - - // Delete the mobile (but not the NetState) - clients[1].Item2.Delete(); - - var found = new List(); - foreach (var ns in map.GetClientsInBounds(rect)) - { - found.Add(ns); - } - - // Should skip the client whose mobile was deleted - Assert.Equal(2, found.Count); - Assert.Contains(clients[0].Item1, found); - Assert.Contains(clients[2].Item1, found); - } - finally - { - DeleteAll(clients); - } - } - private static (NetState, Mobile) CreateClientWithMobile(Map map, Point3D location) { // Create test NetState with real socket and buffers diff --git a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs index fb12d0ab6..3e1ea6ee4 100644 --- a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs @@ -38,100 +38,6 @@ public class ItemByDistanceEnumeratorTests } } - [Fact] - public void ItemByDistanceEnumerator_DeletedItemsAreSkipped() - { - var map = Map.Felucca; - var center = new Point3D(200, 200, 0); - const int range = 5; - - var Items = new TestItem[3]; - try - { - Items[0] = CreateItem(map, new Point3D(202, 202, 0)); - Items[1] = CreateItem(map, new Point3D(203, 202, 0)); - Items[2] = CreateItem(map, new Point3D(204, 202, 0)); - - Items[1].Delete(); - - var found = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center, range)) - { - found.Add(Item); - } - - Assert.Equal(2, found.Count); - Assert.Contains(Items[0], found); - Assert.Contains(Items[2], found); - Assert.DoesNotContain(Items[1], found); - } - finally - { - DeleteAll(Items); - } - } - - [Fact] - public void ItemByDistanceEnumerator_ReturnsMinDistance() - { - var map = Map.Felucca; - var center = new Point3D(300, 300, 0); - const int range = 10; - - var Items = new TestItem[2]; - try - { - Items[0] = CreateItem(map, new Point3D(305, 305, 0)); - Items[1] = CreateItem(map, new Point3D(302, 302, 0)); - - var foundWithDistance = new List<(Item, int)>(); - foreach (var result in map.GetItemsInRangeByDistance(center, range)) - { - foundWithDistance.Add(result); - } - - Assert.Equal(2, foundWithDistance.Count); - // Each Item should have a non-negative min distance - Assert.All(foundWithDistance, item => Assert.True(item.Item2 >= 0)); - } - finally - { - DeleteAll(Items); - } - } - - [Fact] - public void ItemByDistanceEnumerator_OrderedBySector() - { - var map = Map.Felucca; - var center = new Point3D(400, 400, 0); - const int range = Map.SectorSize * 2; - - var Items = new TestItem[3]; - try - { - // Place Items in different sectors - Items[0] = CreateItem(map, new Point3D(center.X + 2, center.Y + 2, 0)); - Items[1] = CreateItem(map, new Point3D(center.X + Map.SectorSize + 2, center.Y + 2, 0)); - Items[2] = CreateItem(map, new Point3D(center.X + 2, center.Y + Map.SectorSize + 2, 0)); - - var found = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center, range)) - { - found.Add(Item); - } - - Assert.Equal(3, found.Count); - Assert.Contains(Items[0], found); - Assert.Contains(Items[1], found); - Assert.Contains(Items[2], found); - } - finally - { - DeleteAll(Items); - } - } - [Fact] public void ItemByDistanceEnumerator_MapNullYieldsEmpty() { @@ -239,50 +145,6 @@ public class ItemByDistanceEnumeratorTests } } - [Fact] - public void ItemByDistanceEnumerator_UsesDifferentPointOverloads() - { - var map = Map.Felucca; - var center = new Point3D(800, 800, 5); - const int range = 5; - - var Items = new TestItem[1]; - try - { - Items[0] = CreateItem(map, new Point3D(802, 802, 0)); - - // Test Point3D overload - var found1 = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center, range)) - { - found1.Add(Item); - } - - // Test (int, int) overload - var found2 = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center.X, center.Y, range)) - { - found2.Add(Item); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(new Point2D(center.X, center.Y), range)) - { - found3.Add(Item); - } - - Assert.Single(found1); - Assert.Equal(Items[0], found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(Items); - } - } - [Fact] public void ItemByDistanceEnumerator_RingTraversal() { @@ -384,58 +246,6 @@ public class ItemByDistanceEnumeratorTests } } - [Fact] - public void ItemByDistanceEnumerator_MultipleRings() - { - var map = Map.Felucca; - var center = new Point3D(1100, 1100, 0); - const int range = Map.SectorSize * 3; - - var Items = new List(); - try - { - // Create a grid of Items across multiple sectors - for (var ringOffset = 0; ringOffset <= 2; ringOffset++) - { - for (var side = 0; side < 4; side++) - { - var offset = ringOffset * Map.SectorSize; - var pos = side switch - { - 0 => new Point3D(center.X + offset, center.Y, 0), - 1 => new Point3D(center.X, center.Y + offset, 0), - 2 => new Point3D(center.X - offset, center.Y, 0), - _ => new Point3D(center.X, center.Y - offset, 0) - }; - Items.Add(CreateItem(map, pos)); - } - } - - var found = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center, range)) - { - found.Add(Item); - } - - // Should find all Items within range - Assert.True(found.Count > 0); - Assert.All(found, Item => - { - var dx = Item.X - center.X; - var dy = Item.Y - center.Y; - var distSq = dx * dx + dy * dy; - Assert.True(distSq <= range * range); - }); - } - finally - { - foreach (var Item in Items) - { - Item?.Delete(); - } - } - } - [Fact] public void ItemByDistanceEnumerator_MatchesBoundsEnumeratorAtMapEdge() { @@ -556,33 +366,6 @@ public class ItemByDistanceEnumeratorTests } } - [Fact] - public void ItemByDistanceEnumerator_Bounds_ReturnsMinDistance() - { - var map = Map.Felucca; - var bounds = new Rectangle2D(300, 300, 20, 20); - - var Items = new TestItem[2]; - try - { - Items[0] = CreateItem(map, new Point3D(305, 305, 0)); - Items[1] = CreateItem(map, new Point3D(315, 315, 0)); - - var foundWithDistance = new List<(Item, int)>(); - foreach (var result in map.GetItemsInBoundsByDistance(bounds)) - { - foundWithDistance.Add(result); - } - - Assert.Equal(2, foundWithDistance.Count); - Assert.All(foundWithDistance, item => Assert.True(item.Item2 >= 0)); - } - finally - { - DeleteAll(Items); - } - } - [Fact] public void ItemByDistanceEnumerator_Bounds_OrdersByProximityToCenter() { diff --git a/Projects/Server.Tests/Tests/Maps/ItemEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ItemEnumeratorTests.cs index bc92c3f58..5bed0ea2c 100644 --- a/Projects/Server.Tests/Tests/Maps/ItemEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ItemEnumeratorTests.cs @@ -256,35 +256,6 @@ public class ItemEnumeratorTests } } - [Fact] - public void ItemAtEnumerator_DeletedItemsAreSkipped() - { - var map = Map.Felucca; - var location = new Point3D(650, 650, 0); - - var items = new Item[3]; - try - { - items[0] = CreateItem(map, location); - items[1] = CreateItem(map, location); - items[2] = CreateItem(map, location); - - items[1].Delete(); - - var found = new List(); - foreach (var item in map.GetItemsAt(location)) - { - found.Add(item); - } - - Assert.Equal(new[] { items[0], items[2] }, found); - } - finally - { - DeleteAll(items); - } - } - [Fact] public void ItemAtEnumerator_ItemsWithParentAreSkipped() { @@ -365,49 +336,6 @@ public class ItemEnumeratorTests } } - [Fact] - public void ItemAtEnumerator_UsesDifferentPoint3DOverloads() - { - var map = Map.Felucca; - var location = new Point3D(800, 800, 5); - - var items = new Item[1]; - try - { - items[0] = CreateItem(map, location); - - // Test Point3D overload - var found1 = new List(); - foreach (var item in map.GetItemsAt(location)) - { - found1.Add(item); - } - - // Test (int, int) overload - should find the same item (Z is ignored) - var found2 = new List(); - foreach (var item in map.GetItemsAt(location.X, location.Y)) - { - found2.Add(item); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var item in map.GetItemsAt(new Point2D(location.X, location.Y))) - { - found3.Add(item); - } - - Assert.Single(found1); - Assert.Equal(items[0], found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(items); - } - } - [Fact] public void ItemEnumerator_ZeroRangeReturnsOnlyCenter() { diff --git a/Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs b/Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs index 60c3904b3..81ce7bc08 100644 --- a/Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs @@ -16,15 +16,6 @@ namespace Server.Tests.Maps Assert.Equal(expected, flags.ToCommaDelimitedString()); } - [Fact] - public void TestFormatFeluccaOnlyMapSelection() - { - const MapSelectionFlags flags = MapSelectionFlags.Felucca; - - // When, Then - Assert.Equal("Felucca", flags.ToCommaDelimitedString()); - } - public class TestMapConfig { [JsonConverter(typeof(FlagsConverter))] diff --git a/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs index 3f78377f9..c5d3afe64 100644 --- a/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs @@ -71,76 +71,6 @@ public class MobileByDistanceEnumeratorTests } } - [Fact] - public void MobileByDistanceEnumerator_ReturnsMinDistance() - { - var map = Map.Felucca; - var center = new Point3D(300, 300, 0); - const int range = 10; - - var mobiles = new TestMobile[2]; - try - { - mobiles[0] = CreateMobile(map, new Point3D(305, 305, 0)); - mobiles[1] = CreateMobile(map, new Point3D(302, 302, 0)); - - var foundWithDistance = new List<(Mobile, int)>(); - foreach (var result in map.GetMobilesInRangeByDistance(center, range)) - { - foundWithDistance.Add(result); - } - - Assert.Equal(2, foundWithDistance.Count); - // Each mobile should have a non-negative min distance - Assert.All(foundWithDistance, item => Assert.True(item.Item2 >= 0)); - } - finally - { - DeleteAll(mobiles); - } - } - - [Fact] - public void MobileByDistanceEnumerator_OrderedBySector() - { - var map = Map.Felucca; - var center = new Point3D(400, 400, 0); - const int range = Map.SectorSize * 2; - - var mobiles = new TestMobile[3]; - try - { - // Place mobiles in different sectors - mobiles[0] = CreateMobile(map, new Point3D(center.X + 2, center.Y + 2, 0)); - mobiles[1] = CreateMobile(map, new Point3D(center.X + Map.SectorSize + 2, center.Y + 2, 0)); - mobiles[2] = CreateMobile(map, new Point3D(center.X + 2, center.Y + Map.SectorSize + 2, 0)); - - var found = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(center, range)) - { - found.Add(mobile); - } - - Assert.Equal(3, found.Count); - Assert.Contains(mobiles[0], found); - Assert.Contains(mobiles[1], found); - Assert.Contains(mobiles[2], found); - } - finally - { - DeleteAll(mobiles); - } - } - - [Fact] - public void MobileByDistanceEnumerator_MapNullYieldsEmpty() - { - var center = new Point2D(0, 0); - var bounds = new Rectangle2D(center.m_X - 10, center.m_Y - 10, 21, 21); - var enumerator = new Map.MobileDistanceEnumerable(null, bounds, center, false).GetEnumerator(); - Assert.False(enumerator.MoveNext()); - } - [Fact] public void MobileByDistanceEnumerator_ThrowsOnVersionChange() { @@ -241,50 +171,6 @@ public class MobileByDistanceEnumeratorTests } } - [Fact] - public void MobileByDistanceEnumerator_UsesDifferentPointOverloads() - { - var map = Map.Felucca; - var center = new Point3D(800, 800, 5); - const int range = 5; - - var mobiles = new TestMobile[1]; - try - { - mobiles[0] = CreateMobile(map, new Point3D(802, 802, 0)); - - // Test Point3D overload - var found1 = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(center, range)) - { - found1.Add(mobile); - } - - // Test (int, int) overload - var found2 = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(center.X, center.Y, range)) - { - found2.Add(mobile); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(new Point2D(center.X, center.Y), range)) - { - found3.Add(mobile); - } - - Assert.Single(found1); - Assert.Equal(mobiles[0], found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(mobiles); - } - } - [Fact] public void MobileByDistanceEnumerator_RingTraversal() { @@ -386,58 +272,6 @@ public class MobileByDistanceEnumeratorTests } } - [Fact] - public void MobileByDistanceEnumerator_MultipleRings() - { - var map = Map.Felucca; - var center = new Point3D(1100, 1100, 0); - const int range = Map.SectorSize * 3; - - var mobiles = new List(); - try - { - // Create a grid of mobiles across multiple sectors - for (var ringOffset = 0; ringOffset <= 2; ringOffset++) - { - for (var side = 0; side < 4; side++) - { - var offset = ringOffset * Map.SectorSize; - var pos = side switch - { - 0 => new Point3D(center.X + offset, center.Y, 0), - 1 => new Point3D(center.X, center.Y + offset, 0), - 2 => new Point3D(center.X - offset, center.Y, 0), - _ => new Point3D(center.X, center.Y - offset, 0) - }; - mobiles.Add(CreateMobile(map, pos)); - } - } - - var found = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(center, range)) - { - found.Add(mobile); - } - - // Should find all mobiles within range - Assert.True(found.Count > 0); - Assert.All(found, mobile => - { - var dx = mobile.X - center.X; - var dy = mobile.Y - center.Y; - var distSq = dx * dx + dy * dy; - Assert.True(distSq <= range * range); - }); - } - finally - { - foreach (var mobile in mobiles) - { - mobile?.Delete(); - } - } - } - private static TestMobile CreateMobile(Map map, Point3D location) { var mobile = new TestMobile(World.NewMobile); @@ -526,33 +360,6 @@ public class MobileByDistanceEnumeratorTests } } - [Fact] - public void MobileByDistanceEnumerator_Bounds_ReturnsMinDistance() - { - var map = Map.Felucca; - var bounds = new Rectangle2D(300, 300, 20, 20); - - var mobiles = new TestMobile[2]; - try - { - mobiles[0] = CreateMobile(map, new Point3D(305, 305, 0)); - mobiles[1] = CreateMobile(map, new Point3D(315, 315, 0)); - - var foundWithDistance = new List<(Mobile, int)>(); - foreach (var result in map.GetMobilesInBoundsByDistance(bounds)) - { - foundWithDistance.Add(result); - } - - Assert.Equal(2, foundWithDistance.Count); - Assert.All(foundWithDistance, item => Assert.True(item.Item2 >= 0)); - } - finally - { - DeleteAll(mobiles); - } - } - [Fact] public void MobileByDistanceEnumerator_Bounds_OrdersByProximityToCenter() { diff --git a/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs index ee932096f..4f9539486 100644 --- a/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs @@ -87,13 +87,6 @@ public class MobileEnumeratorTests } } - [Fact] - public void MobileEnumerator_MapNullYieldsEmpty() - { - var enumerator = new Map.MobileEnumerator(null, Rectangle2D.Empty, false); - Assert.False(enumerator.MoveNext()); - } - [Fact] public void MobileEnumerator_ThrowsOnVersionChange() { diff --git a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs index c064623bf..718fadefc 100644 --- a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs @@ -88,13 +88,6 @@ public class MultiEnumeratorTests } } - [Fact] - public void MultiEnumerator_MapNullYieldsEmpty() - { - var enumerator = new Map.MultiBoundsEnumerable(null, Rectangle2D.Empty, false).GetEnumerator(); - Assert.False(enumerator.MoveNext()); - } - [Fact] public void MultiEnumerator_ThrowsOnVersionChange() { @@ -250,35 +243,6 @@ public class MultiEnumeratorTests } } - [Fact] - public void MultiSectorEnumerator_DeletedMultisAreSkipped() - { - var map = Map.Felucca; - var location = new Point3D(750, 750, 0); - - var multis = new TestMulti[3]; - try - { - multis[0] = CreateMulti(map, location); - multis[1] = CreateMulti(map, new Point3D(location.X + 1, location.Y, 0)); - multis[2] = CreateMulti(map, new Point3D(location.X + 2, location.Y, 0)); - - multis[1].Delete(); - - var found = new List(); - foreach (var multi in map.GetMultisInRange(location, 10)) - { - found.Add(multi); - } - - Assert.Equal(new[] { multis[0], multis[2] }, found); - } - finally - { - DeleteAll(multis); - } - } - [Fact] public void MultiSectorEnumerator_MapNullYieldsEmpty() { @@ -286,49 +250,6 @@ public class MultiEnumeratorTests Assert.False(enumerator.MoveNext()); } - [Fact] - public void MultiSectorEnumerator_UsesDifferentPointOverloads() - { - var map = Map.Felucca; - var location = new Point3D(800, 800, 5); - - var multis = new TestMulti[1]; - try - { - multis[0] = CreateMulti(map, location); - - // Test Point3D overload - var found1 = new List(); - foreach (var multi in map.GetMultisInSector(location)) - { - found1.Add(multi); - } - - // Test (int, int) overload - var found2 = new List(); - foreach (var multi in map.GetMultisInSector(location.X, location.Y)) - { - found2.Add(multi); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var multi in map.GetMultisInSector(new Point2D(location.X, location.Y))) - { - found3.Add(multi); - } - - Assert.Single(found1); - Assert.Equal(multis[0], found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(multis); - } - } - [Fact] public void MultiEnumerator_ZeroRangeReturnsOnlyCenter() { @@ -392,7 +313,7 @@ public class MultiEnumeratorTests var map = Map.Felucca; // Components reach two tiles either side, so at x = 1024 the multi spans sectors 63 and 64 - var spanning = new SpanningMulti(); + var spanning = new TestMulti(SpanningComponents); try { spanning.MoveToWorld(new Point3D(1024, 1000, 0), map); @@ -430,29 +351,11 @@ public class MultiEnumeratorTests } } - // Test implementation of BaseMulti - private class TestMulti : BaseMulti - { - public TestMulti() : base(0x1) - { - } - } - - private class SpanningMulti : BaseMulti - { - // Entries after the first need non-zero flags or MultiComponentList drops them - private static readonly MultiComponentList _components = new( - [ - new MultiTileEntry(0x1, -2, 0, 0, TileFlag.Background), - new MultiTileEntry(0x1, 2, 0, 0, TileFlag.Background) - ] - ); - - public SpanningMulti() : base(0x1) - { - } - - public override MultiComponentList Components => _components; - } + // Entries after the first need non-zero flags or MultiComponentList drops them + private static readonly MultiComponentList SpanningComponents = new( + [ + new MultiTileEntry(0x1, -2, 0, 0, TileFlag.Background), + new MultiTileEntry(0x1, 2, 0, 0, TileFlag.Background) + ] + ); } - diff --git a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs index edce65c89..70fc9e006 100644 --- a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs @@ -7,50 +7,23 @@ namespace Server.Tests.Maps; [Collection("Sequential Server Tests")] public class StaticTileEnumeratorTests { - [Fact] - public void StaticTileEnumerator_MapNullYieldsEmpty() - { - var enumerator = new Map.StaticTileEnumerable(null, new Point2D(0, 0)).GetEnumerator(); - Assert.False(enumerator.MoveNext()); - } - - [Fact] - public void StaticTileEnumerator_EmptyLocationYieldsEmpty() - { - var map = Map.Felucca; - var location = new Point2D(100, 100); - - var tiles = new List(); - foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: true, includeMultis: false)) - { - tiles.Add(tile); - } - - // Since we don't have actual map files loaded, this should be empty - Assert.Empty(tiles); - } - [Fact] public void StaticTileEnumerator_IncludeStaticsOnlyWorks() { var map = Map.Felucca; var location = new Point2D(200, 200); + // 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; try { - // Create a multi at the location multi = CreateMultiWithComponents(map, new Point3D(200, 200, 0)); - // Get tiles with statics only (no multis) - var tiles = new List(); - foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: true, includeMultis: false)) - { - tiles.Add(tile); - } + var tiles = CollectTiles(map, location, includeStatics: true, includeMultis: false); - // Should not include multi tiles - Assert.Empty(tiles); + Assert.Equal(mapStatics, tiles); } finally { @@ -92,21 +65,20 @@ public class StaticTileEnumeratorTests var map = Map.Felucca; var location = new Point2D(400, 400); + var mapStatics = CollectTiles(map, location, includeStatics: true, includeMultis: false); + TestMulti multi = null; try { - // Create a multi at the location multi = CreateMultiWithComponents(map, new Point3D(400, 400, 0)); - // Get all tiles (statics and multis) - var tiles = new List(); - foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: true, includeMultis: true)) - { - tiles.Add(tile); - } + var multiTiles = CollectTiles(map, location, includeStatics: false, includeMultis: true); + var tiles = CollectTiles(map, location, includeStatics: true, includeMultis: true); - // Should include multi tiles (statics would be empty without map files) - Assert.NotEmpty(tiles); + Assert.NotEmpty(multiTiles); + Assert.Equal(mapStatics.Count + multiTiles.Count, tiles.Count); + Assert.All(mapStatics, tile => Assert.Contains(tile, tiles)); + Assert.All(multiTiles, tile => Assert.Contains(tile, tiles)); } finally { @@ -144,24 +116,6 @@ public class StaticTileEnumeratorTests } } - [Fact] - public void StaticTileEnumerator_TileMatrixGetStaticTilesWorks() - { - var map = Map.Felucca; - var x = 600; - var y = 600; - - // Test the TileMatrix.GetStaticTiles method - var tiles = new List(); - foreach (var tile in map.Tiles.GetStaticTiles(x, y)) - { - tiles.Add(tile); - } - - // Without map files loaded, should be empty - Assert.Empty(tiles); - } - [Fact] public void StaticTileEnumerator_TileMatrixGetStaticAndMultiTilesWorks() { @@ -252,13 +206,6 @@ public class StaticTileEnumeratorTests } } - [Fact] - public void StaticTileEnumerator_EmptyReturnsCorrectly() - { - var enumerator = Map.StaticTileEnumerable.Empty.GetEnumerator(); - Assert.False(enumerator.MoveNext()); - } - [Fact] public void StaticTileEnumerator_DeletedMultiSkipped() { @@ -305,10 +252,10 @@ public class StaticTileEnumeratorTests { // Outer's bounds cover the probe cell but it has no tile there; placing it first puts it // ahead of inner in the sector's multi list - outer = new TestMulti(World.NewItem, GappedComponents); + outer = new TestMulti(GappedComponents); outer.MoveToWorld(new Point3D(1100, 1100, 0), map); - inner = new TestMulti(World.NewItem, SingleTileComponents); + inner = new TestMulti(SingleTileComponents); inner.MoveToWorld(new Point3D(location.X, location.Y, 20), map); var tiles = new List(); @@ -342,28 +289,28 @@ public class StaticTileEnumeratorTests ] ); + private static readonly MultiComponentList DefaultComponents = new( + [ + new MultiTileEntry(0x1, 0, 0, 0, 0x0), + new MultiTileEntry(0x2, 1, 0, 0, 0x0) + ] + ); + + private static List CollectTiles(Map map, Point2D location, bool includeStatics, bool includeMultis) + { + var tiles = new List(); + foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics, includeMultis)) + { + tiles.Add(tile); + } + + return tiles; + } + private static TestMulti CreateMultiWithComponents(Map map, Point3D location) { - var multi = new TestMulti(World.NewItem); + var multi = new TestMulti(DefaultComponents); multi.MoveToWorld(location, map); return multi; } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(Serial serial, MultiComponentList components = null) : base(serial) => - _components = components ?? DefaultComponents; - - public override MultiComponentList Components => _components; - - private static readonly MultiComponentList DefaultComponents = new( - [ - new MultiTileEntry(0x1, 0, 0, 0, 0x0), - new MultiTileEntry(0x2, 1, 0, 0, 0x0) - ] - ); - } } - diff --git a/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs b/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs new file mode 100644 index 000000000..443e142eb --- /dev/null +++ b/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs @@ -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); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs index 5eb1895d8..931947a55 100644 --- a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs +++ b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs @@ -7,8 +7,18 @@ using Xunit; namespace Server.Tests.Network.Bans; -public class BanChannelTests +[Collection("Sequential Server Tests")] +public class BanChannelTests : IDisposable { + private readonly IBanReporter[] _previousReporters = [..BanChannel.Reporters]; + private readonly Func _previousIsExempt = BanChannel.IsExempt; + + public void Dispose() + { + BanChannel.ConfigureForTesting(_previousReporters); + BanChannel.IsExempt = _previousIsExempt; + } + private sealed class FakeReporter : IBanReporter { public readonly List<(IPAddress ip, TimeSpan ttl, string reason)> Reports = []; @@ -69,18 +79,11 @@ public class BanChannelTests var exempt = IPAddress.Parse("203.0.113.7"); BanChannel.IsExempt = (ip, _) => ip.Equals(exempt); - try - { - BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); - Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted + BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); + Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted - BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); - Assert.Single(reporter.Reports); // everyone else is still contributed - } - finally - { - BanChannel.IsExempt = null; - } + BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); + Assert.Single(reporter.Reports); // everyone else is still contributed } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs index ee232b1df..c7a5f9a1e 100644 --- a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs +++ b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs @@ -32,13 +32,4 @@ public class BanConfigurationTests Assert.Equal(original.ReportRateLimitTrips, restored.ReportRateLimitTrips); Assert.Equal(original.AutoBanDuration, restored.AutoBanDuration); // TimeSpan survives } - - [Fact] - public void BanSettings_Defaults_AreReportRateLimitTripsFourHourAutoBan() - { - var settings = new BanSettings(); - - Assert.True(settings.ReportRateLimitTrips); - Assert.Equal(TimeSpan.FromHours(4), settings.AutoBanDuration); - } } diff --git a/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs index dfb8c7be7..c5dcb190c 100644 --- a/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs +++ b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs @@ -10,14 +10,6 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class EventLoopIdleTests { - [Fact] - public void FreshContextIsEmpty() - { - var context = new EventLoopContext(); - - Assert.True(context.IsEmpty); - } - [Fact] public void PostedWorkMakesContextNonEmpty() { diff --git a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs index 63cb9d4a6..4e9c82ff5 100644 --- a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs +++ b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs @@ -4,337 +4,20 @@ using Xunit; namespace Server.Tests.Network; /// -/// Manual tests for MovementThrottle latency scenarios. -/// These tests simulate specific timing scenarios and should NOT run in CI/CD. -/// -/// To run these tests manually: -/// dotnet test --filter "Category=Manual" -/// -/// To exclude from CI/CD, add to your test command: -/// dotnet test --filter "Category!=Manual" +/// MovementThrottle burst forgiveness against a full, wrapped movement history. /// [Collection("Sequential Server Tests")] -[Trait("Category", "Manual")] public class MovementThrottleLatencyTests { - /// - /// Sets the simulated tick count for testing. - /// - private static void SetTickCount(long ticks) - { - Core._tickCount = ticks; - } - - /// - /// Simulates recording movements at specific intervals. - /// - private static void SimulateMovements(NetState ns, params (int delayMs, int costMs)[] movements) - { - ns._movementHistory ??= new NetState.MovementRecord[20]; - ns._movementHistoryIndex = 0; - ns._movementHistoryFull = false; - ns._lastMovementRecordTime = 0; - - var currentTime = 0L; - - foreach (var (delayMs, costMs) in movements) - { - currentTime += delayMs; - SetTickCount(currentTime); - - if (ns._lastMovementRecordTime > 0) - { - var interval = (int)(currentTime - ns._lastMovementRecordTime); - - // Skip chain-breaking gaps (> 2000ms) like the real code does - if (interval > 2000) - { - ns._lastGapDuration = interval; - ns._lastMovementRecordTime = currentTime; - continue; - } - - ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord - { - Interval = (short)interval, - TargetSpeed = (ushort)costMs, - QueueDepth = 0, - Flags = 0 - }; - - ns._movementHistoryIndex++; - if (ns._movementHistoryIndex >= 20) - { - ns._movementHistoryIndex = 0; - ns._movementHistoryFull = true; - } - } - - ns._lastMovementRecordTime = currentTime; - } - } - - /// - /// Scenario: Player experiences a 3-second network lag spike, then all buffered - /// packets arrive at once. This should NOT trigger false positive detection. - /// - /// Timeline: - /// - T=0-1000ms: Normal movement (10 packets at 100ms intervals) - /// - T=1000-4000ms: Network lag (client buffers packets) - /// - T=4000ms: Network recovers, 30 buffered packets arrive simultaneously - /// [Fact] - public void LagSpike_BurstRecovery_NoFalsePositive() - { - var ns = PacketTestUtilities.CreateTestNetState(); - SetTickCount(0); - - // Phase 1: Normal movement for 1 second - var movements = new (int delayMs, int costMs)[10]; - for (var i = 0; i < 10; i++) - { - movements[i] = (100, 100); // Normal mounted running - } - SimulateMovements(ns, movements); - - // Verify normal rate before lag - var rateBeforeLag = MovementThrottle.CalculateMovementRate(ns, out var samples); - Assert.True(samples >= 8, $"Expected >= 8 samples, got {samples}"); - Assert.True(rateBeforeLag >= 0.95f && rateBeforeLag <= 1.05f, - $"Expected normal rate before lag, got {rateBeforeLag}"); - - // Phase 2: Simulate 3 second lag followed by burst - // The gap (3000ms) is tracked, then burst packets arrive - var currentTime = Core.TickCount; - - // Record the lag gap - SetTickCount(currentTime + 3000); - ns._lastGapDuration = 3000; // Gap > maxChainGap triggers this - - // Burst packets arriving (5 packets with tiny intervals) - for (var i = 0; i < 5; i++) - { - SetTickCount(Core.TickCount + 2); // 2ms between packets (simultaneous arrival) - - if (ns._lastMovementRecordTime > 0) - { - var interval = (int)(Core.TickCount - ns._lastMovementRecordTime); - ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord - { - Interval = (short)interval, - TargetSpeed = 100, - QueueDepth = 0, - Flags = 0 - }; - ns._movementHistoryIndex++; - if (ns._movementHistoryIndex >= 20) - { - ns._movementHistoryIndex = 0; - ns._movementHistoryFull = true; - } - } - ns._lastMovementRecordTime = Core.TickCount; - } - - // Analyze - should apply burst forgiveness due to large gap - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - - // The burst will have high rate due to tiny intervals, but: - // 1. _lastGapDuration > 1000 (maxChainGap/2) triggers burst forgiveness - // 2. Confidence should be reduced by 0.3f multiplier - // 3. Should NOT be Definite unless truly extreme - Assert.True( - verdict != MovementThrottle.DetectionVerdict.Definite, - $"Lag recovery should not be Definite. Rate={rate}, Confidence={confidence}, Verdict={verdict}" - ); - - // Gap duration should be cleared after analysis - Assert.Equal(0, ns._lastGapDuration); - } - - /// - /// Scenario: Player with consistently high latency (400ms RTT) moves normally. - /// Their connection is consistent but slow - should NOT be considered "stable" - /// for the purpose of tightening detection thresholds. - /// - [Fact] - public void HighLatencyConsistent_NotConsideredStable() + public void AnalyzeMovement_BurstAfterGap_WrappedFullHistory_ForgivesAndClearsGap() { var ns = PacketTestUtilities.CreateTestNetState(); - // Set up high but consistent RTT - ns._rttHistory = new long[] { 400, 405, 398, 402, 401, 399, 403, 400 }; - ns._rttSampleCount = 8; - - // Calculate variance (should be low since values are consistent) - long sum = 0, sumSq = 0; - for (var i = 0; i < 8; i++) - { - sum += ns._rttHistory[i]; - sumSq += ns._rttHistory[i] * ns._rttHistory[i]; - } - var mean = sum / 8; - ns._rttVariance = sumSq / 8 - mean * mean; - - // Variance is low (consistent), but should NOT be "stable" due to high latency - Assert.False(ns.HasStableConnection, - $"400ms RTT should not be stable. AvgRTT={ns.AverageRtt}, Variance={ns._rttVariance}"); - - // Now verify detection gives more leniency for high-latency connections - SimulateMovements(ns, - (105, 100), (105, 100), (105, 100), (105, 100), (105, 100), - (105, 100), (105, 100), (105, 100), (105, 100), (105, 100) - ); - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - - // Slightly slow but high-latency player should get benefit of doubt - Assert.True( - verdict == MovementThrottle.DetectionVerdict.Normal || - verdict == MovementThrottle.DetectionVerdict.Possible, - $"High latency player should get leniency. Verdict={verdict}" - ); - } - - /// - /// Scenario: RTT probing and measurement. - /// Verifies that RTT variance is calculated correctly and stability - /// is properly determined. - /// - [Fact] - public void RttMeasurement_VarianceCalculation() - { - var ns = PacketTestUtilities.CreateTestNetState(); - SetTickCount(1000); - - // Simulate stable low-latency RTT measurements - var rttValues = new long[] { 50, 48, 52, 49, 51, 50, 48, 52 }; - - ns._rttHistory = new long[8]; - for (var i = 0; i < rttValues.Length; i++) - { - ns._rttHistory[i] = rttValues[i]; - } - ns._rttSampleCount = 8; - - // Calculate expected variance - long sum = 0, sumSq = 0; - foreach (var rtt in rttValues) - { - sum += rtt; - sumSq += rtt * rtt; - } - var expectedMean = sum / 8; - var expectedVariance = sumSq / 8 - expectedMean * expectedMean; - - // Clamp negative variance (safety check) - if (expectedVariance < 0) - { - expectedVariance = 0; - } - - ns._rttVariance = expectedVariance; - - Assert.True(ns._rttVariance >= 0, "Variance should never be negative"); - Assert.True(ns._rttVariance < 2500, "Low-variance samples should have variance < 2500"); - Assert.True(ns.AverageRtt > 0 && ns.AverageRtt < 200, "Average RTT should be ~50ms"); - Assert.True(ns.HasStableConnection, "Should be considered stable connection"); - } - - /// - /// Scenario: Actual speed hacker with stable connection. - /// Should be detected with high confidence. - /// - [Fact] - public void SpeedHack_StableConnection_DetectedDefinite() - { - var ns = PacketTestUtilities.CreateTestNetState(); - - // Set up stable, low-latency RTT - ns._rttHistory = new long[] { 30, 32, 28, 31, 29, 30, 31, 30 }; - ns._rttSampleCount = 8; - - long sum = 0, sumSq = 0; - for (var i = 0; i < 8; i++) - { - sum += ns._rttHistory[i]; - sumSq += ns._rttHistory[i] * ns._rttHistory[i]; - } - ns._rttVariance = sumSq / 8 - (sum / 8) * (sum / 8); - if (ns._rttVariance < 0) - { - ns._rttVariance = 0; - } - - Assert.True(ns.HasStableConnection, "Should be stable for this test"); - - // Simulate speed hack: 50% faster movement - SimulateMovements(ns, - (67, 100), (67, 100), (67, 100), (67, 100), (67, 100), - (67, 100), (67, 100), (67, 100), (67, 100), (67, 100), - (67, 100), (67, 100), (67, 100), (67, 100), (67, 100) - ); - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - - Assert.True(rate > 1.4f, $"Expected rate > 1.4 (50% speed hack), got {rate}"); - Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict); - Assert.True(confidence > 0.5f, $"Expected high confidence, got {confidence}"); - } - - /// - /// Scenario: Borderline speed hack (7% faster) with unstable connection. - /// Should give benefit of doubt (not Definite). - /// - [Fact] - public void BorderlineSpeed_UnstableConnection_BenefitOfDoubt() - { - var ns = PacketTestUtilities.CreateTestNetState(); - - // Set up unstable connection - ns._rttHistory = new long[] { 50, 300, 80, 250, 100, 400, 60, 350 }; - ns._rttSampleCount = 8; - - long sum = 0, sumSq = 0; - for (var i = 0; i < 8; i++) - { - sum += ns._rttHistory[i]; - sumSq += ns._rttHistory[i] * ns._rttHistory[i]; - } - ns._rttVariance = sumSq / 8 - (sum / 8) * (sum / 8); - - Assert.False(ns.HasStableConnection, "Should be unstable due to high variance"); - - // Simulate borderline speed (7% faster) - SimulateMovements(ns, - (93, 100), (93, 100), (93, 100), (93, 100), (93, 100), - (93, 100), (93, 100), (93, 100), (93, 100), (93, 100) - ); - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - - Assert.True(rate > 1.05f && rate < 1.10f, $"Expected rate ~1.07, got {rate}"); - - // Unstable connection + borderline rate = should NOT be Definite - Assert.True( - verdict != MovementThrottle.DetectionVerdict.Definite, - $"Borderline case with unstable connection should not be Definite. Verdict={verdict}, Confidence={confidence}" - ); - } - - /// - /// Scenario: Verifies that burst forgiveness triggers correctly and clears gap duration. - /// - [Fact] - public void MultipleLagSpikes_NoAccumulatedSuspicion() - { - var ns = PacketTestUtilities.CreateTestNetState(); - SetTickCount(0); - - // Set up history buffer with mostly normal movements and a burst at the end + // The production recorder is private and only reachable through a full movement request, so + // the history is laid out directly: 17 normal steps, then a 3-step burst ending at the wrap ns._movementHistory = new NetState.MovementRecord[20]; - // Fill with 17 normal movements (100ms intervals) for (var i = 0; i < 17; i++) { ns._movementHistory[i] = new NetState.MovementRecord @@ -346,7 +29,6 @@ public class MovementThrottleLatencyTests }; } - // Add 3 burst movements at the end (10ms intervals = burst) for (var i = 17; i < 20; i++) { ns._movementHistory[i] = new NetState.MovementRecord @@ -358,73 +40,22 @@ public class MovementThrottleLatencyTests }; } - ns._movementHistoryIndex = 0; // Next write position (wrapped) - ns._movementHistoryFull = true; // Buffer is full + ns._movementHistoryIndex = 0; + ns._movementHistoryFull = true; - // Set gap duration (simulating a lag spike before the burst) + // A lag spike longer than the chain gap preceded the burst ns._lastGapDuration = 2500; - // Analyze - burst forgiveness should trigger - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var samples, out var confidence); + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - // Key assertions: - // 1. Gap duration should be cleared (burst forgiveness triggered) Assert.Equal(0, ns._lastGapDuration); - - // 2. Confidence should be reduced by 0.3x multiplier Assert.True( confidence < 0.5f, $"Burst after gap should have low confidence. Confidence={confidence}, Rate={rate}" ); - - // 3. Should NOT be Definite despite the burst (forgiveness applied) Assert.True( verdict != MovementThrottle.DetectionVerdict.Definite, $"Should not be Definite after burst forgiveness. Verdict={verdict}" ); } - - /// - /// Scenario: Walking vs running speed differences. - /// Walking (200ms interval) vs mounted running (100ms interval) should both - /// calculate correct rates. - /// - [Fact] - public void WalkingVsRunning_CorrectRateCalculation() - { - // Test walking (200ms expected interval) - var nsWalking = PacketTestUtilities.CreateTestNetState(); - SimulateMovements(nsWalking, - (200, 200), (200, 200), (200, 200), (200, 200), (200, 200), - (200, 200), (200, 200), (200, 200), (200, 200), (200, 200) - ); - - var walkingRate = MovementThrottle.CalculateMovementRate(nsWalking, out var walkingSamples); - Assert.True(walkingSamples >= 8, "Should have enough walking samples"); - Assert.True(walkingRate >= 0.95f && walkingRate <= 1.05f, - $"Walking rate should be ~1.0, got {walkingRate}"); - - // Test mounted running (100ms expected interval) - var nsRunning = PacketTestUtilities.CreateTestNetState(); - SimulateMovements(nsRunning, - (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), - (100, 100), (100, 100), (100, 100), (100, 100), (100, 100) - ); - - var runningRate = MovementThrottle.CalculateMovementRate(nsRunning, out var runningSamples); - Assert.True(runningSamples >= 8, "Should have enough running samples"); - Assert.True(runningRate >= 0.95f && runningRate <= 1.05f, - $"Running rate should be ~1.0, got {runningRate}"); - - // Test speed hack while walking - var nsWalkingHack = PacketTestUtilities.CreateTestNetState(); - SimulateMovements(nsWalkingHack, - (100, 200), (100, 200), (100, 200), (100, 200), (100, 200), - (100, 200), (100, 200), (100, 200), (100, 200), (100, 200) - ); - - var walkingHackRate = MovementThrottle.CalculateMovementRate(nsWalkingHack, out _); - Assert.True(walkingHackRate >= 1.9f && walkingHackRate <= 2.1f, - $"Walking at running speed should be rate ~2.0, got {walkingHackRate}"); - } } diff --git a/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs b/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs index eca2ee0bf..808447b56 100644 --- a/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs +++ b/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs @@ -212,30 +212,6 @@ public class MovementThrottleTests Assert.True(rate > 1.25f, $"Expected rate > 1.25, got {rate}"); } - [Fact] - public void AnalyzeMovement_ModerateSpeedHack_ReturnsPossibleOrLikely() - { - // 8% faster than expected - moderate, should be Possible or Likely - var ns = CreateNetStateWithHistory( - (93, 100), (93, 100), (93, 100), (93, 100), (93, 100), - (93, 100), (93, 100), (93, 100), (93, 100), (93, 100) - ); - - // Set up stable, low-latency RTT (required for Likely verdict path) - ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50]; - ns._rttSampleCount = 8; - ns._rttVariance = 4; // Low variance - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); - - Assert.True( - verdict == MovementThrottle.DetectionVerdict.Possible || - verdict == MovementThrottle.DetectionVerdict.Likely, - $"Expected Possible or Likely, got {verdict}. Rate={rate}, Confidence={confidence}" - ); - Assert.True(rate > 1.05f && rate < 1.15f, $"Expected rate ~1.07, got {rate}"); - } - [Fact] public void AnalyzeMovement_BurstAfterLargeGap_ReducesConfidence() { @@ -279,25 +255,6 @@ public class MovementThrottleTests Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict); } - [Fact] - public void AnalyzeMovement_StableConnectionLowLatency_IncreasesConfidence() - { - var ns = CreateNetStateWithHistory( - (95, 100), (95, 100), (95, 100), (95, 100), (95, 100), - (95, 100), (95, 100), (95, 100), (95, 100), (95, 100) - ); - - // Set up stable, low-latency RTT - ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50]; - ns._rttSampleCount = 8; - ns._rttVariance = 4; // Low variance - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); - - // With stable connection, even small anomalies are more suspicious - Assert.True(confidence > 0.2f, $"Expected higher confidence for stable connection, got {confidence}"); - } - [Fact] public void AnalyzeMovement_UnstableConnection_ReducesConfidence() { diff --git a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs index d1f273db3..fafb15d8a 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs @@ -1,6 +1,4 @@ using System; -using System.Diagnostics; -using System.Net.Sockets; using Server.Network; using Xunit; @@ -96,35 +94,10 @@ public class NetStateDisconnectTests Assert.True(ns._socket.DisconnectPending); - var received = new byte[payload.Length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - - while (total < payload.Length && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, payload.Length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(payload, received); + Assert.Equal(payload, NetTestHelpers.ReadAll(client, payload.Length, 5000)); // FIN follows once the send completes - var eof = -1; - while (eof != 0 && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - eof = client.Receive(received); - } - } - - Assert.Equal(0, eof); + NetTestHelpers.WaitForEof(client, 5000); } finally { @@ -156,35 +129,8 @@ public class NetStateDisconnectTests Assert.True(ns._socket.DisconnectPending); - var received = new byte[payload.Length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - - while (total < payload.Length && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, payload.Length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(payload.Length, total); - Assert.Equal(payload, received); - - var eof = -1; - while (eof != 0 && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - eof = client.Receive(received); - } - } - - Assert.Equal(0, eof); + Assert.Equal(payload, NetTestHelpers.ReadAll(client, payload.Length)); + NetTestHelpers.WaitForEof(client); } finally { diff --git a/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs b/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs index 548406ec4..8d596d31b 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs @@ -1,8 +1,4 @@ -using System; using System.Buffers; -using System.Diagnostics; -using System.Network; -using System.Threading; using Server.Network; using Xunit; @@ -12,71 +8,33 @@ namespace Server.Tests.Network; public class NetStateRecvGuardTests { private const byte TestPacketId = 0xB1; + private const byte AttachAccountPacketId = 0x71; - private static unsafe void RegisterVariableLength() - { - if (IncomingPackets.GetHandler(TestPacketId) == null) - { - IncomingPackets.Register(TestPacketId, 0, false, &NoOp); - } - } + private static unsafe NetTestHelpers.HandlerRegistration RegisterVariableLength() => + NetTestHelpers.RegisterHandler(TestPacketId, 0, &NoOp); private static void NoOp(NetState state, SpanReader reader) { } - // Not 0x73: NetStateSendBufferTests registers its own no-op there first-come-wins, since the - // handler table is process-global, and this test needs its own handler's side effect to fire. - private const byte TestPingPacketId = 0x72; - - private static byte _lastPing; - - private static unsafe void RegisterNoOpPing() - { - if (IncomingPackets.GetHandler(TestPingPacketId) == null) - { - IncomingPackets.Register(TestPingPacketId, 2, false, &RecordPing); - } - } - - private static void RecordPing(NetState state, SpanReader reader) => _lastPing = reader.ReadByte(); - - private const byte AttachAccountPacketId = 0x71; - - private static unsafe void RegisterAttachAccount() - { - if (IncomingPackets.GetHandler(AttachAccountPacketId) == null) - { - IncomingPackets.Register(AttachAccountPacketId, 3, false, &AttachAccount); - } - } + private static unsafe NetTestHelpers.HandlerRegistration RegisterAttachAccount() => + NetTestHelpers.RegisterHandler(AttachAccountPacketId, 3, &AttachAccount); // Stands in for the 0x91 handler: the account attaches mid-parse, so the promotion is pending // with a receive armed when the next packet in the same completion reaches the guard private static void AttachAccount(NetState state, SpanReader reader) => state.Account = new MockAccount(); - private static void SliceUntil(Func done) - { - var deadline = Stopwatch.StartNew(); - while (!done() && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - Thread.Sleep(5); - } - - Assert.True(done()); - } - [SkippableFact] public void HandleReceive_PacketLongerThanTheRecvBuffer_IsAnError() { - RegisterVariableLength(); + // A 16-bit length cannot exceed the 64 KiB base buffer, so the guard is only reachable + // through a pre-auth buffer + NetTestHelpers.RequirePreAuthBuffers(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { - // A 16-bit length cannot exceed a 64 KiB buffer, so the guard is unreachable there and - // this only ever hit the `< 3` check on a buffer that size. - Skip.If(ns._socket.RecvBuffer.PhysicalSize > ushort.MaxValue); + Assert.True(ns._socket.RecvBuffer.PhysicalSize <= ushort.MaxValue); ns._protocolState = NetState.ProtocolState.GameServer_LoggedIn; @@ -84,7 +42,7 @@ public class NetStateRecvGuardTests // park the connection with nothing to arm a recv into var declared = ns._socket.RecvBuffer.PhysicalSize; // one more than the buffer can ever hold client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._protocolState == NetState.ProtocolState.Error); + NetTestHelpers.SliceUntil(() => ns._protocolState == NetState.ProtocolState.Error); Assert.Contains("bad state", ns._disconnectReason); } @@ -98,7 +56,7 @@ public class NetStateRecvGuardTests [Fact] public void HandleReceive_PacketThatFits_WaitsForTheRest() { - RegisterVariableLength(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -106,7 +64,7 @@ public class NetStateRecvGuardTests var declared = ns._socket.RecvBuffer.PhysicalSize / 2; client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); Assert.True(ns.Running); @@ -122,8 +80,8 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_OversizePacket_WithAnAccount_RetriesPromotionInsteadOfErroring() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterVariableLength(); + NetTestHelpers.RequirePreAuthBuffers(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -135,7 +93,7 @@ public class NetStateRecvGuardTests var declared = NetState.InitialRecvBufferSize; // as much as the initial buffer can ever hold client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); Assert.True(ns.Running); @@ -143,7 +101,7 @@ public class NetStateRecvGuardTests // The deferred promotion applies at this next completion; the header is then delivered // whole to the 0xB1 no-op handler client.Send(new byte[declared - 3]); - SliceUntil( + NetTestHelpers.SliceUntil( () => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize ); @@ -161,8 +119,8 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_AfterABurstFillsTheInitialBuffer_KeepsReceiving() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterNoOpPing(); + NetTestHelpers.RequirePreAuthBuffers(); + using var ping = NetTestHelpers.RegisterPing(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -174,17 +132,17 @@ public class NetStateRecvGuardTests var pings = new byte[capacity + 1]; for (var i = 0; i < pings.Length; i += 2) { - pings[i] = TestPingPacketId; + pings[i] = NetTestHelpers.PingPacketId; pings[i + 1] = (byte)(i / 2); } client.Send(pings); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._receivedData); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._receivedData); Assert.True(ns.Running); // Without a re-arm this ping never arrives - client.Send(new byte[] { TestPingPacketId, 0xEE }); - SliceUntil(() => _lastPing == 0xEE); + client.Send(new byte[] { NetTestHelpers.PingPacketId, 0xEE }); + NetTestHelpers.SliceUntil(() => NetTestHelpers.LastPing == 0xEE); Assert.True(ns.Running); } finally @@ -197,9 +155,9 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_AccountAttachedMidParse_ThenOversizeHeader_WaitsForThePendingPromotion() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterAttachAccount(); - RegisterVariableLength(); + NetTestHelpers.RequirePreAuthBuffers(); + using var attach = RegisterAttachAccount(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -212,7 +170,7 @@ public class NetStateRecvGuardTests // header then hits the guard after the promotion already applied and simply waits on the // 64 KiB buffer, so the test cannot flake, it just exercises the weaker path on that run. client.Send(new byte[] { AttachAccountPacketId, 0x00, 0x00, TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns.Account != null); + NetTestHelpers.SliceUntil(() => ns.Account != null); Assert.True(ns.Running); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); @@ -220,7 +178,7 @@ public class NetStateRecvGuardTests // The next completion applies the promotion and the whole packet is delivered client.Send(new byte[declared - 3]); - SliceUntil( + NetTestHelpers.SliceUntil( () => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize ); diff --git a/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs b/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs index eba603e28..c92260070 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs @@ -1,10 +1,8 @@ using System; using System.Buffers; using System.Collections.Generic; -using System.Diagnostics; using System.Net.Sockets; using System.Network; -using System.Threading; using Server.Network; using Xunit; @@ -22,42 +20,11 @@ public class NetStateSendBufferTests return ns; } - private static byte[] Pattern(int length, int seed) - { - var data = new byte[length]; - new System.Random(seed).NextBytes(data); - return data; - } - - private static unsafe void RegisterNoOpPing() - { - if (IncomingPackets.GetHandler(0x73) == null) - { - IncomingPackets.Register(0x73, 2, false, &NoOp); - } - } - - private static void NoOp(NetState state, SpanReader reader) - { - } - - private static void SliceUntil(Func done) - { - var deadline = Stopwatch.StartNew(); - while (!done() && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - Thread.Sleep(5); - } - - Assert.True(done()); - } - [SkippableFact] public void Account_InTheGameServerState_PromotesBothBuffers() { - Skip.If(NetState.InitialSendBufferSize == 0); - RegisterNoOpPing(); + NetTestHelpers.RequirePreAuthBuffers(); + using var ping = NetTestHelpers.RegisterPing(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -73,8 +40,8 @@ public class NetStateSendBufferTests // Recv promotes at the next completion ns._protocolState = NetState.ProtocolState.GameServer_LoggedIn; - client.Send(new byte[] { 0x73, 0x01 }); - SliceUntil(() => ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize); + client.Send(new byte[] { NetTestHelpers.PingPacketId, 0x01 }); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize); Assert.True(ns.Running); } finally @@ -87,7 +54,7 @@ public class NetStateSendBufferTests [SkippableFact] public void Account_OnTheLoginServer_KeepsTheInitialBuffers() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -108,11 +75,11 @@ public class NetStateSendBufferTests public void Send_BeyondTheInitialBuffer_BeforeCredentials_IsExhausted() { // Nothing promotes before credentials verify: the 4 KiB ring is the whole pre-auth budget - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { - var data = Pattern(NetState.InitialSendBufferSize + 512, 7); + var data = NetTestHelpers.Pattern(NetState.InitialSendBufferSize + 512, 7); ns.Send(data); Assert.Contains("exhausted", ns._disconnectReason, StringComparison.OrdinalIgnoreCase); @@ -123,7 +90,7 @@ public class NetStateSendBufferTests // Once it completes the NetState is disposed and _socket goes null, so nothing below // this point may touch it. client.Close(); - SliceUntil(() => !ns.Running); + NetTestHelpers.SliceUntil(() => !ns.Running); } finally { @@ -137,19 +104,19 @@ public class NetStateSendBufferTests { // The late-verdict path from Account_AssignedAfterTheStateFlipped_StillPromotes: an account // attached while the setter's own gate does not fire must still let the send path promote - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { ns.Account = new MockAccount(); // _protocolState is still AwaitingSeed: the setter does not promote - var data = Pattern(NetState.InitialSendBufferSize + 512, 7); + var data = NetTestHelpers.Pattern(NetState.InitialSendBufferSize + 512, 7); ns.Send(data); Assert.True(ns.Running); Assert.Equal(string.Empty, ns._disconnectReason); Assert.Equal(NetState.SendBufferSize, ns._socket.SendBuffer.PhysicalSize); Assert.False(ns._sendBufferGrown); // promotion is not growth: nothing to shrink later - Assert.Equal(data, ReadAll(client, data.Length)); + Assert.Equal(data, NetTestHelpers.ReadAll(client, data.Length)); } finally { @@ -161,12 +128,12 @@ public class NetStateSendBufferTests [SkippableFact] public void Send_BacklogBeforeCredentials_IsExhaustedInsteadOfPromoted() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { // No Slice() between sends: nothing drains, so a second write finds a non-empty initial buffer - var half = Pattern(NetState.InitialSendBufferSize / 2, 11); + var half = NetTestHelpers.Pattern(NetState.InitialSendBufferSize / 2, 11); ns.Send(half); ns.Send(half); ns.Send(half); // cannot fit, buffer not empty, no account: refused @@ -179,7 +146,7 @@ public class NetStateSendBufferTests // Once it completes the NetState is disposed and _socket goes null, so nothing below // this point may touch it. client.Close(); - SliceUntil(() => !ns.Running); + NetTestHelpers.SliceUntil(() => !ns.Running); } finally { @@ -191,7 +158,7 @@ public class NetStateSendBufferTests [SkippableFact] public void Account_AssignedAfterTheStateFlipped_StillPromotes() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -211,7 +178,7 @@ public class NetStateSendBufferTests { var randomLength = compressedLength * 5 / 8; var data = new byte[compressedLength * 8]; - Pattern(randomLength, seed).CopyTo(data, 0); + NetTestHelpers.Pattern(randomLength, seed).CopyTo(data, 0); var scratch = new byte[compressedLength * 2 + 16]; for (var length = randomLength; length < data.Length; length++) @@ -228,47 +195,12 @@ public class NetStateSendBufferTests throw new InvalidOperationException($"No chunk compresses to exactly {compressedLength} bytes"); } - private static byte[] ReadAll(Socket client, int length) - { - var received = new byte[length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - while (total < length && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - - // Poll takes microseconds, not milliseconds - if (client.Poll(50_000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(length, total); - return received; - } - // Peer receipt does not free the buffer; the completion must land first - private static void WaitForDrain(NetState ns) - { - var deadline = Stopwatch.StartNew(); - while (deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - - if (ns._socket.SendBuffer.ReadableBytes == 0 && ns._socket.SendBuffer.InFlightBytes == 0) - { - break; - } - - Thread.Sleep(5); - } - - Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes); - Assert.Equal(0, ns._socket.SendBuffer.InFlightBytes); - } + private static void WaitForDrain(NetState ns) => + NetTestHelpers.SliceUntil( + () => ns._socket.SendBuffer.ReadableBytes == 0 && ns._socket.SendBuffer.InFlightBytes == 0, + 10000 + ); [Fact] public void Send_GrowsInsteadOfDisconnecting_AndStreamStaysIntact() @@ -283,7 +215,7 @@ public class NetStateSendBufferTests var expected = new byte[chunk * 6]; for (var i = 0; i < 6; i++) { - var data = Pattern(chunk, i); + var data = NetTestHelpers.Pattern(chunk, i); data.CopyTo(expected, i * chunk); ns.Send(data); } @@ -293,7 +225,7 @@ public class NetStateSendBufferTests Assert.True(ns._socket.SendBuffer.PhysicalSize > baseSize); Assert.True(ns._sendBufferGrown); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -318,7 +250,7 @@ public class NetStateSendBufferTests var expectedLength = 0; for (var i = 0; i < 6; i++) { - var data = Pattern(chunkLength, i + 100); + var data = NetTestHelpers.Pattern(chunkLength, i + 100); var compressedLength = NetworkCompression.Compress(data, scratch); Assert.True(compressedLength > 0); scratch.AsSpan(0, compressedLength).CopyTo(expected.AsSpan(expectedLength)); @@ -332,7 +264,7 @@ public class NetStateSendBufferTests Assert.True(ns._sendBufferGrown); Array.Resize(ref expected, expectedLength); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -365,7 +297,7 @@ public class NetStateSendBufferTests while (ns._socket.SendBuffer.WritableBytes > 4096) { var writable = ns._socket.SendBuffer.WritableBytes; - SendAndRecord(Pattern(Math.Min(baseSize / 8, (writable - 2048) * 8 / 11), seed++)); + SendAndRecord(NetTestHelpers.Pattern(Math.Min(baseSize / 8, (writable - 2048) * 8 / 11), seed++)); } // Full; no span to compress into @@ -381,39 +313,7 @@ public class NetStateSendBufferTests Assert.Equal(baseSize * 2, ns._socket.SendBuffer.PhysicalSize); Assert.True(ns._sendBufferGrown); - Assert.Equal(expected.ToArray(), ReadAll(client, expected.Count)); - } - finally - { - ns.Dispose(); - client.Close(); - } - } - - [Fact] - public void Send_WithPartialRemainder_GrowsAndCopiesFullPayload() - { - var ns = CreateAuthenticatedNetState(out var client); - var baseSize = ns._socket.SendBuffer.PhysicalSize; - - try - { - var first = Pattern(baseSize / 2, 200); - var second = Pattern(baseSize / 2 + 1, 201); - var expected = new byte[first.Length + second.Length]; - first.CopyTo(expected, 0); - second.CopyTo(expected, first.Length); - - // Remainder too small for the second send - ns.Send(first); - ns.Send(second); - - Assert.True(ns.Running); - Assert.Equal(string.Empty, ns._disconnectReason); - Assert.True(ns._socket.SendBuffer.PhysicalSize > baseSize); - Assert.True(ns._sendBufferGrown); - - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected.ToArray(), NetTestHelpers.ReadAll(client, expected.Count)); } finally { @@ -433,8 +333,8 @@ public class NetStateSendBufferTests // A small peer buffer keeps the first send in flight through the growth client.ReceiveBufferSize = 4096; - var first = Pattern(baseSize / 2, 300); - var second = Pattern(baseSize / 2 + 1, 301); + var first = NetTestHelpers.Pattern(baseSize / 2, 300); + var second = NetTestHelpers.Pattern(baseSize / 2 + 1, 301); ns.Send(first); NetState.Slice(); // posts the first send @@ -451,7 +351,7 @@ public class NetStateSendBufferTests first.CopyTo(expected, 0); second.CopyTo(expected, first.Length); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -471,11 +371,11 @@ public class NetStateSendBufferTests var chunk = baseSize / 4; for (var i = 0; i < 6; i++) { - ns.Send(Pattern(chunk, i)); + ns.Send(NetTestHelpers.Pattern(chunk, i)); } Assert.True(ns._sendBufferGrown); - ReadAll(client, chunk * 6); + NetTestHelpers.ReadAll(client, chunk * 6); WaitForDrain(ns); var grewAt = ns._sendBufferGrewAt; @@ -503,11 +403,11 @@ public class NetStateSendBufferTests var chunk = baseSize / 4; for (var i = 0; i < 6; i++) { - ns.Send(Pattern(chunk, i + 400)); + ns.Send(NetTestHelpers.Pattern(chunk, i + 400)); } Assert.True(ns._sendBufferGrown); - ReadAll(client, chunk * 6); + NetTestHelpers.ReadAll(client, chunk * 6); WaitForDrain(ns); // The sweep shrinks; nothing here calls TryShrinkSendBuffer @@ -536,7 +436,7 @@ public class NetStateSendBufferTests var writes = NetState.MaxSendBufferSize / chunk + 1; for (var i = 0; i < writes; i++) { - ns.Send(Pattern(chunk, i)); + ns.Send(NetTestHelpers.Pattern(chunk, i)); } Assert.Contains("Send buffer exhausted", ns._disconnectReason); @@ -564,7 +464,7 @@ public class NetStateSendBufferTests for (var i = 0; i < 6; i++) { - ns.Send(Pattern(baseSize / 4, i)); + ns.Send(NetTestHelpers.Pattern(baseSize / 4, i)); } Assert.Equal(baseSize, ns._socket.SendBuffer.PhysicalSize); @@ -590,7 +490,7 @@ public class NetStateSendBufferTests NetState.Slice(); // handoff // CannotSendPackets short-circuits before any growth - ns.Send(Pattern(baseSize * 2, 500)); + ns.Send(NetTestHelpers.Pattern(baseSize * 2, 500)); Assert.Equal(baseSize, ns._socket.SendBuffer.PhysicalSize); Assert.False(ns._sendBufferGrown); @@ -628,58 +528,4 @@ public class NetStateSendBufferTests // growth off; budget untouched Assert.Equal(1L, NetState.CoerceSendBufferGrowthBudget(1, sendBufferSize, sendBufferSize)); } - - [Fact] - public void CoerceMaxBufferSlabs_ClampsToOneSlabPerConnection() - { - var maxConnections = NetState.SocketManager.MaxSockets; - - // fewer than one slab is meaningless - Assert.Equal(1, NetState.CoerceMaxBufferSlabs(0)); - Assert.Equal(1, NetState.CoerceMaxBufferSlabs(-4)); - - // more slabs than connections cannot make a slab any smaller - Assert.Equal(maxConnections, NetState.CoerceMaxBufferSlabs(maxConnections * 2)); - - Assert.Equal(128, NetState.CoerceMaxBufferSlabs(128)); - } - - [Fact] - public void CoerceInitialBufferSlabs_ClampsToTheSlabCount() - { - var slabs = NetState.BasePoolSlabCount(128); - Assert.True(slabs > 1); - - Assert.Equal(1, NetState.CoerceInitialBufferSlabs(0, slabs)); - Assert.Equal(1, NetState.CoerceInitialBufferSlabs(-1, slabs)); - - // a pool never holds more slabs than it has - Assert.Equal(slabs, NetState.CoerceInitialBufferSlabs(slabs + 1, slabs)); - - Assert.Equal(4, NetState.CoerceInitialBufferSlabs(4, slabs)); - } - - [Fact] - public void Ring_RegisteredBufferTable_MatchesTheConfiguredSlabCount() - { - var manager = NetState.SocketManager; - var maxBufferSlabs = NetState.CoerceMaxBufferSlabs(ServerConfiguration.GetSetting("network.maxBufferSlabs", NetState.DefaultMaxBufferSlabs)); - - // A table smaller than this throws at manager construction. Connections start on the - // transport minimum until the game server promotes them, so the formula must count those too. - // Mirrors what production passes: the request, not the effective size the manager may coerce - // down to (RequiredRegisteredBuffers treats a non-zero request as a pool either way). - Assert.Equal( - RingSocketManager.RequiredRegisteredBuffers( - manager.MaxSockets, - NetState.SendBufferSize, - manager.MaxSendBufferSize, - manager.SendBufferGrowthBudget, - maxBufferSlabs, - IORingBuffer.MinimumSize, - IORingBuffer.MinimumSize - ), - NetState.Ring.MaxRegisteredBuffers - ); - } } diff --git a/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs index c1bc6a800..549fa8857 100644 --- a/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs @@ -42,20 +42,6 @@ public class NetworkCompressionBoundsTests } } - [Fact] - public void StillCompressesWhenOutputIsLargeEnough() - { - var input = new byte[64]; - Array.Fill(input, (byte)'A'); - - var output = new byte[256]; - - var written = NetworkCompression.Compress(input, output); - - Assert.True(written > 0); - Assert.True(written <= output.Length); - } - [Fact] public void ReportsFailureRatherThanOverrunningATightOutput() { diff --git a/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs deleted file mode 100644 index 5a64a61fe..000000000 --- a/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Net.Sockets; -using Server.Network; -using Xunit; - -namespace Server.Tests.Network; - -[Collection("Sequential Server Tests")] -public class NetworkStatsTests -{ - private static byte[] Pattern(int length, int seed) - { - var data = new byte[length]; - new System.Random(seed).NextBytes(data); - return data; - } - - [Fact] - public void MaintainSendBuffers_RecordsTheRefusalsItResets() - { - var previous = NetState._availableMemoryBytes; - NetState ns = null; - Socket client = null; - - try - { - ns = PacketTestUtilities.CreateTestNetState(out client); - ns._protocolState = NetState.ProtocolState.GameServer_AwaitingGameServerLogin; - ns.Account = new MockAccount(); - var baseSize = ns._socket.SendBuffer.PhysicalSize; - - NetState.MaintainSendBuffers(); // drain whatever earlier tests refused - NetState._availableMemoryBytes = 1; // any working set is above 80% of one byte - - for (var i = 0; i < 6; i++) - { - ns.Send(Pattern(baseSize / 4, i)); - } - - // The sweep refreshes _availableMemoryBytes, so refusals must happen before it - NetState.MaintainSendBuffers(); - var stats = NetState.GetNetworkStats(); - - Assert.True(stats.LastSweep.Ran); - Assert.True(stats.LastSweep.CeilingRefusals >= 1); - - // Counters are per sweep, not cumulative - NetState.MaintainSendBuffers(); - Assert.Equal(0, NetState.GetNetworkStats().LastSweep.CeilingRefusals); - } - finally - { - NetState._availableMemoryBytes = previous; - ns?.Dispose(); - client?.Close(); - } - } - - [Fact] - public void GetNetworkStats_CountsAuthenticatedConnectionsSeparately() - { - var ns = PacketTestUtilities.CreateTestNetState(out var client); - - try - { - var before = NetState.GetNetworkStats(); - ns._protocolState = NetState.ProtocolState.GameServer_AwaitingGameServerLogin; - ns.Account = new MockAccount(); - var after = NetState.GetNetworkStats(); - - Assert.Equal(before.Connected, after.Connected); - Assert.Equal(before.Authenticated + 1, after.Authenticated); - Assert.True(after.Connected >= after.Authenticated); - } - finally - { - ns.Dispose(); - client.Close(); - } - } -} diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs index 6d8bd9fd9..c6a8b43a4 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.Net; -using Server.Accounting; using Server.Network; using Xunit; @@ -10,60 +8,6 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class AccountPacketTests { - private class MockedAccount : IAccount - { - public int TotalGold { get; } - public int TotalPlat { get; } - public bool DepositGold(int amount) => throw new NotImplementedException(); - - public bool DepositPlat(int amount) => throw new NotImplementedException(); - - public bool WithdrawGold(int amount) => throw new NotImplementedException(); - - public bool WithdrawPlat(int amount) => throw new NotImplementedException(); - - public long GetTotalGold() => throw new NotImplementedException(); - - public int CompareTo(IAccount other) => throw new NotImplementedException(); - - public string Username { get; } - public string Email { get; set; } - public AccessLevel AccessLevel { get; set; } - public int Length { get; } - public int Limit { get; } - public int Count { get; } - - private Dictionary _mobiles = new(); - - public Mobile this[int index] - { - get => _mobiles[index]; - set - { - _mobiles[index] = value; - if (value != null) - { - value.Account = this; - } - } - } - - public DateTime Created { get; set; } - public Serial Serial { get; } - public void Deserialize(IGenericReader reader) => throw new NotImplementedException(); - - public void Serialize(IGenericWriter writer) => throw new NotImplementedException(); - - public bool Deleted { get; } - - public void Delete() => throw new NotImplementedException(); - public bool TrySetUsername(string username) => throw new NotImplementedException(); - - public void SetPassword(string password) => throw new NotImplementedException(); - - public bool CheckPassword(string password) => throw new NotImplementedException(); - } - [Fact] public void TestChangeCharacter() { @@ -75,58 +19,38 @@ public class AccountPacketTests secondMobile.DefaultMobileInit(); secondMobile.RawName = null; - var account = new MockedAccount + var account = new MockAccount { [0] = firstMobile, [1] = null, [2] = secondMobile }; - // var account = new MockAccount(new[] { firstMobile, null, secondMobile }); - var expected = new ChangeCharacter(account).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeCharacter(account); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeCharacter(account), ns => ns.SendChangeCharacter(account)); } [Fact] public void TestClientVersionReq() { - var expected = new ClientVersionReq().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - - ns.SendClientVersionRequest(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ClientVersionReq(), ns => ns.SendClientVersionRequest()); } [Fact] public void TestDeleteResult() { - var expected = new DeleteResult(DeleteResultType.BadRequest).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCharacterDeleteResult(DeleteResultType.BadRequest); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DeleteResult(DeleteResultType.BadRequest), + ns => ns.SendCharacterDeleteResult(DeleteResultType.BadRequest) + ); } [Fact] public void TestPopupMessage() { - var expected = new PopupMessage(PMMessage.LoginSyncError).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPopupMessage(PMMessage.LoginSyncError); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new PopupMessage(PMMessage.LoginSyncError), + ns => ns.SendPopupMessage(PMMessage.LoginSyncError) + ); } [Theory, InlineData(ProtocolChanges.Version70610), InlineData(ProtocolChanges.Version6000)] @@ -136,7 +60,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = firstMobile, [1] = null, @@ -168,25 +92,13 @@ public class AccountPacketTests m.Direction = Direction.Down; m.LogoutMap = Map.Felucca; - var expected = new LoginConfirm(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLoginConfirmation(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LoginConfirm(m), ns => ns.SendLoginConfirmation(m)); } [Fact] public void TestLoginComplete() { - var expected = new LoginComplete().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLoginComplete(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LoginComplete(), ns => ns.SendLoginComplete()); } [Fact] @@ -196,7 +108,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.RawName = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -205,13 +117,7 @@ public class AccountPacketTests [4] = null }; - var expected = new CharacterListUpdate(account).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCharacterListUpdate(account); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CharacterListUpdate(account), ns => ns.SendCharacterListUpdate(account)); } [Fact] @@ -221,7 +127,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -235,17 +141,16 @@ public class AccountPacketTests new CityInfo("Test City", "Test Building", 50, 100, 10, -10) }; - var expected = new CharacterList(account, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.CityInfo = info; - ns.Account = account; - ns.ProtocolChanges = ProtocolChanges.Version70130; - - ns.SendCharacterList(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new CharacterList(account, info), + ns => + { + ns.CityInfo = info; + ns.Account = account; + ns.SendCharacterList(); + }, + ProtocolChanges.Version70130 + ); } [Fact] @@ -255,7 +160,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -269,29 +174,23 @@ public class AccountPacketTests new CityInfo("Test City", "Test Building", 50, 100, 10, -10) }; - var expected = new CharacterListOld(account, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.CityInfo = info; - ns.Account = account; - - ns.SendCharacterList(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new CharacterListOld(account, info), + ns => + { + ns.CityInfo = info; + ns.Account = account; + ns.SendCharacterList(); + } + ); } [Fact] public void TestAccountLoginRej() { var reason = ALRReason.BadComm; - var expected = new AccountLoginRej(reason).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendAccountLoginRejected(reason); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new AccountLoginRej(reason), ns => ns.SendAccountLoginRejected(reason)); } [Fact] @@ -302,15 +201,14 @@ public class AccountPacketTests new ServerInfo("Test Server", 0, TimeZoneInfo.Local, IPEndPoint.Parse("127.0.0.1")) }; - var expected = new AccountLoginAck(info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ServerInfo = info; - - ns.SendAccountLoginAck(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new AccountLoginAck(info), + ns => + { + ns.ServerInfo = info; + ns.SendAccountLoginAck(); + } + ); } [Fact] @@ -319,13 +217,6 @@ public class AccountPacketTests var si = new ServerInfo("Test Server", 0, TimeZoneInfo.Local, IPEndPoint.Parse("127.0.0.1")); const int authId = 0x123456; - var expected = new PlayServerAck(si, authId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - - ns.SendPlayServerAck(si, authId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlayServerAck(si, authId), ns => ns.SendPlayServerAck(si, authId)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs index a885cae9e..de2401a0c 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs @@ -12,25 +12,13 @@ public class CombatPacketTests var attacker = (Serial)0x1024; var defender = (Serial)0x2048; - var expected = new Swing(attacker, defender).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSwing(attacker, defender); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Swing(attacker, defender), ns => ns.SendSwing(attacker, defender)); } [Theory, InlineData(true), InlineData(false)] public void TestSetWarMode(bool warmode) { - var expected = new SetWarMode(warmode).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSetWarMode(warmode); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SetWarMode(warmode), ns => ns.SendSetWarMode(warmode)); } [Fact] @@ -38,12 +26,6 @@ public class CombatPacketTests { var serial = (Serial)0x1024; - var expected = new ChangeCombatant(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeCombatant(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeCombatant(serial), ns => ns.SendChangeCombatant(serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs index 00dfc4054..98229c6fe 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs @@ -14,13 +14,7 @@ public class ContainerPacketTests var serial = (Serial)0x1024; ushort gumpId = 100; - var expected = new ContainerDisplay(serial, gumpId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayContainer(serial, gumpId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerDisplay(serial, gumpId), ns => ns.SendDisplayContainer(serial, gumpId)); } [Fact] @@ -29,14 +23,11 @@ public class ContainerPacketTests var serial = (Serial)0x1024; ushort gumpId = 100; - var expected = new ContainerDisplayHS(serial, gumpId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas; - ns.SendDisplayContainer(serial, gumpId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new ContainerDisplayHS(serial, gumpId), + ns => ns.SendDisplayContainer(serial, gumpId), + ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas + ); } [Fact] @@ -44,13 +35,7 @@ public class ContainerPacketTests { var serial = (Serial)0x1024; - var expected = new DisplaySpellbook(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySpellbook(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplaySpellbook(serial), ns => ns.SendDisplaySpellbook(serial)); } [Fact] @@ -58,14 +43,11 @@ public class ContainerPacketTests { var serial = (Serial)0x1024; - var expected = new DisplaySpellbookHS(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas; - ns.SendDisplaySpellbook(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySpellbookHS(serial), + ns => ns.SendDisplaySpellbook(serial), + ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas + ); } [Fact] @@ -76,17 +58,20 @@ public class ContainerPacketTests ushort offset = 10; ulong content = 0x123456789ABCDEF0; var opl = ObjectPropertyList.Enabled; - - var expected = new NewSpellbookContent(serial, graphic, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.NewSpellbook; ObjectPropertyList.Enabled = true; - ns.SendSpellbookContent(serial, graphic, offset, content); - ObjectPropertyList.Enabled = opl; - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + try + { + PacketAssert.Sends( + new NewSpellbookContent(serial, graphic, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content), + ProtocolChanges.ContainerGridLines | ProtocolChanges.NewSpellbook + ); + } + finally + { + ObjectPropertyList.Enabled = opl; + } } [Fact] @@ -97,13 +82,10 @@ public class ContainerPacketTests ushort graphic = 100; ulong content = 0x123456789ABCDEF0; - var expected = new SpellbookContent(serial, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSpellbookContent(serial, graphic, offset, content); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpellbookContent(serial, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content) + ); } [Fact] @@ -114,14 +96,11 @@ public class ContainerPacketTests ushort graphic = 100; ulong content = 0x123456789ABCDEF0; - var expected = new SpellbookContent6017(serial, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendSpellbookContent(serial, graphic, offset, content); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpellbookContent6017(serial, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content), + ProtocolChanges.ContainerGridLines + ); } [Fact] @@ -130,13 +109,7 @@ public class ContainerPacketTests var serial = (Serial)0x1024; var item = new Item(serial); - var expected = new ContainerContentUpdate(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContentUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerContentUpdate(item), ns => ns.SendContainerContentUpdate(item)); } [Fact] @@ -145,66 +118,17 @@ public class ContainerPacketTests var serial = (Serial)0x1024; var item = new Item(serial); - var expected = new ContainerContentUpdate6017(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendContainerContentUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new ContainerContentUpdate6017(item), + ns => ns.SendContainerContentUpdate(item), + ProtocolChanges.ContainerGridLines + ); } - [Fact] - public void TestContainerContent() + // An administrator beholder on the container's map, so every item is visible and sent. + private static (Container Container, Mobile Beholder) CreateViewedContainer(int itemCount) { var cont = new Container(World.NewItem); - cont.AddItem(new Item(World.NewItem)); - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - var expected = new ContainerContent(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } - - [Fact] - public void TestContainerContent6017() - { - var cont = new Container(World.NewItem); - cont.AddItem(new Item(World.NewItem)); - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - var expected = new ContainerContent6017(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } - - [Fact] - public void TestContainerContent_CapsAtProtocolLimit() - { - var cont = new Container(World.NewItem); - - // Past the 3448-entry cap (19-byte entries) so truncation engages. - const int itemCount = 5000; for (var i = 0; i < itemCount; i++) { cont.AddItem(new Item(World.NewItem)); @@ -217,9 +141,62 @@ public class ContainerPacketTests m.AccessLevel = AccessLevel.Administrator; m.Map = Map.Felucca; + return (cont, m); + } + + [Fact] + public void TestContainerContent() + { + var (cont, m) = CreateViewedContainer(1); + + try + { + PacketAssert.Sends(new ContainerContent(m, cont), ns => ns.SendContainerContent(m, cont)); + } + finally + { + cont.Delete(); + m.Delete(); + } + } + + [Fact] + public void TestContainerContent6017() + { + var (cont, m) = CreateViewedContainer(1); + + try + { + PacketAssert.Sends( + new ContainerContent6017(m, cont), + ns => ns.SendContainerContent(m, cont), + ProtocolChanges.ContainerGridLines + ); + } + finally + { + cont.Delete(); + m.Delete(); + } + } + + // 0x3C's length and count fields are 16-bit: 19-byte entries cap at 3448, 20-byte grid-line entries at 3276. + [Theory] + [InlineData(false, 3448, 65517)] + [InlineData(true, 3276, 65525)] + public void TestContainerContent_CapsAtProtocolLimit(bool gridLines, int maxEntries, int maxLength) + { + // One item past the cap is enough to make truncation engage. + var (cont, m) = CreateViewedContainer(maxEntries + 1); + try { using var ns = PacketTestUtilities.CreateTestNetState(); + if (gridLines) + { + ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; + } + // A ~64KB packet exceeds the 4KB pre-auth send buffer; an account lets Send() promote it // on demand instead of exhausting the buffer and disconnecting. ns.Account = new MockAccount(); @@ -233,57 +210,9 @@ public class ContainerPacketTests var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]); var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]); - const int entrySize = 19; // no grid lines negotiated on this NetState - var expectedMaxEntries = (ushort.MaxValue - 5) / entrySize; - - Assert.Equal(65517, length); + Assert.Equal(maxLength, length); Assert.Equal(length, span.Length); - Assert.Equal(expectedMaxEntries, count); - } - finally - { - cont.Delete(); - m.Delete(); - } - } - - [Fact] - public void TestContainerContent_CapsAtProtocolLimit_WithGridLines() - { - var cont = new Container(World.NewItem); - - // Past the 3276-entry cap (20-byte entries with grid lines) so truncation engages. - const int itemCount = 5000; - for (var i = 0; i < itemCount; i++) - { - cont.AddItem(new Item(World.NewItem)); - } - - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - try - { - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.Account = new MockAccount(); - - var ex = Record.Exception(() => ns.SendContainerContent(m, cont)); - Assert.Null(ex); - - var span = ns.SendBuffer.GetReadSpan(); - Assert.Equal((byte)0x3C, span[0]); - - var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]); - var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]); - - Assert.Equal(65525, length); - Assert.Equal(length, span.Length); - Assert.Equal(3276, count); + Assert.Equal(maxEntries, count); } finally { @@ -295,32 +224,13 @@ public class ContainerPacketTests [Fact] public void TestContainerContent_RentedBuffer_MatchesReferencePacket() { - var cont = new Container(World.NewItem); - // Below the entry cap but past the 1024-byte stackalloc threshold, so SendContainerContent // rents its buffer instead of using the stack. - const int itemCount = 100; - for (var i = 0; i < itemCount; i++) - { - cont.AddItem(new Item(World.NewItem)); - } - - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; + var (cont, m) = CreateViewedContainer(100); try { - var expected = new ContainerContent(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerContent(m, cont), ns => ns.SendContainerContent(m, cont)); } finally { diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs index 5f31a49d4..bda7ad009 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs @@ -11,13 +11,7 @@ public class DamagePacketTests { var serial = (Serial)0x1024; - var expected = new DamagePacketOld(serial, inputAmount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDamage(serial, inputAmount); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DamagePacketOld(serial, inputAmount), ns => ns.SendDamage(serial, inputAmount)); } [Theory, InlineData(10), InlineData(-5), InlineData(1024), InlineData(100000)] @@ -25,14 +19,10 @@ public class DamagePacketTests { var serial = (Serial)0x1024; - var expected = new DamagePacket(serial, inputAmount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.DamagePacket; - - ns.SendDamage(serial, inputAmount); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DamagePacket(serial, inputAmount), + ns => ns.SendDamage(serial, inputAmount), + ProtocolChanges.DamagePacket + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs index 7ade294f6..e9395948d 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs @@ -7,18 +7,13 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class EffectPackets { - [Theory, InlineData(10, 1000, 10, 5)] - public void TestSoundEffect(ushort soundID, int x, int y, int z) + [Fact] + public void TestSoundEffect() { - var p = new Point3D(x, y, z); + const ushort soundID = 10; + var p = new Point3D(1000, 10, 5); - var expected = new PlaySound(soundID, p).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSoundEffect(soundID, p); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlaySound(soundID, p), ns => ns.SendSoundEffect(soundID, p)); } [Fact] @@ -94,13 +89,7 @@ public class EffectPackets [Theory, InlineData(ScreenEffectType.DarkFlash), InlineData(ScreenEffectType.FadeInOut)] public void TestScreenEffect(ScreenEffectType screenType) { - var expected = new ScreenEffect(screenType).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendScreenEffect(screenType); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ScreenEffect(screenType), ns => ns.SendScreenEffect(screenType)); } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs index e18256383..2899569f8 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs @@ -76,157 +76,6 @@ public class HuedEffect : Packet } } -public sealed class TargetParticleEffect : ParticleEffect -{ - public TargetParticleEffect( - IEntity e, int itemID, int speed, int duration, int hue, int renderMode, int effect, - int layer, int unknown - ) : base( - EffectType.FixedFrom, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode, - effect, - 1, - 0, - e.Serial, - layer, - unknown - ) - { - } -} - -public sealed class TargetEffect : HuedEffect -{ - public TargetEffect(IEntity e, int itemID, int speed, int duration, int hue, int renderMode) : base( - EffectType.FixedFrom, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode - ) - { - } -} - -public sealed class LocationParticleEffect : ParticleEffect -{ - public LocationParticleEffect( - IEntity e, int itemID, int speed, int duration, int hue, int renderMode, int effect, - int unknown - ) : base( - EffectType.FixedXYZ, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode, - effect, - 1, - 0, - e.Serial, - 255, - unknown - ) - { - } -} - -public sealed class LocationEffect : HuedEffect -{ - public LocationEffect(IPoint3D p, int itemID, int speed, int duration, int hue, int renderMode) : base( - EffectType.FixedXYZ, - Serial.Zero, - Serial.Zero, - itemID, - p, - p, - speed, - duration, - true, - false, - hue, - renderMode - ) - { - } -} - -public sealed class MovingParticleEffect : ParticleEffect -{ - public MovingParticleEffect( - IEntity from, IEntity to, int itemID, int speed, int duration, bool fixedDirection, - bool explodes, int hue, int renderMode, int effect, int explodeEffect, int explodeSound, EffectLayer layer, - int unknown - ) : base( - EffectType.Moving, - from.Serial, - to.Serial, - itemID, - from.Location, - to.Location, - speed, - duration, - fixedDirection, - explodes, - hue, - renderMode, - effect, - explodeEffect, - explodeSound, - Serial.Zero, - (int)layer, - unknown - ) - { - } -} - -public sealed class MovingEffect : HuedEffect -{ - public MovingEffect( - IEntity from, IEntity to, int itemID, int speed, int duration, bool fixedDirection, - bool explodes, int hue, int renderMode - ) : base( - EffectType.Moving, - from.Serial, - to.Serial, - itemID, - from.Location, - to.Location, - speed, - duration, - fixedDirection, - explodes, - hue, - renderMode - ) - { - } -} - public class ScreenEffect : Packet { public ScreenEffect(ScreenEffectType type) diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs index 8dc535e03..21933bc91 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs @@ -31,19 +31,16 @@ public class EquipmentPacketTests } ); - var expected = new DisplayEquipmentInfo(item, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayEquipmentInfo( - item.Serial, - info.Number, - info.Crafter?.RawName, - info.Unidentified, - new List(info.Attributes) + PacketAssert.Sends( + new DisplayEquipmentInfo(item, info), + ns => ns.SendDisplayEquipmentInfo( + item.Serial, + info.Number, + info.Crafter?.RawName, + info.Unidentified, + new List(info.Attributes) + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -54,12 +51,6 @@ public class EquipmentPacketTests var item = new Item(World.NewItem) { Parent = m }; - var expected = new EquipUpdate(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEquipUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EquipUpdate(item), ns => ns.SendEquipUpdate(item)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs index edbe6b668..febac5288 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs @@ -6,17 +6,13 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class GumpPacketTests { - [Theory] - [InlineData(100, 10)] - public void TestCloseGump(int typeId, int buttonId) + [Fact] + public void TestCloseGump() { - var expected = new CloseGump(typeId, buttonId).Compile(); + const int typeId = 100; + const int buttonId = 10; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCloseGump(typeId, buttonId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CloseGump(typeId, buttonId), ns => ns.SendCloseGump(typeId, buttonId)); } [Fact] @@ -27,13 +23,10 @@ public class GumpPacketTests const string unknownString = "This is an unknown string"; const string caption = "This is a caption"; - var expected = new DisplaySignGump(gumpSerial, gumpId, unknownString, caption).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySignGump(gumpSerial, gumpId, unknownString, caption); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySignGump(gumpSerial, gumpId, unknownString, caption), + ns => ns.SendDisplaySignGump(gumpSerial, gumpId, unknownString, caption) + ); } [Fact] @@ -41,13 +34,7 @@ public class GumpPacketTests { var gump = new NameChangeDeedGump(); - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = gump.Compile(ns).Compile(); - ns.SendGump(gump); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => gump.Compile(ns), ns => ns.SendGump(gump)); } [Fact] @@ -60,13 +47,7 @@ public class GumpPacketTests var gump = new AdminGump(m, AdminGumpPage.Clients); - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = gump.Compile(ns).Compile(); - ns.SendGump(gump); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => gump.Compile(ns), ns => ns.SendGump(gump)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs index 27bad8aaf..d2736a723 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs @@ -1,116 +1,75 @@ +using System; using Server.Network; using Xunit; namespace Server.Tests.Network; [Collection("Sequential Server Tests")] -public class ItemPacketTests +public class ItemPacketTests : IDisposable { + private const int TestItemId = 1; + + // TileData is global, so each test restores it. + private readonly ItemData _originalItemData; + + public ItemPacketTests() + { + _originalItemData = TileData.ItemTable[TestItemId]; + TileData.ItemTable[TestItemId] = new ItemData("Test Item Data", TileFlag.Generic, 1, 1, 1, 1, 1, 1); + } + + public void Dispose() => TileData.ItemTable[TestItemId] = _originalItemData; + [Fact] public void TestWorldItemPacket() { var serial = (Serial)0x1024; - var itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10), Direction = Direction.Left }; - var expected = new WorldItem(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new WorldItem(item), ns => ns.SendWorldItem(item)); } [Fact] public void TestWorldItemSAPacket() { var serial = (Serial)0x1024; - ushort itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10) }; - var expected = new WorldItemSA(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.StygianAbyss; - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new WorldItemSA(item), ns => ns.SendWorldItem(item), ProtocolChanges.StygianAbyss); } [Fact] public void TestWorldItemHSPacket() { var serial = (Serial)0x1024; - var itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10) }; - var expected = new WorldItemHS(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.StygianAbyss | ProtocolChanges.HighSeas; - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new WorldItemHS(item), + ns => ns.SendWorldItem(item), + ProtocolChanges.StygianAbyss | ProtocolChanges.HighSeas + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs index 084ba4587..90bd89dfb 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs @@ -187,21 +187,3 @@ public sealed class WorldItemHS : Packet Stream.Write((short)0x00); // ?? } } - -public sealed class OPLInfo : Packet -{ - /*public OPLInfo( ObjectPropertyList list ) : base( 0xBF ) - { - EnsureCapacity( 13 ); - - m_Stream.Write( (short) 0x10 ); - m_Stream.Write( (int) list.Entity.Serial ); - m_Stream.Write( (int) list.Hash ); - }*/ - - public OPLInfo(Serial serial, int hash) : base(0xDC, 9) - { - Stream.Write(serial); - Stream.Write(hash); - } -} \ No newline at end of file diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs index c66a61ca7..22ed4c423 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs @@ -10,13 +10,8 @@ public class LightPacketTests public void TestGlobalLightLevel() { const byte lightLevel = 5; - var expected = new GlobalLightLevel(lightLevel).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendGlobalLightLevel(lightLevel); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new GlobalLightLevel(lightLevel), ns => ns.SendGlobalLightLevel(lightLevel)); } [Fact] @@ -24,12 +19,10 @@ public class LightPacketTests { var serial = (Serial)0x1024; byte lightLevel = 5; - var expected = new PersonalLightLevel(serial, lightLevel).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPersonalLightLevel(serial, lightLevel); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new PersonalLightLevel(serial, lightLevel), + ns => ns.SendPersonalLightLevel(serial, lightLevel) + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs index a438730e0..d563db252 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs @@ -9,26 +9,13 @@ public class MapPatchesTests [Fact] public void TestMapPatches() { - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = new MapPatches().Compile(); - - ns.SendMapPatches(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MapPatches(), ns => ns.SendMapPatches()); } [Fact] public void TestInvalidMapEnable() { - var expected = new InvalidMapEnable().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendInvalidMap(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new InvalidMapEnable(), ns => ns.SendInvalidMap()); } [Theory] @@ -37,12 +24,7 @@ public class MapPatchesTests public void TestMapChange(string mapName) { var map = Map.Parse(mapName); - var expected = new MapChange(map).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMapChange(map); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MapChange(map), ns => ns.SendMapChange(map)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs index 3da55c89e..c87355ac8 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs @@ -42,13 +42,7 @@ public class MenuPacketTests ] ); - var expected = new DisplayItemListMenu(menu).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayItemListMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplayItemListMenu(menu), ns => ns.SendDisplayItemListMenu(menu)); } [Fact] @@ -63,12 +57,7 @@ public class MenuPacketTests ] ); - var expected = new DisplayQuestionMenu(menu).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayQuestionMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplayQuestionMenu(menu), ns => ns.SendDisplayQuestionMenu(menu)); } [Theory] @@ -84,18 +73,12 @@ public class MenuPacketTests var item = new ContextMenuItem(World.NewItem, newPacket); var menu = ContextMenuSystem.CreateContextMenu(m, item); - var packet = newHaven && newPacket ? (Packet)new DisplayContextMenu(menu) : new DisplayContextMenuOld(menu); - var expected = packet.Compile(); + var expected = newHaven && newPacket ? (Packet)new DisplayContextMenu(menu) : new DisplayContextMenuOld(menu); - using var ns = PacketTestUtilities.CreateTestNetState(); - if (newHaven) - { - ns.ProtocolChanges |= ProtocolChanges.NewHaven; - } - - ns.SendDisplayContextMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + expected, + ns => ns.SendDisplayContextMenu(menu), + newHaven ? ProtocolChanges.NewHaven : ProtocolChanges.None + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs index b5eb423f1..c1911db57 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs @@ -19,31 +19,10 @@ public class MessageTests var name = "Stuff"; var args = "Arguments"; - var expected = new MessageLocalized( - serial, - graphic, - messageType, - hue, - font, - number, - name, - args - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalized( - serial, - graphic, - messageType, - hue, - font, - number, - name, - args + PacketAssert.Sends( + new MessageLocalized(serial, graphic, messageType, hue, font, number, name, args), + ns => ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, args) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -58,15 +37,10 @@ public class MessageTests var name = "Stuff"; var argValue = 42; - var expected = new MessageLocalized( - serial, graphic, messageType, hue, font, number, name, $"value: {argValue}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}"); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new MessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}"), + ns => ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}") + ); } [Fact] @@ -83,35 +57,12 @@ public class MessageTests var affixType = AffixType.System; var affix = "Affix"; - var expected = new MessageLocalizedAffix( - serial, - graphic, - messageType, - hue, - font, - number, - name, - affixType, - affix, - args - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalizedAffix( - serial, - graphic, - messageType, - hue, - font, - number, - name, - affixType, - affix, - args + PacketAssert.Sends( + new MessageLocalizedAffix(serial, graphic, messageType, hue, font, number, name, affixType, affix, args), + ns => ns.SendMessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, args + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -128,18 +79,14 @@ public class MessageTests var affix = "Affix"; var argValue = 42; - var expected = new MessageLocalizedAffix( - serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalizedAffix( - serial, graphic, messageType, hue, font, number, name, affixType, affix, - $"value: {argValue}" + PacketAssert.Sends( + new MessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" + ), + ns => ns.SendMessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -154,17 +101,10 @@ public class MessageTests var name = "Stuff"; var who = "Alice"; - var expected = new UnicodeMessage( - serial, graphic, messageType, hue, font, lang, name, $"hello, {who}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, graphic, messageType, hue, font, false, lang, name, $"hello, {who}" + PacketAssert.Sends( + new UnicodeMessage(serial, graphic, messageType, hue, font, lang, name, $"hello, {who}"), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, false, lang, name, $"hello, {who}") ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -178,31 +118,10 @@ public class MessageTests var name = "Stuff"; var text = "Some Text"; - var expected = new AsciiMessage( - serial, - graphic, - messageType, - hue, - font, - name, - text - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, - graphic, - messageType, - hue, - font, - true, - null, - name, - text + PacketAssert.Sends( + new AsciiMessage(serial, graphic, messageType, hue, font, name, text), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, true, null, name, text) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -217,32 +136,10 @@ public class MessageTests var name = "Stuff"; var text = "Some Text"; - var expected = new UnicodeMessage( - serial, - graphic, - messageType, - hue, - font, - lang, - name, - text - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, - graphic, - messageType, - hue, - font, - false, - lang, - name, - text + PacketAssert.Sends( + new UnicodeMessage(serial, graphic, messageType, hue, font, lang, name, text), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, false, lang, name, text) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -251,13 +148,7 @@ public class MessageTests var serial = (Serial)0x1024; var serial2 = (Serial)0x2; - var expected = new FollowMessage(serial, serial2).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendFollowMessage(serial, serial2); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new FollowMessage(serial, serial2), ns => ns.SendFollowMessage(serial, serial2)); } [Fact] @@ -266,33 +157,18 @@ public class MessageTests var s = (Serial)0x100; var text = "This is some testing text"; - var expected = new ObjectHelpResponse(s, text).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendHelpResponse(s, text); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ObjectHelpResponse(s, text), ns => ns.SendHelpResponse(s, text)); } - internal class TestPrompt : Prompt + [Fact] + public void TestUnicodePrompt() { + var prompt = new TestPrompt(); + + PacketAssert.Sends(new UnicodePrompt(prompt), ns => ns.SendPrompt(prompt)); } - [Collection("Sequential Server Tests")] - public class UnicodePromptTests + private class TestPrompt : Prompt { - [Fact] - public void TestUnicodePrompt() - { - var prompt = new TestPrompt(); - var expected = new UnicodePrompt(prompt).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPrompt(prompt); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs index d7d3beca2..3d839899b 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs @@ -6,19 +6,38 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class MobilePacketTests { + private static Mobile CreateMobileWithStats(uint serial = 0x1, string name = null) + { + var m = new Mobile((Serial)serial) { Name = name }; + m.DefaultMobileInit(); + m.Str = 50; + m.Hits = 100; + m.Int = 75; + m.Mana = 100; + m.Dex = 25; + m.Stam = 100; + + return m; + } + + private sealed class TestPoison : Poison + { + public TestPoison(int level) : base(level) => Level = level; + + public override string Name => $"Test Poison {Level}"; + public override int Level { get; } + public override PoisonFamily Family => PoisonFamily.Standard; + + public override Timer ConstructTimer(Mobile m) => null; + } + [Fact] public void TestDeathAnimation() { var killed = (Serial)0x1; var corpse = (Serial)0x1000; - var expected = new DeathAnimation(killed, corpse).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDeathAnimation(killed, corpse); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DeathAnimation(killed, corpse), ns => ns.SendDeathAnimation(killed, corpse)); } [Fact] @@ -27,13 +46,7 @@ public class MobilePacketTests var petSerial = (Serial)0x1; const bool bonded = true; - var expected = new BondedStatus(petSerial, bonded).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendBondedStatus(petSerial, bonded); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new BondedStatus(petSerial, bonded), ns => ns.SendBondedStatus(petSerial, bonded)); } [Theory] @@ -46,14 +59,11 @@ public class MobilePacketTests var noto = 10; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - var expected = new MobileMoving(m, noto, ns.StygianAbyss).Compile(); - - ns.SendMobileMoving(m, noto); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileMoving(m, noto, ns.StygianAbyss), + ns => ns.SendMobileMoving(m, noto), + protocolChanges + ); } [Theory] @@ -65,13 +75,7 @@ public class MobilePacketTests var m = new Mobile((Serial)0x1) { Name = name }; m.DefaultMobileInit(); - var expected = new MobileName(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileName(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileName(m), ns => ns.SendMobileName(m)); } [Theory] @@ -81,29 +85,10 @@ public class MobilePacketTests { var mobile = (Serial)0x1; - var expected = new MobileAnimation( - mobile, - action, - frameCount, - repeatCount, - !reverse, - repeat, - delay - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAnimation( - mobile, - action, - frameCount, - repeatCount, - !reverse, - repeat, - delay + PacketAssert.Sends( + new MobileAnimation(mobile, action, frameCount, repeatCount, !reverse, repeat, delay), + ns => ns.SendMobileAnimation(mobile, action, frameCount, repeatCount, !reverse, repeat, delay) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Theory] @@ -113,43 +98,24 @@ public class MobilePacketTests { var mobile = (Serial)0x1; - var expected = new NewMobileAnimation( - mobile, - action, - frameCount, - delay - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendNewMobileAnimation( - mobile, - action, - frameCount, - delay + PacketAssert.Sends( + new NewMobileAnimation(mobile, action, frameCount, delay), + ns => ns.SendNewMobileAnimation(mobile, action, frameCount, delay) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } + // Level -1 means not poisoned. Constructed directly: the poison registry is filled by UOContent. [Theory] - [InlineData("None")] - [InlineData("Lesser")] - [InlineData("Lethal")] - public void TestHealthbarPoison(string pName) + [InlineData(-1)] + [InlineData(0)] + [InlineData(4)] + public void TestHealthbarPoison(int level) { - var p = Poison.GetPoison(pName); var m = new Mobile((Serial)0x1); m.DefaultMobileInit(); - m.Poison = p; + m.Poison = level < 0 ? null : new TestPoison(level); - var expected = new HealthbarPoison(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHealthbar(m, Healthbar.Poison); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new HealthbarPoison(m), ns => ns.SendMobileHealthbar(m, Healthbar.Poison)); } [Theory] @@ -164,13 +130,7 @@ public class MobilePacketTests m.Blessed = isBlessed; m.YellowHealthbar = isYellowHealth; - var expected = new HealthbarYellow(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHealthbar(m, Healthbar.Yellow); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new HealthbarYellow(m), ns => ns.SendMobileHealthbar(m, Healthbar.Yellow)); } [Theory] @@ -178,22 +138,9 @@ public class MobilePacketTests [InlineData(false)] public void TestMobileStatusCompact(bool canBeRenamed) { - var m = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(name: "Random Mobile 1"); - var expected = new MobileStatusCompact(canBeRenamed, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStatusCompact(m, canBeRenamed); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStatusCompact(canBeRenamed, m), ns => ns.SendMobileStatusCompact(m, canBeRenamed)); } [Theory] @@ -201,32 +148,14 @@ public class MobilePacketTests [InlineData(ProtocolChanges.Version502b)] public void TestMobileStatus(ProtocolChanges changes) { - var beholder = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - beholder.DefaultMobileInit(); - beholder.Str = 50; - beholder.Hits = 100; - beholder.Int = 75; - beholder.Mana = 100; - beholder.Dex = 25; - beholder.Stam = 100; + var beholder = CreateMobileWithStats(0x1, "Random Mobile 1"); + var beheld = CreateMobileWithStats(0x2, "Random Mobile 2"); - var beheld = new Mobile((Serial)0x2) { Name = "Random Mobile 2" }; - beheld.DefaultMobileInit(); - beheld.Str = 50; - beheld.Hits = 100; - beheld.Int = 75; - beheld.Mana = 100; - beheld.Dex = 25; - beheld.Stam = 100; - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileStatus(beholder, beheld, ns).Compile(); - ns.SendMobileStatus(beholder, beheld); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileStatus(beholder, beheld, ns), + ns => ns.SendMobileStatus(beholder, beheld), + changes + ); } [Theory] @@ -240,33 +169,32 @@ public class MobilePacketTests int mobileStatusVersion ) { - var expansionInfo = ExpansionInfo.CoreExpansion; var oldExpansion = Core.Expansion; + var expansionInfo = ExpansionInfo.GetInfo(expansion); var oldVersion = expansionInfo.MobileStatusVersion; - Core.Expansion = expansion; - ExpansionInfo.CoreExpansion.MobileStatusVersion = mobileStatusVersion; - var m = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + try + { + Core.Expansion = expansion; + expansionInfo.MobileStatusVersion = mobileStatusVersion; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - ns.Version = new ClientVersion(version); - ns.Flags = clientFlags; + var m = CreateMobileWithStats(name: "Random Mobile 1"); - var expected = new MobileStatusExtended(m, ns).Compile(); - ns.SendMobileStatus(m, m); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.ProtocolChanges = changes; + ns.Version = new ClientVersion(version); + ns.Flags = clientFlags; - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - Core.Expansion = oldExpansion; - expansionInfo.MobileStatusVersion = oldVersion; + var expected = new MobileStatusExtended(m, ns).Compile(); + ns.SendMobileStatus(m, m); + + AssertThat.Equal(ns.SendBuffer.GetReadSpan(), expected); + } + finally + { + Core.Expansion = oldExpansion; + expansionInfo.MobileStatusVersion = oldVersion; + } } [Theory] @@ -278,14 +206,7 @@ public class MobilePacketTests m.DefaultMobileInit(); m.SolidHueOverride = solidHueOverride; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileUpdate(m, ns.StygianAbyss).Compile(); - ns.SendMobileUpdate(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => new MobileUpdate(m, ns.StygianAbyss), ns => ns.SendMobileUpdate(m), changes); } [Theory] @@ -333,170 +254,82 @@ public class MobilePacketTests beheld.FacialHairItemID = facialHairItemId; beheld.FacialHairHue = facialHairHue; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileIncoming(ns, beholder, beheld).Compile(); - ns.SendMobileIncoming(beholder, beheld); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileIncoming(ns, beholder, beheld), + ns => ns.SendMobileIncoming(beholder, beheld), + changes + ); } [Fact] public void TestMobileHits() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 100; - m.Hits = 100; + var m = CreateMobileWithStats(); - var expected = new MobileHits(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHits(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileHits(m), ns => ns.SendMobileHits(m)); } [Fact] public void TestMobileHitsN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 100; - m.Hits = 100; + var m = CreateMobileWithStats(); - var expected = new MobileHitsN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHits(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileHitsN(m), ns => ns.SendMobileHits(m, true)); } [Fact] public void TestMobileMana() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Int = 75; - m.Mana = 100; + var m = CreateMobileWithStats(); - var expected = new MobileMana(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileMana(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileMana(m), ns => ns.SendMobileMana(m)); } [Fact] public void TestMobileManaN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Int = 75; - m.Mana = 100; + var m = CreateMobileWithStats(); - var expected = new MobileManaN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileMana(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileManaN(m), ns => ns.SendMobileMana(m, true)); } [Fact] public void TestMobileStam() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Dex = 75; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileStam(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStam(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStam(m), ns => ns.SendMobileStam(m)); } [Fact] public void TestMobileStamN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Dex = 75; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileStamN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStam(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStamN(m), ns => ns.SendMobileStam(m, true)); } [Fact] public void TestMobileAttributes() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileAttributes(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAttributes(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileAttributes(m), ns => ns.SendMobileAttributes(m)); } [Fact] public void TestMobileAttributesN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileAttributesN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAttributes(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileAttributesN(m), ns => ns.SendMobileAttributes(m, true)); } [Fact] public void TestRemoveEntity() { var e = (Serial)0x1000; - var expected = new RemoveEntity(e).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendRemoveEntity(e); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new RemoveEntity(e), ns => ns.SendRemoveEntity(e)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs index 330eaf0b5..5f8a27206 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs @@ -12,26 +12,18 @@ public class MovementPacketTests [InlineData(2)] public void TestSpeedControl(byte speedControl) { - var expected = new SpeedControl(speedControl).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSpeedControl((SpeedControlSetting)speedControl); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpeedControl(speedControl), + ns => ns.SendSpeedControl((SpeedControlSetting)speedControl) + ); } [Fact] public void TestMovePlayer() { const Direction d = Direction.Left; - var expected = new MovePlayer(d).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovePlayer(d); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MovePlayer(d), ns => ns.SendMovePlayer(d)); } [Fact] @@ -42,13 +34,7 @@ public class MovementPacketTests const byte seq = 100; - var expected = new MovementRej(seq, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovementRej(seq, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MovementRej(seq, m), ns => ns.SendMovementRej(seq, m)); } [Fact] @@ -59,12 +45,6 @@ public class MovementPacketTests const byte seq = 100; - var expected = MovementAck.Instantiate(seq, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovementAck(seq, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(MovementAck.Instantiate(seq, m), ns => ns.SendMovementAck(seq, m)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs index 6d6e5daa3..8bfe17a9e 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs @@ -8,23 +8,16 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class PlayerPacketTests { - [Theory] - [InlineData(StatLockType.Down, StatLockType.Up, StatLockType.Locked)] - public void TestStatLockInfo(StatLockType str, StatLockType intel, StatLockType dex) + [Fact] + public void TestStatLockInfo() { var m = new Mobile((Serial)0x1); m.DefaultMobileInit(); - m.StrLock = str; - m.IntLock = intel; - m.DexLock = dex; + m.StrLock = StatLockType.Down; + m.IntLock = StatLockType.Up; + m.DexLock = StatLockType.Locked; - var expected = new StatLockInfo(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendStatLockInfo(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new StatLockInfo(m), ns => ns.SendStatLockInfo(m)); } [Theory] @@ -33,25 +26,13 @@ public class PlayerPacketTests [InlineData(200)] public void TestChangeUpdateRange(int range) { - var expected = new ChangeUpdateRange(range).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeUpdateRange((byte)range); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeUpdateRange(range), ns => ns.SendChangeUpdateRange((byte)range)); } [Fact] public void TestDeathStatus() { - var expected = new DeathStatus(false).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDeathStatus(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DeathStatus(false), ns => ns.SendDeathStatus()); } [Theory] @@ -59,13 +40,10 @@ public class PlayerPacketTests [InlineData(0x1000u, null, null, null)] public void TestDisplayProfile(uint serial, string header, string body, string footer) { - var expected = new DisplayProfile((Serial)serial, header, body, footer).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayProfile((Serial)serial, header, body, footer); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayProfile((Serial)serial, header, body, footer), + ns => ns.SendDisplayProfile((Serial)serial, header, body, footer) + ); } [Theory] @@ -73,55 +51,31 @@ public class PlayerPacketTests [InlineData(LRReason.TryToSteal)] public void TestLiftRej(LRReason reason) { - var expected = new LiftRej(reason).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLiftReject(reason); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LiftRej(reason), ns => ns.SendLiftReject(reason)); } [Fact] public void TestLogoutAck() { - var expected = new LogoutAck().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLogoutAck(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LogoutAck(), ns => ns.SendLogoutAck()); } [Theory] [InlineData(1, 2, 3)] [InlineData(4, 5, 6)] - [InlineData(0x1234, 0x5678, 0x9ABC)] - public void TestWeather(int type, int density, int temp) + [InlineData(0xFF, 0x46, 0xEC)] + public void TestWeather(byte type, byte density, byte temp) { - var expected = new Weather(type, density, temp).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendWeather((byte)type, (byte)density, (byte)temp); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Weather(type, density, temp), ns => ns.SendWeather(type, density, temp)); } - [Theory] - [InlineData(100, 1000, 1, 0)] - public void TestServerChange(int x, int y, int z, int mapID) + [Fact] + public void TestServerChange() { - var p = new Point3D(x, y, z); - var map = Map.Maps[mapID]; - var expected = new ServerChange(p, map).Compile(); + var p = new Point3D(100, 1000, 1); + var map = Map.Maps[0]; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendServerChange(p, map); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ServerChange(p, map), ns => ns.SendServerChange(p, map)); } [Theory] @@ -130,13 +84,7 @@ public class PlayerPacketTests [InlineData(255)] public void TestSequence(byte num) { - var expected = new Sequence(num).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSequence(num); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Sequence(num), ns => ns.SendSequence(num)); } [Theory] @@ -146,37 +94,22 @@ public class PlayerPacketTests [InlineData("https://www.modernuo.com")] public void TestLaunchBrowser(string uri) { - var expected = new LaunchBrowser(uri).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLaunchBrowser(uri); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LaunchBrowser(uri), ns => ns.SendLaunchBrowser(uri)); } - [Theory] - [InlineData(0x1000u, 1000, 100, 10, 0x2000u, 1125, 125, 5, 0x384, 1024, 25)] - public void TestDragEffect( - uint srcSerial, int srcX, int srcY, int srcZ, - uint trgSerial, int trgX, int trgY, int trgZ, - int itemId, int hue, int amount - ) + [Fact] + public void TestDragEffect() { - var src = new Entity((Serial)srcSerial, new Point3D(srcX, srcY, srcZ), null); - var targ = new Entity((Serial)trgSerial, new Point3D(trgX, trgY, trgZ), null); + var src = new Entity((Serial)0x1000, new Point3D(1000, 100, 10), null); + var targ = new Entity((Serial)0x2000, new Point3D(1125, 125, 5), null); + const int itemId = 0x384; + const int hue = 1024; + const int amount = 25; - var expected = new DragEffect(src, targ, itemId, hue, amount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDragEffect( - src.Serial, src.Location, - targ.Serial, targ.Location, - itemId, hue, amount + PacketAssert.Sends( + new DragEffect(src, targ, itemId, hue, amount), + ns => ns.SendDragEffect(src.Serial, src.Location, targ.Serial, targ.Location, itemId, hue, amount) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Theory] @@ -184,13 +117,7 @@ public class PlayerPacketTests [InlineData(2, true)] public void TestSeasonChange(int season, bool playSound) { - var expected = new SeasonChange(season, playSound).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSeasonChange((byte)season, playSound); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SeasonChange(season, playSound), ns => ns.SendSeasonChange((byte)season, playSound)); } [Theory] @@ -199,13 +126,10 @@ public class PlayerPacketTests [InlineData(0x1024u, "Test Title", true, false)] public void TestDisplayPaperdoll(uint m, string title, bool warmode, bool canLift) { - var expected = new DisplayPaperdoll((Serial)m, title, warmode, canLift).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayPaperdoll((Serial)m, title, warmode, canLift); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayPaperdoll((Serial)m, title, warmode, canLift), + ns => ns.SendDisplayPaperdoll((Serial)m, title, warmode, canLift) + ); } [Theory] @@ -214,13 +138,7 @@ public class PlayerPacketTests [InlineData(MusicName.ValoriaShips)] public void TestPlayMusic(MusicName music) { - var expected = new PlayMusic(music).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPlayMusic(music); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlayMusic(music), ns => ns.SendPlayMusic(music)); } [Theory] @@ -228,41 +146,23 @@ public class PlayerPacketTests [InlineData(100, 10, "Some more text")] public void TestScrollMessage(int type, int tip, string text) { - var expected = new ScrollMessage(type, tip, text).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendScrollMessage(type, tip ,text); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ScrollMessage(type, tip, text), ns => ns.SendScrollMessage(type, tip, text)); } - [Theory] - [InlineData(14, 10, 05)] - public void TestCurrentTime(int hour, int minute, int second) + [Fact] + public void TestCurrentTime() { - var date = new DateTime(2020, 1, 1, hour, minute, second); - var expected = new CurrentTime(date).Compile(); + var date = new DateTime(2020, 1, 1, 14, 10, 5); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCurrentTime(date); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CurrentTime(date), ns => ns.SendCurrentTime(date)); } - [Theory] - [InlineData(1000, 10, 1)] - public void TestPathfindMessage(int x, int y, int z) + [Fact] + public void TestPathfindMessage() { - var p = new Point3D(x, y, z); - var expected = new PathfindMessage(p).Compile(); + var p = new Point3D(1000, 10, 1); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPathfindMessage(p); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PathfindMessage(p), ns => ns.SendPathfindMessage(p)); } [Theory] @@ -271,27 +171,17 @@ public class PlayerPacketTests [InlineData(100)] public void TestPingAck(byte ping) { - var expected = new PingAck(ping).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPingAck(ping); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PingAck(ping), ns => ns.SendPingAck(ping)); } - [Theory] - [InlineData(0xFF01)] - public void TestDisplayHuePicker(int itemID) + [Fact] + public void TestDisplayHuePicker() { - var huePicker = new HuePicker(itemID); + var huePicker = new HuePicker(0xFF01); - var expected = new DisplayHuePicker(huePicker).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayHuePicker(huePicker.Serial, huePicker.ItemID); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayHuePicker(huePicker), + ns => ns.SendDisplayHuePicker(huePicker.Serial, huePicker.ItemID) + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs index 6c10b63e9..ca2934b0c 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs @@ -18,13 +18,10 @@ public class SecureTradePacketTests var firstCont = new Container(World.NewItem); var secondCont = new Container(World.NewItem); - var expected = new DisplaySecureTrade(m, firstCont, secondCont, name).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySecureTrade(m, firstCont, secondCont, name); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySecureTrade(m, firstCont, secondCont, name), + ns => ns.SendDisplaySecureTrade(m, firstCont, secondCont, name) + ); } [Fact] @@ -32,13 +29,7 @@ public class SecureTradePacketTests { var cont = new Container(World.NewItem); - var expected = new CloseSecureTrade(cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCloseSecureTrade(cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CloseSecureTrade(cont), ns => ns.SendCloseSecureTrade(cont)); } [Theory, InlineData(true, false), InlineData(false, true)] @@ -50,13 +41,11 @@ public class SecureTradePacketTests var secondCont = new Container(World.NewItem); var cont = first ? firstCont : secondCont; - var expected = new UpdateSecureTrade(cont, first, second).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendUpdateSecureTrade(cont, first, second); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new UpdateSecureTrade(cont, first, second), + ns => ns.SendUpdateSecureTrade(cont, first, second) + ); } [Theory] @@ -65,13 +54,11 @@ public class SecureTradePacketTests public void TestUpdateGoldSecureTrade(int gold, int plat, TradeFlag flag) { var cont = new Container(World.NewItem); - var expected = new UpdateSecureTrade(cont, flag, gold, plat).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendUpdateSecureTrade(cont, flag, gold, plat); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new UpdateSecureTrade(cont, flag, gold, plat), + ns => ns.SendUpdateSecureTrade(cont, flag, gold, plat) + ); } [Fact] @@ -83,13 +70,7 @@ public class SecureTradePacketTests var cont = new Container(World.NewItem); var itemInCont = new Item(World.NewItem) { Parent = cont }; - var expected = new SecureTradeEquip(itemInCont, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSecureTradeEquip(itemInCont, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SecureTradeEquip(itemInCont, m), ns => ns.SendSecureTradeEquip(itemInCont, m)); } [Fact] @@ -101,14 +82,10 @@ public class SecureTradePacketTests var cont = new Container(World.NewItem); var itemInCont = new Item(World.NewItem) { Parent = cont }; - var expected = new SecureTradeEquip6017(itemInCont, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - - ns.SendSecureTradeEquip(itemInCont, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SecureTradeEquip6017(itemInCont, m), + ns => ns.SendSecureTradeEquip(itemInCont, m), + ProtocolChanges.ContainerGridLines + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs index c2d4d29fc..4cc8112b9 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs @@ -38,14 +38,7 @@ public class TargetPacketsTests var p = new Point3D(1000, 100, 10); MultiTarget t = new TestMultiTarget(multiID, p); - var expected = new MultiTargetReqHS(t).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.HighSeas; - ns.SendMultiTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MultiTargetReqHS(t), ns => ns.SendMultiTargetReq(t), ProtocolChanges.HighSeas); } [Fact] @@ -55,36 +48,20 @@ public class TargetPacketsTests var p = new Point3D(1000, 100, 10); MultiTarget t = new TestMultiTarget(multiID, p); - var expected = new MultiTargetReq(t).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMultiTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MultiTargetReq(t), ns => ns.SendMultiTargetReq(t)); } [Fact] public void TestCancelTarget() { - var expected = new CancelTarget().Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCancelTarget(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CancelTarget(), ns => ns.SendCancelTarget()); } [Fact] public void TestTargetReq() { var t = new TestTarget(10, true, TargetFlags.Beneficial); - var expected = new TargetReq(t).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new TargetReq(t), ns => ns.SendTargetReq(t)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs index 6262cc243..10f498a20 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs @@ -22,15 +22,11 @@ public class VendorBuyPacketTests new("Third Item", cont.Serial, World.NewItem, 30, 10, 0x0F, 0) }; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - - var expected = new VendorBuyContent(buyStates, ns.ContainerGridLines).Compile(); - - ns.SendVendorBuyContent(buyStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new VendorBuyContent(buyStates, ns.ContainerGridLines), + ns => ns.SendVendorBuyContent(buyStates), + protocolChanges + ); } [Theory] @@ -41,15 +37,11 @@ public class VendorBuyPacketTests var vendor = new Mobile((Serial)0x1); vendor.DefaultMobileInit(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - - var expected = new DisplayBuyList(vendor.Serial, ns.HighSeas).Compile(); - - ns.SendDisplayBuyList(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new DisplayBuyList(vendor.Serial, ns.HighSeas), + ns => ns.SendDisplayBuyList(vendor.Serial), + protocolChanges + ); } [Fact] @@ -67,13 +59,7 @@ public class VendorBuyPacketTests new("Third Item", cont.Serial, World.NewItem, 30, 10, 0x0F, 0) }; - var expected = new VendorBuyList(vendor, buyStates).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendVendorBuyList(vendor, buyStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new VendorBuyList(vendor, buyStates), ns => ns.SendVendorBuyList(vendor, buyStates)); } [Fact] @@ -82,12 +68,6 @@ public class VendorBuyPacketTests var vendor = new Mobile((Serial)0x1); vendor.DefaultMobileInit(); - var expected = new EndVendorBuy(vendor.Serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEndVendorBuy(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EndVendorBuy(vendor.Serial), ns => ns.SendEndVendorBuy(vendor.Serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs index 154223349..0b911e6bb 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs @@ -25,13 +25,10 @@ public class VendorSellPacketTests new(item3, 1, "Item 3") }; - var expected = new VendorSellList(vendor, sellStates.ToList()).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendVendorSellList(vendor.Serial, sellStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new VendorSellList(vendor, sellStates.ToList()), + ns => ns.SendVendorSellList(vendor.Serial, sellStates) + ); } [Fact] @@ -40,12 +37,6 @@ public class VendorSellPacketTests var vendor = new Mobile((Serial)0x1024u); vendor.DefaultMobileInit(); - var expected = new EndVendorBuy(vendor.Serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEndVendorSell(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EndVendorSell(vendor), ns => ns.SendEndVendorSell(vendor.Serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs index 3a105d1a4..2d70896f4 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs @@ -15,13 +15,10 @@ public class VirtualHairPacketTests m.HairHue = 0x1000; m.HairItemID = 0x2000; - var expected = new HairEquipUpdate(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendHairEquipUpdatePacket(m, (uint)m.HairSerial, m.HairItemID, m.HairHue, Layer.Hair); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new HairEquipUpdate(m), + ns => ns.SendHairEquipUpdatePacket(m, (uint)m.HairSerial, m.HairItemID, m.HairHue, Layer.Hair) + ); } [Fact] @@ -30,13 +27,7 @@ public class VirtualHairPacketTests var m = new Mobile((Serial)0x1024u); m.DefaultMobileInit(); - var expected = new RemoveHair(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendRemoveHairPacket((uint)m.HairSerial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new RemoveHair(m), ns => ns.SendRemoveHairPacket((uint)m.HairSerial)); } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs index 135a6e698..ac7ebbf59 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs @@ -16,22 +16,6 @@ public sealed class HairEquipUpdate : Packet } } -public sealed class FacialHairEquipUpdate : Packet -{ - public FacialHairEquipUpdate(Mobile parent) - : base(0x2E, 15) - { - var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.FacialHairHue; - - Stream.Write(parent.FacialHairSerial); - Stream.Write((short)parent.FacialHairItemID); - Stream.Write((byte)0); - Stream.Write((byte)Layer.FacialHair); - Stream.Write(parent.Serial); - Stream.Write((short)hue); - } -} - public sealed class RemoveHair : Packet { public RemoveHair(Mobile parent) @@ -40,12 +24,3 @@ public sealed class RemoveHair : Packet Stream.Write(parent.HairSerial); } } - -public sealed class RemoveFacialHair : Packet -{ - public RemoveFacialHair(Mobile parent) - : base(0x1D, 5) - { - Stream.Write(parent.FacialHairSerial); - } -} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs index 19a8704b7..6dbef52b1 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs @@ -102,36 +102,31 @@ public class ObjectPropertyListHashTests // 6- and 8-byte blocks take xxHash3's short-input paths. They still avalanche across all 26 // kept bits, so a counter ticking down never repeats the revision it just had. [Fact] - public void ShortNumericArguments_ConsecutiveValuesDiffer() + public void ShortPropertyBlocks_ConsecutiveValuesDifferAndStayWellDistributed() { - var previous = BuildHash((1060584, "0")); - - for (var charges = 1; charges < 20000; charges++) - { - var current = BuildHash((1060584, charges.ToString())); - Assert.NotEqual(previous, current); - previous = current; - } - } - - [Fact] - public void SmallPropertyBlocks_StayWellDistributed() - { - const int count = 20000; + const int count = 5000; var withArgument = new HashSet(); var withoutArgument = new HashSet(); + var previous = 0; for (var i = 0; i < count; i++) { - withArgument.Add(BuildHash((1060584, i.ToString()))); + var current = BuildHash((1060584, i.ToString())); + if (i > 0) + { + Assert.NotEqual(previous, current); + } + + previous = current; + withArgument.Add(current); withoutArgument.Add(BuildHash((1060000 + i, null))); } - // Birthday expects ~3 collisions over a 26-bit space; allow an order of magnitude so the - // bound holds for any seed. A hash that stopped mixing collapses far past it. - Assert.True(withArgument.Count >= count - 30, $"8-byte blocks: {withArgument.Count}/{count}"); - Assert.True(withoutArgument.Count >= count - 30, $"6-byte blocks: {withoutArgument.Count}/{count}"); + // Birthday expects ~0.2 collisions over a 26-bit space; allow a few so the bound holds for + // any seed. A hash that stopped mixing collapses far past it. + Assert.True(withArgument.Count >= count - 5, $"8-byte blocks: {withArgument.Count}/{count}"); + Assert.True(withoutArgument.Count >= count - 5, $"6-byte blocks: {withoutArgument.Count}/{count}"); } [Fact] diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs index a30e9c9e3..a09454ba6 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs @@ -25,18 +25,6 @@ public class ObjectPropertyListReentrancyTests return value; } - [Fact] - public void InterpolatedAdd_ResetMidHole_DoesNotThrow() - { - var opl = new ObjectPropertyList(null); - - var ex = Record.Exception( - () => opl.Add(1060776, $"{ResettingHole(opl, "Knight")}\t{"Council of Mages"}") - ); - - Assert.Null(ex); - } - [Fact] public void InterpolatedAdd_DisposeMidHole_DoesNotThrow() { @@ -57,19 +45,6 @@ public class ObjectPropertyListReentrancyTests [Collection("Sequential Server Tests")] public class ObjectPropertyListNestedBuildTests { - [Fact] - public void NestedBuild_DoesNotUnguardTheOuterList() - { - var outer = new ObjectPropertyList(null); - var inner = new ObjectPropertyList(null); - - outer.IsBuilding = true; - inner.IsBuilding = true; // another entity starts building, and finishes - inner.IsBuilding = false; - - Assert.True(outer.IsBuilding); - } - [Fact] public void Reset_MidInterpolation_LeavesTheListUsable() { diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs index 41444ea56..ff032f530 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -82,4 +82,112 @@ public class AnchoredItemSerializationTests restored?.Delete(); } } + + /// + /// LastMoved is the real time of the move, so a load after downtime must not shift it. + /// Shifting it rewrote every item record on the first save after every restart. + /// + [Fact] + public void LastMoved_IsNotShiftedByTheLoadAnchor() + { + var item = new Item(0x1F13); + var moved = Core.Now - TimeSpan.FromDays(30); + item.LastMoved = moved; + + var restored = new Item((Serial)0x7ffff124u); + restored.Deserialize(new BufferReader(SerializeItem(item)) { AnchoredTimeShift = TimeSpan.FromHours(2) }); + + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(default(DateTime), restored.DecayResetTime); + + item.Delete(); + restored.Delete(); + } + + /// + /// Downtime still does not count toward decay: a countdown running when the save started + /// resumes with the time it had left, carried by DecayResetTime. + /// + [Fact] + public void Downtime_IsCreditedToARunningDecayCountdown() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.LastMoved = start - TimeSpan.FromMinutes(10); + var deadline = item.ScheduledDecayTime; + + var downtime = TimeSpan.FromHours(2); + Core._now = start + downtime; + item.CreditDowntimeToDecay(start, downtime); + + Assert.Equal(start - TimeSpan.FromMinutes(10), item.LastMoved); + Assert.Equal(deadline + downtime, item.ScheduledDecayTime); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// The common case: an item whose countdown had already run out gets no credit, so a restart + /// leaves its record byte-identical. + /// + [Fact] + public void ItemAlreadyDueAtSave_GetsNoCredit_AndKeepsItsBytes() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.LastMoved = start - TimeSpan.FromDays(3); + var before = SerializeItem(item); + + var downtime = TimeSpan.FromMinutes(30); + Core._now = start + downtime; + item.CreditDowntimeToDecay(start, downtime); + + Assert.Equal(default(DateTime), item.DecayResetTime); + Assert.Equal(before, SerializeItem(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// Stat-gain stamps are absolute and not shifted at load either; downtime counts toward a + /// cooldown that is only minutes long. + /// + [Fact] + public void StatGainStamps_AreNotShiftedByTheLoadAnchor() + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.LastStrGain = Core.Now - TimeSpan.FromMinutes(3); + mobile.LastIntGain = Core.Now - TimeSpan.FromMinutes(2); + mobile.LastDexGain = Core.Now - TimeSpan.FromMinutes(1); + + var writer = new BufferWriter(new byte[4096], true); + mobile.Serialize(writer); + + var restored = new Mobile(World.NewMobile); + restored.Deserialize(new BufferReader(writer.Buffer[..(int)writer.Position]) { AnchoredTimeShift = TimeSpan.FromHours(2) }); + + Assert.Equal(mobile.LastStrGain, restored.LastStrGain); + Assert.Equal(mobile.LastIntGain, restored.LastIntGain); + Assert.Equal(mobile.LastDexGain, restored.LastDexGain); + + mobile.Delete(); + restored.Delete(); + } } diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs index a99b9850c..1015e704d 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; using Xunit; namespace Server.Tests; @@ -69,6 +67,63 @@ public class AnchoredTimeTests Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime()); } + + private static readonly DateTime SaveStart = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc); + + private static IGenericReader ReadSaved(BufferWriter writer, TimeSpan downtime) => + new BufferReader(writer.Buffer) { AnchoredTimeShift = downtime, AnchoredTimeOrigin = SaveStart }; + + [Fact] + public void AnchoredTime_DeadlineRunningAtSaveStart_KeepsItsRemainingTime() + { + var writer = new BufferWriter(new byte[64], true); + var deadline = SaveStart + TimeSpan.FromMinutes(20); + var downtime = TimeSpan.FromHours(3); + + writer.WriteAnchoredTime(deadline); + + Assert.Equal(deadline + downtime, ReadSaved(writer, downtime).ReadAnchoredTime()); + } + + /// + /// A deadline that had passed before the save must stay passed. Shifting it brought an expiry + /// back into the future whenever the downtime was longer than the time since it expired. + /// + [Fact] + public void AnchoredTime_DeadlinePassedBeforeSaveStart_StaysPassed() + { + var writer = new BufferWriter(new byte[64], true); + var expired = SaveStart - TimeSpan.FromMinutes(5); + + writer.WriteAnchoredTime(expired); + + Assert.Equal(expired, ReadSaved(writer, TimeSpan.FromMinutes(30)).ReadAnchoredTime()); + } + + /// + /// A stamp of something that already happened (a last-use time) is never shifted, so its + /// bytes survive a restart and downtime counts toward its window. + /// + [Fact] + public void AnchoredTime_PastStamp_IsNotShifted() + { + var writer = new BufferWriter(new byte[64], true); + var lastUse = SaveStart - TimeSpan.FromDays(2); + + writer.WriteAnchoredTime(lastUse); + + Assert.Equal(lastUse, ReadSaved(writer, TimeSpan.FromHours(1)).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_ValueAtSaveStart_IsNotShifted() + { + var writer = new BufferWriter(new byte[64], true); + + writer.WriteAnchoredTime(SaveStart); + + Assert.Equal(SaveStart, ReadSaved(writer, TimeSpan.FromHours(1)).ReadAnchoredTime()); + } } internal class AnchoredEntity : ISerializable @@ -107,83 +162,48 @@ public class AnchoredTimePersistenceTests [Fact] public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad() { - var previousAssemblies = AssemblyHandler.Assemblies; - AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly]; + // The scope also restores World.SaveStartTime, which this test backdates. + using var pipeline = new PersistencePipelineScope( + 2, + installWorkers: true, + visibleAssembly: typeof(AnchoredEntity).Assembly, + directoryPrefix: "muo-anchored" + ); + var source = pipeline.Source; + var dir = pipeline.Directory; - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) - { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); - } + var persistence = pipeline.Track(new AnchoredPersistence(2100)); - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + var lastRested = Core.Now - TimeSpan.FromMinutes(10); + var serial = (Serial)1u; + persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; + persistence.RegisterType(typeof(AnchoredEntity)); - var previousSaveStart = World.SaveStartTime; + // Pretend the save started two hours ago, as if the server had been down since. + var downtime = TimeSpan.FromHours(2); + World.SaveStartTime = Core.Now - downtime; - var persistence = new AnchoredPersistence(2100); - AnchoredPersistence loaded = null; + pipeline.WakeWorkers(); - var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - try - { - var lastRested = Core.Now - TimeSpan.FromMinutes(10); - var serial = (Serial)1u; - persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; - persistence.RegisterType(typeof(AnchoredEntity)); + pipeline.FlushAndSleepWorkers(); - // Pretend the save started two hours ago, as if the server had been down since. - var downtime = TimeSpan.FromHours(2); - World.SaveStartTime = Core.Now - downtime; + persistence.WriteSnapshot(dir); + persistence.PostWorldSave(); - foreach (var worker in workers) - { - worker.Wake(); - } + var loaded = pipeline.Track(new AnchoredPersistence(2101)); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); + var entity = loaded.EntitiesBySerial[serial]; + var expected = lastRested + downtime; - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } - - persistence.WriteSnapshot(dir); - persistence.PostWorldSave(); - - loaded = new AnchoredPersistence(2101); - loaded.DeserializeIndexes(dir, null); - loaded.Deserialize(dir, null); - - var entity = loaded.EntitiesBySerial[serial]; - var expected = lastRested + downtime; - - Assert.True( - (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), - $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." - ); - } - finally - { - World.SaveStartTime = previousSaveStart; - persistence.Unregister(); - loaded?.Unregister(); - - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - AssemblyHandler.Assemblies = previousAssemblies; - Directory.Delete(dir, true); - } + Assert.True( + (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), + $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." + ); } } diff --git a/Projects/Server.Tests/Tests/Serialization/DecimalSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/DecimalSerializationTests.cs deleted file mode 100644 index 5a25189d6..000000000 --- a/Projects/Server.Tests/Tests/Serialization/DecimalSerializationTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Buffers.Binary; -using System.Runtime.InteropServices; -using Xunit; - -namespace Server.Tests; - -public class DecimalSerializationTests -{ - private static void Write(byte[] buffer, decimal value) - { - Span bytes = stackalloc int[sizeof(decimal) / 4]; - decimal.GetBits(value, bytes); - - MemoryMarshal.Cast(bytes).CopyTo(buffer.AsSpan()); - } - - private static int ReadInt(ReadOnlySpan buffer) => BinaryPrimitives.ReadInt32LittleEndian(buffer); - - private static decimal ReadDecimal(ReadOnlySpan buffer) => new(stackalloc int[4] { ReadInt(buffer), ReadInt(buffer[4..]), ReadInt(buffer[8..]), ReadInt(buffer[12..]) }); - - public static TheoryData Data => - new() - { - 123.46m, - 0.0256m, - 10000m, - 2m, - 0.0001m, - 0.0000000000000000000000000001m - }; - - [Theory] - [MemberData(nameof(Data))] - public void TestSerializeDecimal(decimal value) - { - // Arrange - var buffer = new byte[sizeof(decimal)]; - - // Act - Write(buffer, value); - - // Assert - Assert.Equal(value, ReadDecimal(buffer)); - } -} diff --git a/Projects/Server.Tests/Tests/Serialization/EnumConversionTests.cs b/Projects/Server.Tests/Tests/Serialization/EnumConversionTests.cs deleted file mode 100644 index 26a6833b6..000000000 --- a/Projects/Server.Tests/Tests/Serialization/EnumConversionTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using Xunit; - -namespace Server.Tests; - -public class EnumConversionTests -{ - [Fact] - public void TestToEnum() - { - var e = ReadEnum(); - - Assert.Equal(TileFlag.Container, e); - } - - private static unsafe T ReadEnum() where T : unmanaged, Enum - { - var num = (long)TileFlag.Container; - return *(T*)# - } -} diff --git a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs index 48ba16e55..c8c4522aa 100644 --- a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; using Xunit; namespace Server.Tests; @@ -72,103 +70,68 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void SnapshotRoundTripsThroughWorkersAndSegmentLogs() { - // The loader resolves types by hash through AssemblyHandler; make this test - // assembly visible for the duration of the test. - var previousAssemblies = AssemblyHandler.Assemblies; - AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(RoundTripEntity).Assembly]; + // The loader resolves types by hash through AssemblyHandler, so this test assembly must be visible. + using var pipeline = new PersistencePipelineScope( + 3, + installWorkers: true, + visibleAssembly: typeof(RoundTripEntity).Assembly, + directoryPrefix: "muo-roundtrip" + ); + var source = pipeline.Source; + var dir = pipeline.Directory; - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[3]; - for (var i = 0; i < workers.Length; i++) + var persistence = pipeline.Track(new RoundTripPersistence(2000)); + + const int entityCount = 25_000; + var rng = new System.Random(0x5EED); + + for (var i = 1; i <= entityCount; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) + { + Value = rng.Next(), + Name = i % 5 == 0 ? null : $"entity-{i}" + }; } - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + persistence.RegisterType(typeof(RoundTripEntity)); - var persistence = new RoundTripPersistence(2000); - RoundTripPersistence loaded = null; + // Freeze: what Persistence.SerializeAll + GenericEntityPersistence.Serialize do, + // against this test's chunk source instead of the world's. + pipeline.WakeWorkers(); - var dir = Path.Combine(Path.GetTempPath(), $"muo-roundtrip-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + source.SetOwner(persistence); + source.PushSingle(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - try + pipeline.FlushAndSleepWorkers(); + + // Background write phase: snapshot from the segment logs. v4 embeds the type + // table in the idx, so no SerializedTypes.db is produced or needed. + persistence.WriteSnapshot(dir); + + persistence.PostWorldSave(); // releases the entries snapshot + + // Load into a fresh persistence, like a server boot would. + var loaded = pipeline.Track(new RoundTripPersistence(2001)); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); + + Assert.True(loaded.SelfPayloadDeserialized); + Assert.Equal(persistence.EntitiesBySerial.Count, loaded.EntitiesBySerial.Count); + + foreach (var (serial, original) in persistence.EntitiesBySerial) { - const int entityCount = 25_000; - var rng = new System.Random(0x5EED); - - for (var i = 1; i <= entityCount; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) - { - Value = rng.Next(), - Name = i % 5 == 0 ? null : $"entity-{i}" - }; - } - - persistence.RegisterType(typeof(RoundTripEntity)); - - // Freeze: what Persistence.SerializeAll + GenericEntityPersistence.Serialize do, - // against this test's chunk source instead of the world's. - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - source.PushSingle(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - - // Mirrors World.PauseSerializationThreads - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } - - // Background write phase: snapshot from the segment logs. v4 embeds the type - // table in the idx, so no SerializedTypes.db is produced or needed. - persistence.WriteSnapshot(dir); - - persistence.PostWorldSave(); // releases the entries snapshot - - // Load into a fresh persistence, like a server boot would. - loaded = new RoundTripPersistence(2001); - loaded.DeserializeIndexes(dir, null); - loaded.Deserialize(dir, null); - - Assert.True(loaded.SelfPayloadDeserialized); - Assert.Equal(persistence.EntitiesBySerial.Count, loaded.EntitiesBySerial.Count); - - foreach (var (serial, original) in persistence.EntitiesBySerial) - { - var entity = loaded.EntitiesBySerial[serial]; - Assert.Equal(original.Value, entity.Value); - Assert.Equal(original.Name, entity.Name); - Assert.Equal(original.Created.Ticks, entity.Created.Ticks); - } - - // Loading must hydrate the type table so the next save can write indexes. - Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _)); + var entity = loaded.EntitiesBySerial[serial]; + Assert.Equal(original.Value, entity.Value); + Assert.Equal(original.Name, entity.Name); + Assert.Equal(original.Created.Ticks, entity.Created.Ticks); } - finally - { - persistence.Unregister(); - loaded?.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - AssemblyHandler.Assemblies = previousAssemblies; - Directory.Delete(dir, true); - } + // Loading must hydrate the type table so the next save can write indexes. + Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _)); } /// @@ -179,70 +142,44 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void SerializerException_IsRecordedOnTheWorker_AndTheDrainCompletes() { - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2); + var source = pipeline.Source; + var workers = pipeline.Workers; + + var persistence = pipeline.Track(new RoundTripPersistence(2002)); + + for (var i = 1; i <= 100; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = i == 50 + ? new ThrowingRoundTripEntity(serial) + : new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; } - var persistence = new RoundTripPersistence(2002); + pipeline.WakeWorkers(); - try + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); + + pipeline.FlushAndSleepWorkers(); + + Exception error = null; + foreach (var worker in workers) { - for (var i = 1; i <= 100; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = i == 50 - ? new ThrowingRoundTripEntity(serial) - : new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; - } - - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - source.Flush(); - - foreach (var worker in workers) - { - worker.Sleep(); - } - - Exception error = null; - foreach (var worker in workers) - { - error ??= worker.Error; - } - - Assert.IsType(error); - persistence.PostWorldSave(); - - // The next drain starts clean. - foreach (var worker in workers) - { - worker.Wake(); - } - - foreach (var worker in workers) - { - worker.Sleep(); - Assert.Null(worker.Error); - } + error ??= worker.Error; } - finally - { - persistence.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } + Assert.IsType(error); + persistence.PostWorldSave(); + + // The next drain starts clean. + pipeline.WakeWorkers(); + + foreach (var worker in workers) + { + worker.Sleep(); + Assert.Null(worker.Error); } } @@ -253,60 +190,28 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void WriteSnapshot_FailsTheSave_InsteadOfDroppingASegment() { - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2, installWorkers: true, directoryPrefix: "muo-segmentfail"); + var source = pipeline.Source; + + var persistence = pipeline.Track(new RoundTripPersistence(2003)); + + for (var i = 1; i <= 100; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; } - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + // Deliberately not registered: the writer cannot index the type. - var persistence = new RoundTripPersistence(2003); - var dir = Path.Combine(Path.GetTempPath(), $"muo-segmentfail-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + pipeline.WakeWorkers(); - try - { - for (var i = 1; i <= 100; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; - } + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - // Deliberately not registered: the writer cannot index the type. + pipeline.FlushAndSleepWorkers(); - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - source.Flush(); - - foreach (var worker in workers) - { - worker.Sleep(); - } - - Assert.Throws(() => persistence.WriteSnapshot(dir)); - persistence.PostWorldSave(); - } - finally - { - persistence.Unregister(); - - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - Directory.Delete(dir, true); - } + Assert.Throws(() => persistence.WriteSnapshot(pipeline.Directory)); + persistence.PostWorldSave(); } } diff --git a/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs b/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs index 69387f4e6..24fea88a7 100644 --- a/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs @@ -199,114 +199,91 @@ public class SerializationChunkSourceTests [Fact] public void WorkersDrainAllEntitiesAndLogSegments() { - var source = new SerializationChunkSource(); - var owner = new TestPersistence { PayloadSize = 512 * 1024 }; - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2); + var source = pipeline.Source; + var workers = pipeline.Workers; + var owner = pipeline.Track(new TestPersistence { PayloadSize = 512 * 1024 }); + + var entities = new List(); + for (var i = 0; i < 10_000; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + entities.Add(new TestEntity { PayloadSize = 16 + i % 64 }); } - try + pipeline.WakeWorkers(); + + // A large payload published as a dedicated single chunk (a persistence + // self-payload), interleaved with the bare entity stream. + source.SetOwner(owner); + for (var i = 0; i < entities.Count; i++) { - var entities = new List(); - for (var i = 0; i < 10_000; i++) + if (i == 5000) { - entities.Add(new TestEntity { PayloadSize = 16 + i % 64 }); + source.PushSingle(owner); } - foreach (var worker in workers) - { - worker.Wake(); - } + source.Push(entities[i]); + } - // A large payload published as a dedicated single chunk (a persistence - // self-payload), interleaved with the bare entity stream. - source.SetOwner(owner); - for (var i = 0; i < entities.Count; i++) + pipeline.FlushAndSleepWorkers(); + + long totalEntities = 0; + long totalBytes = 0; + foreach (var worker in workers) + { + totalEntities += worker.EntitiesSerialized; + totalBytes += worker.BytesSerialized; + } + + Assert.Equal(entities.Count + 1, totalEntities); + + // The self-payload recorded its placement on the persistence itself. + var (selfThread, selfPosition, selfLength) = owner.Placement; + Assert.Equal(owner.PayloadSize, selfLength); + Assert.InRange(selfThread, (byte)0, (byte)(workers.Length - 1)); + var selfHeap = workers[selfThread].GetHeap(selfPosition, selfLength); + Assert.Equal(0, selfHeap[0]); + Assert.Equal((selfLength - 1) & 0xFF, selfHeap[^1]); + + // Every entity appears exactly once in the worker segment logs, with a + // consistent span on that worker's heap: positions are implicit (contiguous + // writes), identity comes from the buffer-entities log. + var seen = new HashSet(); + long expectedBytes = owner.PayloadSize; + foreach (var e in entities) + { + expectedBytes += e.PayloadSize; + } + + foreach (var worker in workers) + { + var lengths = worker.Lengths; + var bufferEntities = worker.BufferEntities; + + foreach (var segment in worker.Segments) { - if (i == 5000) + Assert.Same(owner, segment.Owner); + Assert.Equal(-1, segment.SlotOffset); + + var heapPos = (int)segment.HeapStart; + for (var i = 0; i < segment.RecordCount; i++) { - source.PushSingle(owner); - } + var entity = (TestEntity)bufferEntities[segment.EntitiesStart + i]; + var length = lengths[segment.LengthsStart + i]; - source.Push(entities[i]); - } + Assert.True(seen.Add(entity)); + Assert.Equal(entity.PayloadSize, length); - // Mirrors World.PauseSerializationThreads - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } + var heap = workers[Array.IndexOf(workers, worker)].GetHeap(heapPos, length); + Assert.Equal(0, heap[0]); + Assert.Equal((length - 1) & 0xFF, heap[^1]); - long totalEntities = 0; - long totalBytes = 0; - foreach (var worker in workers) - { - totalEntities += worker.EntitiesSerialized; - totalBytes += worker.BytesSerialized; - } - - Assert.Equal(entities.Count + 1, totalEntities); - - // The self-payload recorded its placement on the persistence itself. - var (selfThread, selfPosition, selfLength) = owner.Placement; - Assert.Equal(owner.PayloadSize, selfLength); - Assert.InRange(selfThread, (byte)0, (byte)(workers.Length - 1)); - var selfHeap = workers[selfThread].GetHeap(selfPosition, selfLength); - Assert.Equal(0, selfHeap[0]); - Assert.Equal((selfLength - 1) & 0xFF, selfHeap[^1]); - - // Every entity appears exactly once in the worker segment logs, with a - // consistent span on that worker's heap: positions are implicit (contiguous - // writes), identity comes from the buffer-entities log. - var seen = new HashSet(); - long expectedBytes = owner.PayloadSize; - foreach (var e in entities) - { - expectedBytes += e.PayloadSize; - } - - foreach (var worker in workers) - { - var lengths = worker.Lengths; - var bufferEntities = worker.BufferEntities; - - foreach (var segment in worker.Segments) - { - Assert.Same(owner, segment.Owner); - Assert.Equal(-1, segment.SlotOffset); - - var heapPos = (int)segment.HeapStart; - for (var i = 0; i < segment.RecordCount; i++) - { - var entity = (TestEntity)bufferEntities[segment.EntitiesStart + i]; - var length = lengths[segment.LengthsStart + i]; - - Assert.True(seen.Add(entity)); - Assert.Equal(entity.PayloadSize, length); - - var heap = workers[Array.IndexOf(workers, worker)].GetHeap(heapPos, length); - Assert.Equal(0, heap[0]); - Assert.Equal((length - 1) & 0xFF, heap[^1]); - - heapPos += length; - } + heapPos += length; } } + } - Assert.Equal(entities.Count, seen.Count); - Assert.Equal(expectedBytes, totalBytes); - } - finally - { - owner.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } - } + Assert.Equal(entities.Count, seen.Count); + Assert.Equal(expectedBytes, totalBytes); } } diff --git a/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs b/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs new file mode 100644 index 000000000..12e057204 --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs @@ -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); + } +} diff --git a/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs b/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs new file mode 100644 index 000000000..afb35b784 --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs @@ -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); + } +} diff --git a/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs b/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs new file mode 100644 index 000000000..8d04ae98f --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs @@ -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.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.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(() => TestTerrain.Scope()); + Assert.Contains($"{nameof(TestTerrainTests)}.{nameof(NestedScopes_Throw)}", error.Message); + } + + [Fact] + public void StampingOutsideAScope_Throws_OncePrefabsAreStamped() + { + Prefabs.StampAll(); + + Assert.Throws(() => 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(() => Prefabs.Coastline.AssertPresent()); + } +} diff --git a/Projects/Server.Tests/Tests/Timer/TimerTests.cs b/Projects/Server.Tests/Tests/Timer/TimerTests.cs index 9f610de50..e7a9b6241 100644 --- a/Projects/Server.Tests/Tests/Timer/TimerTests.cs +++ b/Projects/Server.Tests/Tests/Timer/TimerTests.cs @@ -85,6 +85,30 @@ public class TimerTests Assert.Equal(2, timerTicks.ExecutedCount); } + [Fact] + public void ClearAllTimers_StopsEveryTimerInASlot() + { + Timer.Init(0); + + var timers = new Timer[3]; + for (var i = 0; i < timers.Length; i++) + { + timers[i] = Timer.DelayCall(TimeSpan.FromMilliseconds(80), () => Assert.Fail("cleared timer fired")); + } + + Timer.ClearAllTimers(0); + + for (var i = 0; i < timers.Length; i++) + { + Assert.False(timers[i].Running); + } + + for (var tick = 8; tick <= 160; tick += 8) + { + Timer.Slice(tick); + } + } + private class TimerTicks { public long ExpectedTicks; diff --git a/Projects/Server.Tests/Tests/Utility/ActivatorExtensionsTests.cs b/Projects/Server.Tests/Tests/Utility/ActivatorExtensionsTests.cs index ed331c2ff..059f6fa55 100644 --- a/Projects/Server.Tests/Tests/Utility/ActivatorExtensionsTests.cs +++ b/Projects/Server.Tests/Tests/Utility/ActivatorExtensionsTests.cs @@ -54,15 +54,6 @@ namespace Server.Tests Assert.Equal("Test ModernUO", result.Name); } - [Fact] - public void TestTwoParamsOptionalActivator() - { - var result = typeof(TestTwoOptionalParamsClass).CreateInstance(10, "Prod ModernUO"); - Assert.IsType(result); - Assert.Equal(10, result.Amount); - Assert.Equal("Prod ModernUO", result.Name); - } - private class TestZeroParamsClass { } diff --git a/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs b/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs index e2172a191..edc65d211 100644 --- a/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs +++ b/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs @@ -35,54 +35,6 @@ public class HtmlEscapeTests Assert.Equal(expected, result); } - [Theory(DisplayName = "Multiple instances of same character")] - [InlineData("<><>", "<><>")] - [InlineData("&&&&", "&&&&")] - [InlineData("\"\"\"", """"")] - [InlineData("'''", "'''")] - [InlineData(">>>", ">>>")] - public void EscapeHtml_MultipleSpecialCharacters_EscapesAll(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "Mixed content")] - [InlineData("Hello ", "Hello <world>")] - [InlineData("
Hello
", "<div>Hello</div>")] - [InlineData("Tom & Jerry", "Tom & Jerry")] - [InlineData("He said \"hello\"", "He said "hello"")] - [InlineData("It's a test", "It's a test")] - public void EscapeHtml_MixedContent_EscapesMixedSpecialCharacters(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "Starting with special character")] - [InlineData("World", ">World")] - [InlineData("&Start", "&Start")] - [InlineData("\"Quote", ""Quote")] - [InlineData("'Apostrophe", "'Apostrophe")] - public void EscapeHtml_StartsWithSpecialCharacter_EscapesStart(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "Ending with special character")] - [InlineData("Hello<", "Hello<")] - [InlineData("World>", "World>")] - [InlineData("End&", "End&")] - [InlineData("Quote\"", "Quote"")] - [InlineData("Test'", "Test'")] - public void EscapeHtml_EndsWithSpecialCharacter_EscapesEnd(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - [Theory(DisplayName = "Complex mixed scenarios")] [InlineData("

Hello & goodbye

", "<p>Hello & goodbye</p>")] [InlineData("<already>", "&lt;already&gt;")] @@ -103,59 +55,6 @@ public class HtmlEscapeTests Assert.Empty(result); } - [Fact(DisplayName = "Empty string input")] - public void EscapeHtml_EmptyString_ReturnsEmpty() - { - var result = "".EscapeHtml(); - Assert.Empty(result); - } - - [Fact(DisplayName = "Only special characters")] - public void EscapeHtml_OnlySpecialCharacters_EscapesAll() - { - const string input = "<>&\"'"; - const string expected = "<>&"'"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Fact(DisplayName = "Ampersand must be escaped first")] - public void EscapeHtml_AmpersandFirst_PreventDoubleEscaping() - { - // This is critical: & must be escaped to & - // If we're not careful, we could double-escape already-escaped content - const string input = "<"; - const string expected = "&lt;"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "ReadOnlySpan overload - no special characters")] - [InlineData("Hello World")] - [InlineData("Plain text")] - public void EscapeHtml_ReadOnlySpan_NoSpecialCharacters_ReturnsUnchanged(string input) - { - var result = input.AsSpan().EscapeHtml(); - Assert.Equal(input, result); - } - - [Theory(DisplayName = "ReadOnlySpan overload - with special characters")] - [InlineData("
", "<div>")] - [InlineData("Tom & Jerry", "Tom & Jerry")] - public void EscapeHtml_ReadOnlySpan_WithSpecialCharacters_EscapesCorrectly(string input, string expected) - { - var result = input.AsSpan().EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "ReadOnlySpan overload - empty input")] - [InlineData("")] - public void EscapeHtml_ReadOnlySpan_Empty_ReturnsEmpty(string input) - { - var result = input.AsSpan().EscapeHtml(); - Assert.Empty(result); - } - [Fact(DisplayName = "Consecutive special characters")] public void EscapeHtml_ConsecutiveSpecialCharacters_EscapesAll() { @@ -165,51 +64,6 @@ public class HtmlEscapeTests Assert.Equal(expected, result); } - [Fact(DisplayName = "Special characters with single normal character between")] - public void EscapeHtml_SpecialCharactersWithGaps_EscapesAll() - { - const string input = "b&c\"d'e"; - const string expected = "<a>b&c"d'e"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Fact(DisplayName = "HTML tags")] - public void EscapeHtml_HtmlTags_EscapesTagBrackets() - { - var input = "Hello"; - var expected = "<html><body>Hello</body></html>"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Fact(DisplayName = "HTML attributes with mixed quotes")] - public void EscapeHtml_HtmlAttributesWithQuotes_EscapesCorrectly() - { - var input = ""; - var expected = "<a href="test" data='value'>"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "Whitespace handling")] - [InlineData(" spaces ", " spaces ")] - [InlineData("\ttabs\t", "\ttabs\t")] - [InlineData("\nnewlines\n", "\nnewlines\n")] - public void EscapeHtml_Whitespace_PreservedAsIs(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Fact(DisplayName = "Performance: long string without special characters")] - public void EscapeHtml_LongStringNoSpecialCharacters_ReturnsQuickly() - { - var input = new string('a', 10000); - var result = input.EscapeHtml(); - Assert.Equal(input, result); - } - [Fact(DisplayName = "Performance: long string with special characters")] public void EscapeHtml_LongStringWithSpecialCharacters_HandlesCorrectly() { @@ -229,15 +83,4 @@ public class HtmlEscapeTests var result = input.EscapeHtml(); Assert.Equal(expected, result); } - - [Fact(DisplayName = "String overload matches ReadOnlySpan overload")] - public void EscapeHtml_StringVsReadOnlySpan_ProduceSameResult() - { - const string input = "
Tom & Jerry 'in' \"quotes\"
"; - - var resultString = input.EscapeHtml(); - var resultSpan = input.AsSpan().EscapeHtml(); - - Assert.Equal(resultString, resultSpan); - } } diff --git a/Projects/Server.Tests/Tests/Utility/StringHelperTests.cs b/Projects/Server.Tests/Tests/Utility/StringHelperTests.cs index f428a060a..c7376cc4c 100644 --- a/Projects/Server.Tests/Tests/Utility/StringHelperTests.cs +++ b/Projects/Server.Tests/Tests/Utility/StringHelperTests.cs @@ -7,17 +7,6 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class TestStringHelpers { - [Theory] - [InlineData(null, "default value", "default value")] - [InlineData("", "default value", "default value")] - [InlineData("this is a valid string", "default value", "this is a valid string")] - public void TestIsNullOrDefault(string value, string defaultValue, string expected) - { - var actual = value.DefaultIfNullOrEmpty(defaultValue); - - Assert.Equal(expected, actual); - } - [Theory] [InlineData("this is not capitalized", "This Is Not Capitalized")] [InlineData("", "")] diff --git a/Projects/Server.Tests/Tests/World/VirtualSerialTests.cs b/Projects/Server.Tests/Tests/World/VirtualSerialTests.cs index 6c841f9f8..376fd661e 100644 --- a/Projects/Server.Tests/Tests/World/VirtualSerialTests.cs +++ b/Projects/Server.Tests/Tests/World/VirtualSerialTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Xunit; namespace Server.Tests; @@ -5,20 +6,38 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class VirtualSerialTests { + // Walking up from ResetVirtualSerial takes ~17.9 million draws; start just short of the wrap. + private const uint DrawsBeforeMax = 1000; + + private static readonly FieldInfo _nextVirtualSerial = + typeof(World).GetField("_nextVirtualSerial", BindingFlags.NonPublic | BindingFlags.Static); + [Fact] public void TestNewVirtualGetsAndRollover() { - // Acquire virtual serials until we hit the max - var lastSerial = World.NewVirtual; - do - { - var virtualSerial = World.NewVirtual; - Assert.Equal(lastSerial + 1, virtualSerial); - lastSerial = (Serial) (uint) virtualSerial; - } while (lastSerial != World.MaxVirtualSerial); + Assert.NotNull(_nextVirtualSerial); - // Next one should be MaxItemSerial (the first VirtualSerial) due to rollover - var nextSerial = World.NewVirtual; - Assert.Equal(nextSerial, (Serial)World.ResetVirtualSerial); + var saved = (uint)_nextVirtualSerial.GetValue(null); + _nextVirtualSerial.SetValue(null, World.MaxVirtualSerial - DrawsBeforeMax); + + try + { + // Acquire virtual serials until we hit the max + var lastSerial = World.NewVirtual; + do + { + var virtualSerial = World.NewVirtual; + Assert.Equal(lastSerial + 1, virtualSerial); + lastSerial = (Serial)(uint)virtualSerial; + } while (lastSerial != World.MaxVirtualSerial); + + // Next one should be MaxItemSerial (the first VirtualSerial) due to rollover + var nextSerial = World.NewVirtual; + Assert.Equal(nextSerial, (Serial)World.ResetVirtualSerial); + } + finally + { + _nextVirtualSerial.SetValue(null, saved); + } } } diff --git a/Projects/Server/Client/MultiData.cs b/Projects/Server/Client/MultiData.cs index 43498614d..d45998bfc 100644 --- a/Projects/Server/Client/MultiData.cs +++ b/Projects/Server/Client/MultiData.cs @@ -52,6 +52,12 @@ public static class MultiData public static MultiComponentList GetComponents(int multiID) => _components.TryGetValue(multiID & 0x3FFF, out var mcl) ? mcl : MultiComponentList.Empty; + /// + /// Registers a footprint without multi files, for hosts that generate their own (tests). + /// + internal static void SetComponents(int multiID, MultiComponentList components) => + _components[multiID & 0x3FFF] = components; + private static void LoadUOP(string path) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 4226f100f..c67acdc85 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -1042,13 +1042,14 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.Write((int)flags); - // Anchored: shifted by downtime at load, so time-since-moved is preserved and the - // bytes are stable across saves while the item does not move. - writer.WriteAnchoredTime(LastMoved); + // Absolute and never shifted at load, so an item that does not move serializes to the + // same bytes across saves and across restarts. Downtime credit for a decay countdown that + // was running at save time goes into DecayResetTime (CreditDowntimeToDecay). + writer.Write(LastMoved); if (GetSaveFlag(flags, SaveFlag.DecayReset)) { - writer.WriteAnchoredTime(info.m_DecayReset); + writer.Write(info.m_DecayReset); } if (GetSaveFlag(flags, SaveFlag.Direction)) @@ -2494,6 +2495,30 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert UpdateDecayRegistration(); } + /// + /// Credits the downtime before this load to a decay countdown that was still running when the + /// save started, so the shard being down does not age the item. stays + /// the real time of the move; the credit goes into , which only + /// the items moved within their of the save ever carry. Called once + /// per item after the world loads, when is final. An item already + /// due at save time gets nothing: it is due either way. + /// + internal void CreditDowntimeToDecay(DateTime saveStartUtc, TimeSpan downtime) + { + if (downtime <= TimeSpan.Zero || !Decays) + { + return; + } + + var reset = DecayResetTime; + var start = reset > LastMoved ? reset : LastMoved; + + if (start + DecayTime > saveStartUtc) + { + DecayResetTime = start + downtime; + } + } + public void UpdateDecayRegistration() { DecayScheduler.Unregister(this); @@ -2856,7 +2881,9 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 11) { - LastMoved = reader.ReadAnchoredTime(); + // Written with WriteAnchoredTime before LastMoved stopped being shifted; + // the bytes are the same absolute ticks either way. + LastMoved = reader.ReadDateTime(); } else if (version < 7) { @@ -2878,7 +2905,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) { - var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + var reset = version >= 11 ? reader.ReadDateTime() : reader.ReadDeltaTime(); // Pre-v11 LastMoved was stored at whole-minute precision; keep the // stamp only while it still extends the deadline. diff --git a/Projects/Server/Localization/Localization.cs b/Projects/Server/Localization/Localization.cs index 21dbe801f..0b0bde43c 100644 --- a/Projects/Server/Localization/Localization.cs +++ b/Projects/Server/Localization/Localization.cs @@ -68,7 +68,9 @@ public static class Localization return false; } - if (entries.Count == 0) + // The fallback table stays registered even when empty: _fallbackEntries points at it, and a + // replacement created by a later Add would be invisible to fallback lookups. + if (entries.Count == 0 && entries != _fallbackEntries) { _localizations.Remove(lang); } diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 608256651..9a9668de4 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -45,6 +45,10 @@ public static class Core private static volatile bool _performSnapshot; private static string _snapshotPath; + // 0 = relaunch in-process. Otherwise a restart exits with this code and leaves the relaunch to a + // supervisor; a spawned child loses the console and dies with a systemd unit or container. + private static int _restartExitCode; + // A backstop, not a latency control: the wheel's tick rate already bounds the sleep. // Measured across 1/2/4/8ms; 2 is optimal. private static int _eventLoopIdleWaitMs = 2; @@ -434,7 +438,7 @@ public static class Core ConsoleInputHandler.ReadLine(); } - DoKill(); + DoKill(_restartOnKill); } } @@ -468,27 +472,28 @@ public static class Core HandleClosed(); + if (restart && _restartExitCode != 0) + { + logger.Information("Restarting (exiting with code {ExitCode} for the supervisor)", _restartExitCode); + Environment.Exit(_restartExitCode); + } + if (restart) { try { logger.Information("Restarting"); - if (IsWindows) - { - using var process = Process.Start("dotnet", $"{ApplicationAssembly.Location}"); - } - else - { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = $"{ApplicationAssembly.Location}", - UseShellExecute = true - }; - process.Start(); + // Relaunch through whichever host started us: the apphost (ModernUO.exe) runs directly, + // `dotnet ModernUO.dll` needs the assembly path. ArgumentList quotes paths with spaces. + var hostPath = Environment.ProcessPath ?? "dotnet"; + var startInfo = new ProcessStartInfo(hostPath) { UseShellExecute = false }; + if (Path.GetFileNameWithoutExtension(hostPath).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + startInfo.ArgumentList.Add(ApplicationAssembly.Location); } + + using var process = Process.Start(startInfo); logger.Information("Restart done"); } catch (Exception e) @@ -630,6 +635,19 @@ public static class Core _lateWakePercent = Math.Clamp(lateWakePercent, 0, 100); + // Unix truncates exit codes to a byte, so anything outside 1-255 cannot be told apart reliably. + var restartExitCode = ServerConfiguration.GetSetting("server.restartExitCode", 0); + if (restartExitCode is < 0 or > 255) + { + logger.Warning( + "server.restartExitCode {Value} is outside 1-255; restarts will relaunch the server directly", + restartExitCode + ); + restartExitCode = 0; + } + + _restartExitCode = restartExitCode; + var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration); // Load UOContent.dll diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index c0e800bab..8d98d2ffe 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -2349,9 +2349,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { writer.Write(38); // version - writer.WriteAnchoredTime(LastStrGain); - writer.WriteAnchoredTime(LastIntGain); - writer.WriteAnchoredTime(LastDexGain); + // Absolute and never shifted at load: the stat-gain cooldown is minutes long, so letting + // downtime count toward it costs nothing, and an idle mobile keeps its bytes across restarts. + writer.Write(LastStrGain); + writer.Write(LastIntGain); + writer.Write(LastDexGain); byte hairflag = 0x00; @@ -6185,9 +6187,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { if (version >= 38) { - LastStrGain = reader.ReadAnchoredTime(); - LastIntGain = reader.ReadAnchoredTime(); - LastDexGain = reader.ReadAnchoredTime(); + LastStrGain = reader.ReadDateTime(); + LastIntGain = reader.ReadDateTime(); + LastDexGain = reader.ReadDateTime(); } else { diff --git a/Projects/Server/Serialization/BinaryFileReader.cs b/Projects/Server/Serialization/BinaryFileReader.cs index f8b51c182..37332ef8c 100644 --- a/Projects/Server/Serialization/BinaryFileReader.cs +++ b/Projects/Server/Serialization/BinaryFileReader.cs @@ -80,6 +80,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader set => _reader.AnchoredTimeShift = value; } + public DateTime AnchoredTimeOrigin + { + get => _reader.AnchoredTimeOrigin; + set => _reader.AnchoredTimeOrigin = value; + } + public void Dispose() { _accessor?.SafeMemoryMappedViewHandle.ReleasePointer(); diff --git a/Projects/Server/Serialization/BufferReader.cs b/Projects/Server/Serialization/BufferReader.cs index bd37f4ff6..90b9e17ff 100644 --- a/Projects/Server/Serialization/BufferReader.cs +++ b/Projects/Server/Serialization/BufferReader.cs @@ -39,6 +39,8 @@ public class BufferReader : IGenericReader public TimeSpan AnchoredTimeShift { get; set; } + public DateTime AnchoredTimeOrigin { get; set; } + public BufferReader(byte[] buffer, Dictionary typesDb = null, Encoding encoding = null) { _buffer = buffer; diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index ab53e2f2f..9b0199344 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -409,9 +409,9 @@ public class BufferWriter : IGenericWriter } /// - /// Writes the absolute value; re-bases it - /// by the elapsed time since the save started, so downtime does not age it and an - /// unchanged value serializes to identical bytes. + /// Writes the absolute value; re-bases it by + /// the downtime when it was still in the future at save start, so downtime does not consume a + /// running deadline and an unchanged value serializes to identical bytes. /// public void WriteAnchoredTime(DateTime value) { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 70e9c6aab..325789d5c 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -529,6 +529,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer // The whole save shares one anchor. Publish it so payloads without their own // (GenericPersistence bins) can shift too; indexes load before any of them. World.LoadTimeShift = _anchoredTimeShift; + World.LoadedSaveStartTime = anchor; } if (version >= 4) @@ -709,7 +710,8 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) { - AnchoredTimeShift = _anchoredTimeShift + AnchoredTimeShift = _anchoredTimeShift, + AnchoredTimeOrigin = World.LoadedSaveStartTime }; Deserialize(dataReader); diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 2268a86c6..b9261b053 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -103,7 +103,8 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable // These payloads carry no anchor of their own; they inherit the save-wide // shift stamped while the entity indexes were read (indexes always load // before persistence payloads — see Persistence.Load). - AnchoredTimeShift = World.LoadTimeShift + AnchoredTimeShift = World.LoadTimeShift, + AnchoredTimeOrigin = World.LoadedSaveStartTime }; Deserialize(dataReader); diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index bfa302b39..90c78d667 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -65,6 +65,19 @@ public interface IGenericReader ///
TimeSpan AnchoredTimeShift => TimeSpan.Zero; + /// + /// UTC start of the loaded save. leaves a value at or before + /// it unshifted. Default when unknown, which shifts every value. + /// + DateTime AnchoredTimeOrigin => default; + + /// + /// Reads an anchored timestamp. A deadline still in the future when the save started is + /// shifted by , so it keeps the time it had left. A value at or + /// before the save start is returned as saved: an expired deadline stays expired instead of + /// coming back, and a record of something that already happened (a last-use or last-loss stamp) + /// keeps its bytes across restarts, with downtime counting toward its window. + /// DateTime ReadAnchoredTime() { var value = ReadDateTime(); @@ -75,7 +88,7 @@ public interface IGenericReader } var shift = AnchoredTimeShift; - if (shift == TimeSpan.Zero) + if (shift == TimeSpan.Zero || value <= AnchoredTimeOrigin) { return value; } diff --git a/Projects/Server/Serialization/UnmanagedDataReader.cs b/Projects/Server/Serialization/UnmanagedDataReader.cs index aa3916ee9..3ce81a715 100644 --- a/Projects/Server/Serialization/UnmanagedDataReader.cs +++ b/Projects/Server/Serialization/UnmanagedDataReader.cs @@ -45,6 +45,8 @@ public unsafe class UnmanagedDataReader : IGenericReader public TimeSpan AnchoredTimeShift { get; set; } + public DateTime AnchoredTimeOrigin { get; set; } + /// /// Read bits of data raw from a serialized file using Little-endian. /// diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 3730f1dc8..49a152e28 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -37,10 +37,10 @@ - + - - + +
diff --git a/Projects/Server/TileMatrix/TileMatrix.cs b/Projects/Server/TileMatrix/TileMatrix.cs index 333cea53e..70303a8d7 100644 --- a/Projects/Server/TileMatrix/TileMatrix.cs +++ b/Projects/Server/TileMatrix/TileMatrix.cs @@ -220,11 +220,17 @@ public class TileMatrix public StaticTile[][][] GetStaticBlock(int x, int y) { - if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight || DataStream == null || IndexStream == null) + if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight) { return _emptyStaticBlock; } + // A map without static files (tests, generated maps) serves only what was injected. + if (DataStream == null || IndexStream == null) + { + return _staticTiles[x]?[y] ?? _emptyStaticBlock; + } + _staticTiles[x] ??= new StaticTile[BlockHeight][][][]; var tiles = _staticTiles[x][y]; @@ -292,11 +298,17 @@ public class TileMatrix public LandTile[] GetLandBlock(int x, int y) { - if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight || MapStream == null) + if (x < 0 || y < 0 || x >= BlockWidth || y >= BlockHeight) { return _invalidLandBlock; } + // A map without a map file (tests, generated maps) serves only what was injected. + if (MapStream == null) + { + return _landTiles[x]?[y] ?? _invalidLandBlock; + } + _landTiles[x] ??= new LandTile[BlockHeight][]; var tiles = _landTiles[x][y]; diff --git a/Projects/Server/Timer/Timer.TimerWheel.cs b/Projects/Server/Timer/Timer.TimerWheel.cs index 2ab3ce547..0395bf3fe 100644 --- a/Projects/Server/Timer/Timer.TimerWheel.cs +++ b/Projects/Server/Timer/Timer.TimerWheel.cs @@ -321,13 +321,14 @@ public partial class Timer for (var i = 0; i < _ringSize; i++) { var node = t[i]; - Timer next; - do + while (node != null) { - next = node?._nextTimer; - node?.Stop(); - } while (next != null); + // Stop() unlinks the node, so read its successor first. + var next = node._nextTimer; + node.Stop(); + node = next; + } } } } diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 7ce1b52f2..7e968b925 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -108,6 +108,12 @@ public static class World /// payloads, which carry no anchor of their own. Zero for saves that predate the anchor. /// public static TimeSpan LoadTimeShift { get; internal set; } + + /// + /// UTC start time of the save being loaded, read from its index anchor. Default for saves + /// that predate the anchor. + /// + public static DateTime LoadedSaveStartTime { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; @@ -674,6 +680,7 @@ public static class World } item.ClearProperties(); + item.CreditDowntimeToDecay(LoadedSaveStartTime, LoadTimeShift); item.UpdateDecayRegistration(); } } diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index bca909a3e..ddded5fdd 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -1,5 +1,4 @@ using System; -using System.IO; using System.Reflection; using System.Threading; using Server.Items; @@ -7,6 +6,7 @@ using Server.Misc; using Server.Movement; using Server.PathAlgorithms; using Server.Tests.Maps; +using Server.Tests.Terrain; namespace Server.Tests; @@ -24,19 +24,15 @@ namespace Server.Tests; /// single time regardless of how many collection fixtures are constructed. The two stateful /// collections use [CollectionDefinition(DisableParallelization = true)] so they never /// overlap; pure tests still run in parallel. +/// +/// The host runs on generated tile data, terrain and multi footprints (see ), +/// never on client files, so every machine and CI run the same world. /// internal static class TestServerInitializer { private static bool _initialized; private static readonly Lock _lock = new(); - /// - /// True if the UO client tile data was found and loaded. When false (e.g. CI, where the - /// copyrighted client files are absent), tile/map/multi-dependent tests must skip rather than - /// fail. Guard such tests with Skip.If(!TestServerInitializer.TileDataLoaded, ...). - /// - public static bool TileDataLoaded { get; private set; } - public static void Initialize() { lock (_lock) @@ -53,10 +49,9 @@ internal static class TestServerInitializer ServerConfiguration.Load(true); ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory); - // Required for the pathfinding tests (real .mul tile data). Harmless for the rest. - var clientFiles = Environment.GetEnvironmentVariable("MODERNUO_TEST_DATA_DIR") - ?? @"C:\Ultima Online Classic"; - ServerConfiguration.DataDirectories.Add(clientFiles); + // TileData's static cctor short-circuits under xUnit, so the generated tiledata.mul must load + // before anything reads LandTable/ItemTable (the multi footprints, MovementImpl, CheckMovement). + SyntheticWorld.LoadData(); AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); @@ -72,16 +67,7 @@ internal static class TestServerInitializer Timer.Init(0); Server.Network.NetState.Configure(); TestMapDefinitions.ConfigureTestMapDefinitions(); - - // TileData's static cctor short-circuits when running under xUnit - // (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable - // flags are populated before anything that reads TileData (MultiData, MovementImpl, - // CheckMovement). Without this, TileData.MaxItemValue is 0 at MultiData.Configure() - // time, causing every MCL tile ID to be masked to 0 and stored as ID=0 in Tiles[x][y]. - // The copyrighted client files are absent on CI; when tiledata.mul is missing we skip - // the tile/map/multi-dependent bootstrap and leave TileDataLoaded false so those tests - // skip instead of failing the whole collection from the fixture constructor. - TileDataLoaded = TryForceLoadTileData(); + SyntheticWorld.StampTerrain(); // Production runs every static Configure() via AssemblyHandler.Invoke("Configure"); // the fixture calls a curated subset, so configure the pathfinding singleton here so @@ -89,16 +75,6 @@ internal static class TestServerInitializer // calls Find. ServerConfiguration is already loaded above, so the setting resolves. BitmapAStarAlgorithm.Configure(); - if (TileDataLoaded) - { - // Multi component lists (multi.mul / MultiCollection.uop). Production invokes this via - // AssemblyHandler.Invoke("Configure"); the curated fixture subset must call it so that - // BaseMulti.Components (MultiData.GetComponents) returns real footprints instead of - // MultiComponentList.Empty. Required by the Multi pathfinding tests. Depends on the - // client files, so it only runs when tile data loaded. - MultiData.Configure(); - } - World.Configure(); // Registers the Accounts entity persistence; without it no test can construct an Account. Server.Accounting.Accounts.Configure(); @@ -113,55 +89,7 @@ internal static class TestServerInitializer Server.Mobiles.NPCSpeeds.Configure(); Server.Engines.Spawners.SpawnerJsonSerializer.Configure(); - if (TileDataLoaded) - { - VerifyTrammelTileDataLoaded(); - } - _initialized = true; } } - - private static bool TryForceLoadTileData() - { - var tileDataPath = Core.FindDataFile("tiledata.mul", false); - if (string.IsNullOrEmpty(tileDataPath) || !File.Exists(tileDataPath)) - { - return false; - } - - var loadMethod = typeof(TileData).GetMethod( - "Load", - BindingFlags.Static | BindingFlags.NonPublic - ); - if (loadMethod == null) - { - throw new InvalidOperationException( - "TileData.Load not found via reflection — engine may have refactored." - ); - } - loadMethod.Invoke(null, null); - return true; - } - - private static void VerifyTrammelTileDataLoaded() - { - var trammel = Map.Maps[1]; - if (trammel == null) - { - throw new InvalidOperationException( - "Trammel (mapId=1) was not registered. Check TestMapDefinitions." - ); - } - - var tile = trammel.Tiles.GetLandTile(1500, 1600); - if (tile.ID == 0) - { - throw new InvalidOperationException( - $"Trammel tile data did not load — GetLandTile(1500,1600) returned ID 0. " + - $"Verify Distribution/Data/map1*.mul (or map1LegacyMUL.uop) is present at " + - $"{Path.Combine(Core.BaseDirectory, "Data")}." - ); - } - } } diff --git a/Projects/UOContent.Tests/Fixtures/TileDataRequirement.cs b/Projects/UOContent.Tests/Fixtures/TileDataRequirement.cs deleted file mode 100644 index 7b9af1f27..000000000 --- a/Projects/UOContent.Tests/Fixtures/TileDataRequirement.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Xunit; - -namespace Server.Tests; - -/// -/// Shared guard for tests that require the copyrighted UO client tile/map data (tiledata.mul and -/// friends), which is absent on CI. Call as the first statement of a -/// [SkippableFact]/[SkippableTheory] so the test is skipped — not failed — when the -/// data was not loaded. See . -/// -internal static class TileDataRequirement -{ - public static void SkipIfMissing() => - Skip.If( - !TestServerInitializer.TileDataLoaded, - "Requires UO client tile data (tiledata.mul); absent on CI." - ); -} diff --git a/Projects/UOContent.Tests/Helpers/AiTestStubs.cs b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs new file mode 100644 index 000000000..6e69523b8 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs @@ -0,0 +1,22 @@ +using Server.Mobiles; + +namespace Server.Tests; + +/// +/// A plain : walker, target or bystander. Deriving straight from Mobile rather +/// than BaseCreature is the point: MovementImpl sees no creature capabilities (no swim, no fly, no +/// door-opening), which is exactly the walker the step cache bakes for. +/// +public sealed class WalkerStub : Mobile +{ + public WalkerStub() => Body = 0xC9; +} + +/// +/// A BaseCreature built through the Serial constructor, which skips the NPCSpeeds lookup and +/// leaves AIObject unset: call and attach an AI by hand. +/// +public sealed class BareCreatureStub : BaseCreature +{ + public BareCreatureStub(Serial serial) : base(serial) => Body = 0xC9; +} diff --git a/Projects/UOContent.Tests/Helpers/CommandTestHarness.cs b/Projects/UOContent.Tests/Helpers/CommandTestHarness.cs new file mode 100644 index 000000000..61ee1f6b3 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/CommandTestHarness.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using Server.Commands; +using Server.Items; + +namespace Server.Tests; + +/// +/// Shared setup for the generic-command compiler tests: teleporters to run conditions, sorts and +/// distincts against, deleted on dispose, and property bindings resolved for reading. +/// +internal sealed class CommandTestHarness : IDisposable +{ + private readonly List _items = []; + + public T Track(T item) where T : Item + { + _items.Add(item); + return item; + } + + public SkillTeleporter Teleporter(TextDefinition message) => Track(new SkillTeleporter { Message = message }); + + public static Property Bind(Type type, string binding) + { + var prop = new Property(binding); + prop.BindTo(type, PropertyAccess.Read); + return prop; + } + + public void Dispose() + { + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + + _items.Clear(); + } +} diff --git a/Projects/UOContent.Tests/Helpers/MobileTestHarness.cs b/Projects/UOContent.Tests/Helpers/MobileTestHarness.cs new file mode 100644 index 000000000..30b86f6d6 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/MobileTestHarness.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using Server.Mobiles; + +namespace Server.Tests; + +/// +/// Owns the mobiles and items a test creates and deletes them on dispose, so a failed assertion +/// cannot leave entities registered in the shared world. Hold one per test class instance and +/// dispose it from the class's . +/// +internal sealed class MobileTestHarness : IDisposable +{ + private readonly List _mobiles = []; + private readonly List _items = []; + + public T Track(T mobile) where T : Mobile + { + _mobiles.Add(mobile); + return mobile; + } + + public T TrackItem(T item) where T : Item + { + _items.Add(item); + return item; + } + + /// + /// A player registered in the world table, so serialized references to it resolve on load. + /// + public PlayerMobile NewPlayer() + { + var pm = new PlayerMobile(World.NewMobile); + pm.DefaultMobileInit(); + World.AddEntity(pm); + return Track(pm); + } + + public TestCreature NewCreature() => Track(new TestCreature()); + + public void Dispose() + { + for (var i = 0; i < _mobiles.Count; i++) + { + _mobiles[i].Delete(); + } + + for (var i = 0; i < _items.Count; i++) + { + _items[i].Delete(); + } + } +} + +/// +/// A plain melee creature. Its speeds are pinned so tests do not depend on the npc-speeds table. +/// +internal class TestCreature : BaseCreature +{ + public TestCreature() : base(AIType.AI_Melee) => Body = 0xC9; + + public TestCreature(Serial serial) : base(serial) => Body = 0xC9; + + public DateTime SummonEndValue => SummonEnd; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + + public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + activeMoveSpeed = 0.6; + passiveMoveSpeed = 1.2; + } +} + +/// +/// A vendor with no outfit and pinned speeds. Subclasses stock it by overriding +/// , which runs from the base constructor. +/// +internal class TestVendor : BaseVendor +{ + // Initialized before the base constructor calls InitSBInfo. + private readonly List _sbInfos = []; + + public TestVendor() : base("the stub") + { + } + + public TestVendor(Serial serial) : base(serial) + { + } + + protected override List SBInfos => _sbInfos; + + public override void InitSBInfo() => AddSBInfos(_sbInfos); + + protected virtual void AddSBInfos(List sbInfos) + { + } + + public override void InitOutfit() + { + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } +} diff --git a/Projects/UOContent.Tests/Helpers/StepCacheStateScope.cs b/Projects/UOContent.Tests/Helpers/StepCacheStateScope.cs new file mode 100644 index 000000000..39e22fb63 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/StepCacheStateScope.cs @@ -0,0 +1,45 @@ +using System; +using Server.Engines.Pathing.Cache; +using Server.Systems.FeatureFlags; + +namespace Server.Tests; + +/// +/// Snapshots the pathfinding cache's tuning knobs and feature flag, and restores them on dispose +/// along with empty caches. StepCache and MultiMaskCache are process-wide singletons and +/// leaves the knobs alone, so a test that tunes one would otherwise +/// leak it into every test that runs after it. +/// +internal sealed class StepCacheStateScope : IDisposable +{ + private readonly int _missPromotionThreshold; + private readonly uint _missPromotionWindowMs; + private readonly int _maxResidentChunks; + private readonly bool _preloadOnLazyOpen; + private readonly bool _cacheEnabled; + + public StepCacheStateScope() + { + var cache = StepCache.Instance; + _missPromotionThreshold = cache.MissPromotionThreshold; + _missPromotionWindowMs = cache.MissPromotionWindowMs; + _maxResidentChunks = cache.MaxResidentChunks; + _preloadOnLazyOpen = cache.PreloadOnLazyOpen; + _cacheEnabled = ContentFeatureFlags.BitmapPathfindingCache; + } + + public void Dispose() + { + var cache = StepCache.Instance; + + // Clear first: it closes any lazy reader a test opened under the knobs it set. + cache.Clear(); + MultiMaskCache.Instance.Clear(); + + cache.MissPromotionThreshold = _missPromotionThreshold; + cache.MissPromotionWindowMs = _missPromotionWindowMs; + cache.MaxResidentChunks = _maxResidentChunks; + cache.PreloadOnLazyOpen = _preloadOnLazyOpen; + ContentFeatureFlags.BitmapPathfindingCache = _cacheEnabled; + } +} diff --git a/Projects/UOContent.Tests/Helpers/TempDirectory.cs b/Projects/UOContent.Tests/Helpers/TempDirectory.cs new file mode 100644 index 000000000..a88f54d19 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/TempDirectory.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; + +namespace Server.Tests; + +/// +/// A uniquely named directory under the system temp path, deleted with its contents on dispose so a +/// failing assertion cannot leave files behind. +/// +internal sealed class TempDirectory : IDisposable +{ + public TempDirectory(string prefix = "modernuo-test") + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{prefix}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string Combine(string relativePath) => System.IO.Path.Combine(Path, relativePath); + + /// Writes to and returns its full path. + public string WriteFile(string relativePath, string content) + { + var path = Combine(relativePath); + File.WriteAllText(path, content); + return path; + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, true); + } + } + catch (IOException) + { + // A handle still open on Windows; the OS temp cleaner reclaims it. + } + } +} diff --git a/Projects/UOContent.Tests/Helpers/TimerWheelScope.cs b/Projects/UOContent.Tests/Helpers/TimerWheelScope.cs new file mode 100644 index 000000000..daabd3188 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/TimerWheelScope.cs @@ -0,0 +1,140 @@ +using System; +using System.Reflection; + +namespace Server.Tests; + +/// +/// Gives a test its own simulated clock and an empty timer wheel, and puts the process-wide ones +/// back on dispose. Core's tick, Core.Now and the wheel are statics shared by every test in the +/// host, so a test that resets them without restoring hands its clock to whatever runs next. +/// +/// +/// Timers started inside the scope are stopped on dispose; timers that were scheduled before it +/// resume on the restored wheel. +/// +internal sealed class TimerWheelScope : IDisposable +{ + private const BindingFlags PrivateStatic = BindingFlags.NonPublic | BindingFlags.Static; + + private static readonly FieldInfo _ringsField = GetTimerField("_rings"); + private static readonly FieldInfo _ringIndexesField = GetTimerField("_ringIndexes"); + private static readonly FieldInfo _lastTickTurnedField = GetTimerField("_lastTickTurned"); + + private readonly long _savedTick; + private readonly DateTime _savedNow; + private readonly Timer[][] _savedRings; + private readonly int[] _savedRingIndexes; + private readonly long _savedLastTickTurned; + private bool _disposed; + + public TimerWheelScope(long startTick = 0) + { + _savedTick = Core._tickCount; + _savedNow = Core._now; + _savedRings = (Timer[][])Rings.Clone(); + _savedRingIndexes = (int[])RingIndexes.Clone(); + _savedLastTickTurned = (long)_lastTickTurnedField.GetValue(null)!; + + Core._tickCount = startTick; + Timer.Init(startTick); + } + + private static Timer[][] Rings => (Timer[][])_ringsField.GetValue(null)!; + + private static int[] RingIndexes => (int[])_ringIndexesField.GetValue(null)!; + + /// The wheel's tick rate: one turn per call keeps it in lockstep with Core.TickCount. + public static int Step => Timer.TickRate; + + /// Advances the clock by , turning the wheel every step. + public static void RunFor(long ms) + { + var deadline = Core._tickCount + ms; + + while (Core._tickCount - deadline < 0) + { + Core._tickCount += Step; + Timer.Slice(Core._tickCount); + } + } + + /// + /// Advances the clock step by step until holds or + /// has passed. Returns the condition's final value. + /// + public static bool RunUntil(Func condition, long maxMs) + { + var deadline = Core._tickCount + maxMs; + + while (Core._tickCount - deadline < 0) + { + if (condition()) + { + return true; + } + + Core._tickCount += Step; + Timer.Slice(Core._tickCount); + } + + return condition(); + } + + /// Jumps the tick and Core.Now forward together, then fires everything that came due. + public static void Advance(TimeSpan time) + { + Core._now += time; + Core._tickCount += (long)time.TotalMilliseconds; + Timer.Slice(Core._tickCount); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + StopAll(Rings); + + var rings = Rings; + for (var i = 0; i < rings.Length; i++) + { + rings[i] = _savedRings[i]; + } + + Array.Copy(_savedRingIndexes, RingIndexes, _savedRingIndexes.Length); + _lastTickTurnedField.SetValue(null, _savedLastTickTurned); + + Core._tickCount = _savedTick; + Core._now = _savedNow; + } + + // Stop() unlinks the slot's head and promotes the next timer, so draining heads empties a slot. + private static void StopAll(Timer[][] rings) + { + for (var i = 0; i < rings.Length; i++) + { + var ring = rings[i]; + for (var slot = 0; slot < ring.Length; slot++) + { + Timer head; + while ((head = ring[slot]) != null) + { + head.Stop(); + + if (ring[slot] == head) + { + // A head that was already stopped never unlinks itself. + ring[slot] = null; + } + } + } + } + } + + private static FieldInfo GetTimerField(string name) => + typeof(Timer).GetField(name, PrivateStatic) + ?? throw new InvalidOperationException($"Timer.{name} not found via reflection; the timer wheel was refactored."); +} diff --git a/Projects/UOContent.Tests/Tests/Accounting/AccountHandlerTests.cs b/Projects/UOContent.Tests/Tests/Accounting/AccountHandlerTests.cs index 0520f411e..c4970c98a 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/AccountHandlerTests.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/AccountHandlerTests.cs @@ -20,15 +20,4 @@ public class AccountHandlerTests { Assert.Equal(expected, AccountHandler.IsValidUsername(username)); } - - [Theory] - [InlineData("", false)] // Empty password - [InlineData("ValidPass123!", true)] // Standard Password - [InlineData(" ", true)] // Single space - [InlineData("ValidPass!@#", true)] // Valid special characters - [InlineData("ValidPassWithLength1234567890", true)] // Long valid password - public void IsValidPassword_ValidatesCorrectly(string password, bool expected) - { - Assert.Equal(expected, AccountHandler.IsValidPassword(password)); - } } diff --git a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs index 29a1b818f..c0901ca48 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs @@ -1,6 +1,7 @@ using System; using Server.Accounting; using Server.Accounting.Security; +using Server.Tests.Accounting.Security; using Xunit; namespace Server.Tests.Accounting; @@ -64,7 +65,9 @@ public class AccountPasswordTests : IDisposable "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; Assert.True(account.CheckPassword(Password)); - Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", account.Password); + // The cost parameters are shard-tunable; the variant and version are not. + Assert.StartsWith("$argon2id$v=19$", account.Password); + Assert.StartsWith(Argon2TestParameters.CurrentPrefix, account.Password); // Already current: verifying again must not rewrite the hash. var afterFirst = account.Password; diff --git a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs index 5972e5eb9..3412feab9 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Threading; using Server.Accounting; using Server.Accounting.Security; +using Server.Tests.Accounting.Security; using Xunit; namespace Server.Tests.Accounting; @@ -13,12 +15,27 @@ public class PasswordWorkerTests : IDisposable private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; + // The constructor registers each account in the process-wide Accounts table. + private readonly List _created = []; + public PasswordWorkerTests() => AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; - public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } - private static Account CreateAccount(string username) => - Accounts.GetAccount(username) as Account ?? new Account(username, Password); + AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + } + + private Account CreateAccount(string username) + { + var account = new Account(username, Password); + _created.Add(account); + return account; + } /// /// Enqueues work, then pumps the loop context until or the deadline. @@ -136,7 +153,8 @@ public class PasswordWorkerTests : IDisposable var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); Assert.True(outcome.Verified); - Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", outcome.Hash); + Assert.StartsWith("$argon2id$v=19$", outcome.Hash); + Assert.StartsWith(Argon2TestParameters.CurrentPrefix, outcome.Hash); } [Fact] @@ -152,18 +170,6 @@ public class PasswordWorkerTests : IDisposable Assert.Null(outcome.Hash); } - [Fact] - public void AppliesAWrite() - { - var account = CreateAccount("offloop-apply-user"); - var upgraded = Argon2PasswordProtection.Instance.EncryptPassword(Password); - - account.ApplyPasswordWrite(upgraded, PasswordProtectionAlgorithm.Argon2); - - Assert.Equal(upgraded, account.Password); - Assert.True(account.CheckPassword(Password)); - } - /// /// Writes apply in dispatch order, which is what makes a guard unnecessary: dispatch is on the /// loop, one worker drains FIFO, and results return through the loop context in that same order. @@ -217,13 +223,4 @@ public class PasswordWorkerTests : IDisposable AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; Assert.Equal(Password, account.GetRehashPhrase(Password)); } - - [Fact] - public void UsesTheBarePasswordForArgon2Accounts() - { - var account = CreateAccount("offloop-phrase-argon2-user"); - - Assert.Equal(Password, account.GetVerifyPhrase(Password)); - Assert.Equal(Password, account.GetRehashPhrase(Password)); - } } diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/Argon2TestParameters.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/Argon2TestParameters.cs new file mode 100644 index 000000000..8e62573ac --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/Argon2TestParameters.cs @@ -0,0 +1,55 @@ +using System; +using Server.Accounting.Security; + +namespace Server.Tests.Accounting.Security; + +/// +/// The PHC parameter prefix the configured writes, read from +/// a real hash so a shard that tunes the Argon2 cost does not break assertions about the format. +/// +internal static class Argon2TestParameters +{ + /// For example $argon2id$v=19$m=16384,t=1,p=1$, ending at the salt. + public static readonly string CurrentPrefix = ReadPrefix(); + + public static string Type => CurrentPrefix.Split('$')[1]; + + public static string Version => CurrentPrefix.Split('$')[2]; + + public static int Memory => ReadCost('m'); + + public static int Time => ReadCost('t'); + + public static int Parallelism => ReadCost('p'); + + public static string BuildPrefix(string type, int memory, int time, int parallelism) => + $"${type}${Version}$m={memory},t={time},p={parallelism}$"; + + private static string ReadPrefix() + { + var hash = Argon2PasswordProtection.Instance.EncryptPassword("prefix-probe"); + + // $type$version$params$salt$digest: the prefix runs through the fourth '$'. + var end = 0; + for (var i = 0; i < 4; i++) + { + end = hash.IndexOf('$', end) + 1; + } + + return hash[..end]; + } + + private static int ReadCost(char key) + { + var costs = CurrentPrefix.Split('$')[3].Split(','); + for (var i = 0; i < costs.Length; i++) + { + if (costs[i][0] == key) + { + return int.Parse(costs[i].AsSpan(2)); + } + } + + return -1; + } +} diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index a0ef97a56..382a4bc0d 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -113,34 +113,60 @@ public class PasswordProtectionTest Assert.False(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "wrong")); } - [Theory] - // type, memory, time, parallelism -> expected NeedsRehash - [InlineData("argon2id", 16384, 1, 1, false)] // current defaults - [InlineData("argon2i", 8192, 3, 1, true)] // the old shipping default - [InlineData("argon2id", 8192, 1, 1, true)] // right type, stale memory - [InlineData("argon2id", 16384, 3, 1, true)] // right type, stale iterations - [InlineData("argon2id", 16384, 1, 2, true)] // right type, stale parallelism - [InlineData("argon2i", 16384, 1, 1, true)] // right cost, stale type - public void Argon2_NeedsRehash_ComparesTypeAndCost( - string type, int memory, int time, int parallelism, bool expected - ) - { - var hash = $"${type}$v=19$m={memory},t={time},p={parallelism}$" + - "LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + private const string SaltAndDigest = "LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; - Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash)); + [Fact] + public void Argon2_NeedsRehash_IsFalseForCurrentParameters() + { + var hash = Argon2TestParameters.CurrentPrefix + SaltAndDigest; + + Assert.False(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + // Each case differs from the configured parameters in exactly one field, so the test holds + // whatever cost a shard configures. + [Theory] + [InlineData("type")] + [InlineData("memory")] + [InlineData("time")] + [InlineData("parallelism")] + public void Argon2_NeedsRehash_ComparesTypeAndCost(string staleField) + { + var type = Argon2TestParameters.Type; + var memory = Argon2TestParameters.Memory; + var time = Argon2TestParameters.Time; + var parallelism = Argon2TestParameters.Parallelism; + var otherType = type == "argon2id" ? "argon2i" : "argon2id"; + + (type, memory, time, parallelism) = staleField switch + { + "type" => (otherType, memory, time, parallelism), + "memory" => (type, memory * 2, time, parallelism), + "time" => (type, memory, time + 1, parallelism), + _ => (type, memory, time, parallelism + 1) + }; + + var hash = Argon2TestParameters.BuildPrefix(type, memory, time, parallelism) + SaltAndDigest; + + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + [Fact] + public void Argon2_NeedsRehash_IsTrueForLegacyShippingDefault() + { + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(LegacyArgon2iHash)); } // Digest and salt lengths are decoded base64 sizes rather than parameter-list entries, so they // need their own literals. Current type and cost throughout; only a length differs. [Theory] // 16-byte digest: 22 base64 chars instead of the 43 a 32-byte digest encodes to. - [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] + [InlineData("LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] // 8-byte salt: 11 base64 chars instead of the 22 a 16-byte salt encodes to. - [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQ$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw")] - public void Argon2_NeedsRehash_ComparesSaltAndDigestLengths(string hash) + [InlineData("LD1XJz7P3wQ$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw")] + public void Argon2_NeedsRehash_ComparesSaltAndDigestLengths(string saltAndDigest) { - Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(Argon2TestParameters.CurrentPrefix + saltAndDigest)); } [Theory] @@ -150,13 +176,4 @@ public class PasswordProtectionTest { Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); } - - [Fact] - public void NonArgon2Protections_NeverNeedRehash() - { - Assert.False(PBKDF2PasswordProtection.Instance.NeedsRehash("anything")); - Assert.False(HashAlgorithmPasswordProtection.SHA2Instance.NeedsRehash("anything")); - Assert.False(HashAlgorithmPasswordProtection.SHA1Instance.NeedsRehash("anything")); - Assert.False(HashAlgorithmPasswordProtection.MD5Instance.NeedsRehash("anything")); - } } diff --git a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs index a7c266589..dbeba969e 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -4,6 +4,7 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; @@ -14,31 +15,13 @@ namespace UOContent.Tests.Commands; [Collection("Sequential UOContent Tests")] public class ChainedBindingSortTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - _items.Clear(); - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } - - private static Property Bind(string binding) - { - var prop = new Property(binding); - prop.BindTo(typeof(SkillTeleporter), PropertyAccess.Read); - return prop; - } + private static Property Bind(string binding) => CommandTestHarness.Bind(typeof(SkillTeleporter), binding); private static IComparer Sorter(string binding) => SortCompiler.Compile( @@ -107,21 +90,4 @@ public class ChainedBindingSortTests : IDisposable Assert.True(comparer.Compare(blank, valued) < 0); Assert.True(comparer.Compare(valued, blank) > 0); } - - // An unchained binding never had the problem and must keep working untouched. - [Fact] - public void SortingOnAPlainBindingIsUnchanged() - { - var low = Teleporter(null); - low.Hue = 1; - - var high = Teleporter(null); - high.Hue = 2; - - var comparer = Sorter("Hue"); - - Assert.True(comparer.Compare(low, high) < 0); - Assert.True(comparer.Compare(high, low) > 0); - Assert.Equal(0, comparer.Compare(low, low)); - } } diff --git a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs index f430973a9..31b1de29d 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs @@ -4,42 +4,25 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; -// Two shapes the IL emitter got wrong or could not express. A chained binding ending in a value -// type (Message.Number) reused a temp while its value was still live, so every pair compared -// equal and `sort by` was a no-op. A struct with no CompareTo fell through to a raw Ceq, which is -// not valid IL for a non-primitive struct. Both are pinned here against the expression-tree port. +// Two shapes the compiled comparers must handle. A chained binding ending in a value type +// (Message.Number) must order and separate by that value, or every pair compares equal and +// `sort by` does nothing. A struct with no CompareTo compares by value equality for == and !=, +// and the relational operators are rejected when the condition is compiled. [Collection("Sequential UOContent Tests")] public class ConditionalCompilerEdgeTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - _items.Clear(); - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } - - private static Property Bind(Type type, string binding) - { - var prop = new Property(binding); - prop.BindTo(type, PropertyAccess.Read); - return prop; - } + private static Property Bind(Type type, string binding) => CommandTestHarness.Bind(type, binding); [Fact] public void SortingOnAChainedValueTypeOrdersValues() diff --git a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs index e1068f6e2..c135480da 100644 --- a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs @@ -4,6 +4,7 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; @@ -14,23 +15,14 @@ namespace UOContent.Tests.Commands; [Collection("Sequential UOContent Tests")] public class DistinctComparerTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } - - _items.Clear(); - } + public void Dispose() => _harness.Dispose(); private SkillTeleporter Teleporter(Action setup = null) { - var tp = new SkillTeleporter(); + var tp = _harness.Track(new SkillTeleporter()); setup?.Invoke(tp); - _items.Add(tp); return tp; } @@ -40,8 +32,7 @@ public class DistinctComparerTests : IDisposable for (var i = 0; i < bindings.Length; i++) { - props[i] = new Property(bindings[i]); - props[i].BindTo(typeof(SkillTeleporter), PropertyAccess.Read); + props[i] = CommandTestHarness.Bind(typeof(SkillTeleporter), bindings[i]); } return (IEqualityComparer)DistinctCompiler.Compile(typeof(SkillTeleporter), props); diff --git a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs index 997d8b79f..e8cef86c6 100644 --- a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs @@ -6,10 +6,8 @@ using Xunit; namespace UOContent.Tests.Commands; -// The conditional compiler emits into a fresh dynamic assembly for every command invocation -- -// there is no per-type cache, so `Run` (which can never be unloaded) grows the process by one -// assembly per `[global where`. And PropertyValue could only load a handful of primitive constant -// types, so a comparison against anything narrower or unsigned than int threw instead of running. +// A comparison constant is converted to the property's own type, however narrow or unsigned, so +// conditions on byte, sbyte, short, ushort, uint and ulong properties run and compare correctly. public class EmitterRobustnessTests { public class Subject @@ -79,25 +77,4 @@ public class EmitterRobustnessTests Assert.True(Check("Big", ComparisonOperator.Greater, "2147483647")); Assert.True(Check("Huge", ComparisonOperator.Greater, "9223372036854775807")); } - - [Fact] - public void EmittedAssembliesAreCollectible() - { - var prop = new Property("Tiny"); - prop.BindTo(typeof(Subject), PropertyAccess.Read); - - var compiled = ConditionalCompiler.Build( - typeof(Subject), - [TypeCondition.Default, new ComparisonCondition(prop, false, ComparisonOperator.Equal, "5")] - ).Compile(); - - var method = compiled.Method; - - Assert.True( - method.IsCollectible, - "The conditional compiler compiles one delegate per command invocation; code that is " - + $"not collectible ({method.GetType().Name} in {method.Module}) can never be unloaded, " - + "so every [global where would grow the process for good." - ); - } } diff --git a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs index 3d43eca42..3088e2d26 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs @@ -3,37 +3,27 @@ using System.Collections.Generic; using Server; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; -// `where` compiles conditions to IL. Two shapes were broken there: a property type with value -// equality but no IComparable fell through to a raw Ceq (reference equality, so never true), and -// a chained binding dereferenced every link with no null guard (so the first object with a null -// intermediate took the whole sweep down with an NRE). +// `where` compiles conditions into a delegate. A property type with value equality but no +// IComparable compares by value, not reference identity, and a chained binding with a null +// intermediate makes the condition false rather than throwing and ending the whole sweep. [Collection("Sequential UOContent Tests")] public class ObjectConditionalTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); private readonly Mobile _from = new() { AccessLevel = AccessLevel.Developer }; public void Dispose() { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } - - _items.Clear(); + _harness.Dispose(); _from.Delete(); } - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); private bool Check(object target, params string[] condition) { diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs index df795a440..cbd818c40 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs @@ -58,8 +58,7 @@ public class AdvancedSearchConditionsTests [InlineData("Hue")] // no operator public void UnusableLeafIsNoMatchAndDoesNotThrow(string test) { - var ex = Record.Exception(() => Assert.False(Check(test))); - Assert.Null(ex); + Assert.False(Check(test)); } // The old evaluator negated a failed comparison, so `~Hue=abc` matched every entity. @@ -195,8 +194,7 @@ public class AdvancedSearchConditionsTests [Fact] public void ReferenceTypeOrderingIsNoMatchAndDoesNotThrow() { - var ex = Record.Exception(() => Assert.False(Check("Reference>whatever"))); - Assert.Null(ex); + Assert.False(Check("Reference>whatever")); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs index 36f97eaf0..a7149eeae 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace UOContent.Tests; +// Registers the poison table, which is process-wide, so this stays in the sequential collection. [Collection("Sequential UOContent Tests")] public class AdvancedSearchTypesTests { @@ -15,7 +16,11 @@ public class AdvancedSearchTypesTests [Fact] public void Poison_ReferenceTypeParsedViaTypes() { - PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set + // The fixture does not run PoisonKinds.Configure, and a second registration throws. + if (Poison.Poisons.Count == 0) + { + PoisonKinds.Configure(); + } // Poison is a reference type implementing ISpanParsable; its value routes through the shared // Server.Types converter. Poison.Parse returns the registered singleton, so "= Lethal" is a @@ -26,8 +31,6 @@ public class AdvancedSearchTypesTests Assert.True(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lethal")(subject)); Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lesser")(subject)); - var ex = Record.Exception(() => - Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject))); - Assert.Null(ex); + Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject)); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs b/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs new file mode 100644 index 000000000..07abaa538 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server; +using Server.Engines.CannedEvil; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Engines.CannedEvil; + +// Minions summoned by a champion belong to its spawn, so clearing the spawn removes them instead of +// leaving them behind for good. +[Collection("Sequential UOContent Tests")] +public class ChampionMinionTests : IDisposable +{ + private static readonly Point3D _location = new(1500, 1500, 0); + + private readonly List _created = []; + + public void Dispose() + { + foreach (var m in World.Mobiles.Values.OfType().ToList()) + { + m.Delete(); + } + + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private ChampionSpawn NewSpawn() + { + var spawn = new ChampionSpawn(); + _created.Add(spawn); + spawn.MoveToWorld(_location, Map.Felucca); + spawn.SpawnArea = new Rectangle2D(new Point2D(_location.X - 24, _location.Y - 24), new Point2D(_location.X + 24, _location.Y + 24)); + return spawn; + } + + private LordOaks NewOaks() + { + var oaks = new LordOaks(); + _created.Add(oaks); + oaks.MoveToWorld(_location, Map.Felucca); + return oaks; + } + + private Mobile NewAttacker() + { + var attacker = new Mongbat(); + _created.Add(attacker); + return attacker; + } + + [Fact] + public void AChampionsMinion_IsDeletedWithTheSpawnsCreatures() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); + spawn.Champion = oaks; + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + Assert.Contains(queen, spawn.Minions); + + spawn.DeleteCreatures(); + + Assert.True(queen.Deleted); + Assert.Empty(spawn.Minions); + } + + [Fact] + public void AMinionOfAMinion_AlsoBelongsToTheSpawn() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); + spawn.Champion = oaks; + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + var pixie = new Pixie(); + _created.Add(pixie); + Assert.True(queen.SpawnMinion(pixie)); + + Assert.Contains(pixie, spawn.Minions); + } + + [Fact] + public void ACreatureOutsideAnySpawn_SummonsAStandaloneMinion() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); // in the spawn's region, but not its champion + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + spawn.DeleteCreatures(); + + Assert.False(queen.Deleted); + Assert.Null(ChampionSpawn.FindSpawnOf(oaks)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Craft/T2ACookingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Craft/T2ACookingTests.cs new file mode 100644 index 000000000..e0d551bed --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Craft/T2ACookingTests.cs @@ -0,0 +1,145 @@ +using Server; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class T2ACookingTests +{ + private static PlayerMobile CreatePlayer() + { + var m = new PlayerMobile(World.NewMobile); + m.DefaultMobileInit(); + m.MoveToWorld(new Point3D(4200, 500, 0), Map.Felucca); + m.AddItem(new Backpack()); + return m; + } + + [Fact] + public void FlourAndWaterPitcher_MakeDough() + { + var player = CreatePlayer(); + var pack = player.Backpack; + var sack = new SackFlour { ItemID = 0x103A }; + var pitcher = new Pitcher(BeverageType.Water); + pack.AddItem(sack); + pack.AddItem(pitcher); + + var sackBefore = sack.Quantity; + var pitcherBefore = pitcher.Quantity; + + Assert.True(sack.TryCombine(player, pitcher)); + Assert.Equal(sackBefore - 1, sack.Quantity); + Assert.Equal(pitcherBefore - 1, pitcher.Quantity); + Assert.Equal(1, pack.GetAmount(typeof(Dough))); + } + + [Fact] + public void ClosedFlourSack_IsRefused() + { + var player = CreatePlayer(); + var sack = new SackFlour(); + var pitcher = new Pitcher(BeverageType.Water); + player.Backpack.AddItem(sack); + player.Backpack.AddItem(pitcher); + + Assert.False(sack.TryCombine(player, pitcher)); + Assert.Equal(20, sack.Quantity); + Assert.Equal(pitcher.MaxQuantity, pitcher.Quantity); + Assert.Equal(0, player.Backpack.GetAmount(typeof(Dough))); + } + + [Fact] + public void DoughAndApple_MakeUnbakedApplePie() + { + var player = CreatePlayer(); + var dough = new Dough(); + var apple = new Apple(); + player.Backpack.AddItem(dough); + player.Backpack.AddItem(apple); + + Assert.True(dough.TryCombine(player, apple)); + Assert.Equal(1, player.Backpack.GetAmount(typeof(UnbakedApplePie))); + Assert.Equal(0, player.Backpack.GetAmount(typeof(Apple))); + Assert.Equal(0, player.Backpack.GetAmount(typeof(Dough))); + } + + [Fact] + public void HoneyRows_WorkInEitherDirection() + { + var player = CreatePlayer(); + var pack = player.Backpack; + var dough = new Dough(); + var honey = new JarHoney(); + pack.AddItem(dough); + pack.AddItem(honey); + Assert.True(dough.TryCombine(player, honey)); + Assert.Equal(1, pack.GetAmount(typeof(SweetDough))); + + var dough2 = new Dough(); + var honey2 = new JarHoney(); + pack.AddItem(dough2); + pack.AddItem(honey2); + Assert.True(honey2.TryCombine(player, dough2)); + Assert.Equal(2, pack.GetAmount(typeof(SweetDough))); + + var sweet = pack.FindItemByType(); + var honey3 = new JarHoney(); + pack.AddItem(honey3); + Assert.True(sweet.TryCombine(player, honey3)); + Assert.Equal(1, pack.GetAmount(typeof(CookieMix))); + } + + [Fact] + public void WrongTarget_ConsumesNothing() + { + var player = CreatePlayer(); + var dough = new Dough(); + var honey = new JarHoney(); + player.Backpack.AddItem(dough); + player.Backpack.AddItem(honey); + var apple = new Apple(); + player.Backpack.AddItem(apple); + + var pitcher = new Pitcher(BeverageType.Water); + player.Backpack.AddItem(pitcher); + + Assert.False(honey.TryCombine(player, apple)); + Assert.False(dough.TryCombine(player, pitcher)); + Assert.Equal(1, player.Backpack.GetAmount(typeof(Dough))); + Assert.Equal(1, player.Backpack.GetAmount(typeof(JarHoney))); + Assert.Equal(1, player.Backpack.GetAmount(typeof(Apple))); + } + + [Fact] + public void BowlFlourAndSweetDough_MakeCakeMix() + { + var player = CreatePlayer(); + var bowl = new BowlFlour(); + var sweetDough = new SweetDough(); + player.Backpack.AddItem(bowl); + player.Backpack.AddItem(sweetDough); + + Assert.True(sweetDough.TryCombine(player, bowl)); + Assert.True(bowl.Deleted); + Assert.True(sweetDough.Deleted); + Assert.Equal(1, player.Backpack.GetAmount(typeof(CakeMix))); + } + + [Fact] + public void WaterInAnotherPlayersPack_IsRefused() + { + var player = CreatePlayer(); + var other = CreatePlayer(); + var sack = new SackFlour { ItemID = 0x103A }; + var pitcher = new Pitcher(BeverageType.Water); + player.Backpack.AddItem(sack); + other.Backpack.AddItem(pitcher); + + Assert.False(sack.TryCombine(player, pitcher)); + Assert.Equal(20, sack.Quantity); + Assert.Equal(pitcher.MaxQuantity, pitcher.Quantity); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs b/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs new file mode 100644 index 000000000..8a50ef4ac --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs @@ -0,0 +1,92 @@ +using Server; +using Server.Engines.Craft; +using Server.Engines.Craft.T2A; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class T2AHuedFilterTests +{ + private static PlayerMobile CreatePlayer(Point3D location) + { + var m = new PlayerMobile(World.NewMobile); + m.DefaultMobileInit(); + m.MoveToWorld(location, Map.Felucca); + m.AddItem(new Backpack()); + return m; + } + + private static CraftItem MakeRecipe() + { + var item = new CraftItem(typeof(Shirt), "shirt", "shirt"); + item.AddRes(typeof(Cloth), "cloth", 8, "You do not have enough cloth."); + item.AddSkill(SkillName.Tailoring, 0.0, 100.0); + return item; + } + + private static CraftSystem GetTailoringSystem() + { + if (DefTailoring.CraftSystem == null) + { + DefTailoring.Initialize(); + } + + return DefTailoring.CraftSystem; + } + + [Fact] + public void CanCraftItem_WithHue_CountsOnlyMatchingHueCloth() + { + var player = CreatePlayer(new Point3D(4140, 500, 0)); + + try + { + var pack = player.Backpack; + pack.AddItem(new Cloth(5) { Hue = 0x21 }); + pack.AddItem(new Cloth(5) { Hue = 0x30 }); + + var system = GetTailoringSystem(); + var recipe = MakeRecipe(); + + // 10 cloth total, but no single hue reaches the 8 the recipe needs + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + + pack.AddItem(new Cloth(3) { Hue = 0x21 }); + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + } + finally + { + player.Delete(); + } + } + + [Fact] + public void CanCraftItem_WithHue_CountsSameHueUncutCloth() + { + var player = CreatePlayer(new Point3D(4140, 500, 0)); + + try + { + var pack = player.Backpack; + pack.AddItem(new Cloth(5) { Hue = 0x21 }); + pack.AddItem(new UncutCloth(3) { Hue = 0x21 }); + pack.AddItem(new UncutCloth(5) { Hue = 0x30 }); + + var system = GetTailoringSystem(); + var recipe = MakeRecipe(); + + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + } + finally + { + player.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs index addfea7d0..4927d6813 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs @@ -2,13 +2,19 @@ using System; using System.Collections.Generic; using Server; using Server.Engines.Events; +using Server.Tests; using Xunit; namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] -public class EventSchedulerTests +public class EventSchedulerTests : IDisposable { + // Init() pins the process-wide loop clock and timer wheel; every other test in the host reads them. + private readonly TimerWheelScope _clock = new(); + + public void Dispose() => _clock.Dispose(); + // Test implementations for controlled testing private class TestRecurrencePattern : IRecurrencePattern { @@ -58,7 +64,6 @@ public class EventSchedulerTests private static void Init() { Core._now = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); - Timer.Init(0); EventScheduler.Configure(); } @@ -148,39 +153,36 @@ public class EventSchedulerTests [Fact] public void Scheduler_HandlesRecurringEvents() { - for (var i = 0; i < 100_000; i++) + Init(); + + try { - Init(); + var callCount = 0; - try - { - var callCount = 0; + // Create a recurrence pattern that fires every 10 seconds, up to 3 times + var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3); - // Create a recurrence pattern that fires every 10 seconds, up to 3 times - var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3); + var evt = new TestScheduledEvent( + TimeOnly.FromDateTime(Core._now), + () => callCount++, + recurrence + ); - var evt = new TestScheduledEvent( - TimeOnly.FromDateTime(Core._now), - () => callCount++, - recurrence - ); + evt.Schedule(Core._now); - evt.Schedule(Core._now); + // Advance time to after all occurrences should have happened + Core._now = Core._now.AddSeconds(50); + Timer.Slice(8); - // Advance time to after all occurrences should have happened - Core._now = Core._now.AddSeconds(50); - Timer.Slice(8); + // One call per occurrence the pattern yields + Assert.Equal(3, callCount); + Assert.Equal(3, evt.CallCount); - // Should have fired 4 times (3 recurrences) - Assert.Equal(3, callCount); - Assert.Equal(3, evt.CallCount); - - evt.Cancel(); - } - finally - { - Finish(); - } + evt.Cancel(); + } + finally + { + Finish(); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs index b08a49007..70d06a86d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs @@ -225,10 +225,13 @@ public class RecurrencePatternTests } [Theory] - [InlineData("America/New_York", 2024, 3, 10, 2, 30)] // Spring forward - 2:30 AM doesn't exist - [InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - 1:30 AM happens twice + // 2:30 AM does not exist on the spring-forward day, so the occurrence moves to the next month. + [InlineData("America/New_York", 2024, 3, 10, 2, 30, 2024, 4, 10, 6, 30)] + // 1:30 AM happens twice on the fall-back day; LocalToUtc resolves it to the first (EDT) pass. + [InlineData("America/New_York", 2024, 11, 3, 1, 30, 2024, 11, 3, 5, 30)] public void MonthlyRecurrencePattern_DSTTransitions_HandlesCorrectly( - string tzId, int year, int month, int day, int hour, int minute) + string tzId, int year, int month, int day, int hour, int minute, + int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute) { // Arrange var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); @@ -238,39 +241,14 @@ public class RecurrencePatternTests var beforeUtc = localDateBefore.LocalToUtc(tz); var timeOnly = new TimeOnly(hour, minute); - // Act - shouldn't throw exceptions - var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz); - - // Assert - just make sure we got a valid result - Assert.NotEqual(DateTime.MaxValue, result); - - // For invalid times (spring forward), should skip to next valid time - var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz); - if (month == 3) // Spring forward - { - // Should skip the invalid 2:30 AM time - Assert.True(resultLocal.Hour >= 3 || resultLocal > new DateTime(year, month, day)); - } - } - - [Fact] - public void MonthlyOrdinalRecurrencePattern_DST_FallBack_HandlesAmbiguousTimeCorrectly() - { - // This tests specifically the DST fall back case that was failing - var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); - var pattern = new MonthlyOrdinalRecurrencePattern(OrdinalDayOccurrence.First, DayOfWeek.Sunday); - - // November 3, 2024 at 1:30 AM - First Sunday, falls on DST transition - var beforeDate = new DateTime(2024, 11, 2, 12, 0, 0); - var beforeUtc = beforeDate.LocalToUtc(tz); - var timeOnly = new TimeOnly(1, 30); - // Act var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz); - // Assert - should be November 3, 2024 at 1:30 AM - var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz); - Assert.Equal(new DateTime(2024, 11, 3, 1, 30, 0), resultLocal); + // Assert + var expectedUtc = new DateTime( + expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc + ); + Assert.Equal(expectedUtc, result); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs index 2416055b1..4a8fc0458 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs @@ -67,22 +67,6 @@ public class FactionRankTests Assert.True(top.Rank.Rank > bottom.Rank.Rank); } - [Fact] - public void ReadingRank_IsStableAndSideEffectFree() - { - var faction = NewFaction(); - var owner = new List(); - var state = AddMember(faction, owner); - - faction.ZeroRankOffset = 1; - state.RankIndex = 0; - - var first = state.Rank; - var second = state.Rank; - - Assert.Same(first, second); - } - [Fact] public void RankIndexOutOfSyncWithZeroRankOffset_StillResolvesARank() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs new file mode 100644 index 000000000..328123d7a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -0,0 +1,144 @@ +using System; +using Server; +using Server.Engines.Harvest; +using Server.Items; +using Server.Mobiles; +using Server.Random; +using Server.Tests; +using Server.Tests.Terrain; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class FishingTests +{ + [Theory] + [InlineData(32, 32, true)] // the middle of the sea: land 32 tiles away + [InlineData(13, 32, true)] // land 14 tiles west + [InlineData(12, 32, false)] // land 13 tiles west + [InlineData(50, 32, true)] // land 14 tiles east + [InlineData(51, 32, false)] // land 13 tiles east + [InlineData(13, 13, true)] // land 14 tiles away on the diagonal + [InlineData(13, 12, false)] // land 13 tiles north + public void FullValidation_NeedsLandAtLeast14TilesAway(int dx, int dy, bool deep) + { + var sea = Prefabs.DeepSea; + sea.AssertPresent(); + + Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, sea.Bounds.X + dx, sea.Bounds.Y + dy)); + } + + [Theory] + [InlineData(13, true)] // the edge of the map 14 tiles away + [InlineData(12, false)] // the edge of the map 13 tiles away + public void FullValidation_TreatsTheMapEdgeAsLand(int x, bool deep) + { + var sea = Prefabs.MapEdgeSea; + sea.AssertPresent(); + + Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, x, sea.Anchor.Y)); + } + + [Theory] + [InlineData(null, false)] + [InlineData(2.5, false)] + [InlineData(4.5, true)] + public void Give_SerpentGoesBackAfterFiveMinutesUnlessHitInTheLastTwo(double? hitAtMinutes, bool stays) + { + using var clock = new TimerWheelScope(); + + var previousHitsRate = Mobile.DefaultHitsRate; + var fisher = new PlayerMobile(); + BaseCreature serpent = null; + + try + { + // The rate RegenRates sets before AOS, so the hit is not healed before the serpent's five minutes are up. + Mobile.DefaultHitsRate = TimeSpan.FromSeconds(11.0); + Prefabs.DeepSea.AssertPresent(); + fisher.MoveToWorld(Prefabs.DeepSea.Anchor, Map.Felucca); + + var bottle = new MessageInABottle(Map.Felucca); + Fishing.System.Give(fisher, bottle, false); + + serpent = Assert.IsAssignableFrom(bottle.RootParent); + serpent.AIObject?.AITimer.Stop(); + serpent.Combatant = null; + + var elapsed = TimeSpan.Zero; + + if (hitAtMinutes is { } minutes) + { + elapsed = TimeSpan.FromMinutes(minutes); + TimerWheelScope.Advance(elapsed); + serpent.Damage(20, fisher); + Assert.Same(fisher, serpent.FindMostRecentDamager(false)); + } + + TimerWheelScope.Advance(TimeSpan.FromMinutes(4.9) - elapsed); + Assert.False(serpent.Deleted); + + TimerWheelScope.Advance(TimeSpan.FromMinutes(0.2)); + Assert.Equal(!stays, serpent.Deleted); + Assert.Equal(!stays, bottle.Deleted); + } + finally + { + serpent?.Delete(); + fisher.Delete(); + Mobile.DefaultHitsRate = previousHitsRate; + } + } + + [Fact] + public void Construct_SOSChestCatchConsumesTheSOS() + { + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = Expansion.UOR; + BuiltInRng.Generator = new System.Random(1); + fisher.Skills.Fishing.Base = 65.0; + fisher.AddItem(new Backpack()); + Prefabs.DeepSea.AssertPresent(); + fisher.MoveToWorld(Prefabs.DeepSea.Anchor, Map.Felucca); + + var sos = AddSOS(fisher); + + for (var i = 0; i < 200; i++) + { + var item = Fishing.System.Construct(typeof(Fish), fisher); + var isChest = item is WoodenChest or MetalGoldenChest; + item.Delete(); + + if (isChest) + { + Assert.True(sos.Deleted); + return; + } + + Assert.False(sos.Deleted); + } + + Assert.Fail("No SOS chest surfaced in 200 casts"); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + // Map.Internal skips FindLocation's search; the wreck is then where the fisher stands. + private static SOS AddSOS(Mobile fisher) + { + var sos = new SOS(Map.Internal, 1) { TargetMap = fisher.Map, TargetLocation = fisher.Location }; + fisher.Backpack.DropItem(sos); + return sos; + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs b/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs index 99921aea6..6f4e6d85e 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs @@ -9,8 +9,9 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class PartyPacketTests { + // Without a party, the remove-member packet tells the removed member their list is now empty. [Fact] - public void TestPartyEmptyList() + public void TestPartyRemoveMember_WithoutParty_SendsEmptyList() { var m = (Serial)0x1024u; @@ -26,43 +27,61 @@ public class PartyPacketTests [Fact] public void TestPartyRemoveMember() { - var leader = new Mobile((Serial)0x1024u); - leader.DefaultMobileInit(); + // Fresh serials: Delete removes by serial, and a fixed one could belong to a live entity. + var leader = new Mobile(World.NewMobile); + var member = new Mobile(World.NewMobile); - var member = new Mobile((Serial)0x2048u); - member.DefaultMobileInit(); + try + { + leader.DefaultMobileInit(); + member.DefaultMobileInit(); - var p = new Party(leader); - p.Add(member); + var p = new Party(leader); + p.Add(member); - var expected = new PartyRemoveMember(member.Serial, p).Compile(); + var expected = new PartyRemoveMember(member.Serial, p).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPartyRemoveMember(member.Serial, p); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendPartyRemoveMember(member.Serial, p); - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + member.Delete(); + leader.Delete(); + } } [Fact] public void TestPartyMemberList() { - var leader = new Mobile((Serial)0x1024u); - leader.DefaultMobileInit(); + // Fresh serials: Delete removes by serial, and a fixed one could belong to a live entity. + var leader = new Mobile(World.NewMobile); + var member = new Mobile(World.NewMobile); - var member = new Mobile((Serial)0x2048u); - member.DefaultMobileInit(); + try + { + leader.DefaultMobileInit(); + member.DefaultMobileInit(); - var p = new Party(leader); - p.Add(member); + var p = new Party(leader); + p.Add(member); - var expected = new PartyMemberList(p).Compile(); + var expected = new PartyMemberList(p).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPartyMemberList(p); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendPartyMemberList(p); - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + member.Delete(); + leader.Delete(); + } } [Theory] diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index 667995278..ad63c7982 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -1,3 +1,5 @@ +using Server.Tests.Terrain; +using System; using Server.Engines.Pathing.Cache; using Server.Mobiles; using Server.PathAlgorithms; @@ -14,15 +16,18 @@ namespace Server.Tests.Pathfinding; /// to "no path found" on reachable goals. /// [Collection("Sequential Pathfinding Tests")] -public class BitmapAStarAlgorithmTests +public class BitmapAStarAlgorithmTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public BitmapAStarAlgorithmTests(ITestOutputHelper output) { _output = output; } + public void Dispose() => _cacheState.Dispose(); + [Theory] // Pinned cell (1500, 1600, z=10): mask=0xC1 → N, W, NW walkable. // Use start.Z for goal.Z so destNode lands in the same Z plane the algorithm reaches @@ -33,22 +38,27 @@ public class BitmapAStarAlgorithmTests public void DefaultWalker_FindsPath_ViaCacheFastPath(int sx, int sy, int gx, int gy) { StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[1]; Assert.NotNull(map); - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(sx, sy, out _, out var startZ, out _); var start = new Point3D(sx, sy, (sbyte)startZ); var goal = new Point3D(gx, gy, (sbyte)startZ); stub.MoveToWorld(start, map); + var statsBefore = StepCache.Instance.GetStats(); var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); + var statsAfter = StepCache.Instance.GetStats(); stub.Delete(); Assert.NotNull(result); Assert.NotEmpty(result); + Assert.True(statsAfter.BuildsTotal > statsBefore.BuildsTotal, + "a default walker should expand through the cache, not the slow path"); _output.WriteLine($"default-walker ({sx},{sy})->({gx},{gy}): {result.Length} steps"); } @@ -62,7 +72,7 @@ public class BitmapAStarAlgorithmTests var map = Map.Maps[1]; Assert.NotNull(map); - var stub = new SwimmingStub(World.NewMobile); + var stub = new BareCreatureStub(World.NewMobile); stub.DefaultMobileInit(); stub.CanSwim = true; // overlay route — cache + (walkMask | wetMask&canSwim) map.GetAverageZ(sx, sy, out _, out var startZ, out _); @@ -104,13 +114,13 @@ public class BitmapAStarAlgorithmTests var start = new Point3D(sx, sy, (sbyte)startZ); var goal = new Point3D(gx, gy, (sbyte)startZ); - var walker = new DefaultWalkerStub(); + var walker = new WalkerStub(); walker.MoveToWorld(start, map); // Living blocker on the only direct-line cell. CanMoveOver returns false for an // alive non-staff mobile, so the dynamic-obstacle pass must reject this cell. map.GetAverageZ(blockX, blockY, out _, out var blockZ, out _); - var blocker = new DefaultWalkerStub(); + var blocker = new WalkerStub(); blocker.MoveToWorld(new Point3D(blockX, blockY, (sbyte)blockZ), map); var result = BitmapAStarAlgorithm.Instance.Find(walker, map, start, goal); @@ -130,10 +140,9 @@ public class BitmapAStarAlgorithmTests blocker.Delete(); } - [SkippableFact] + [Fact] public void DynamicObstaclePass_RejectsCellOccupiedByImpassableItem() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[1]; Assert.NotNull(map); @@ -146,24 +155,13 @@ public class BitmapAStarAlgorithmTests var blockX = 1499; var blockY = 1599; - // Find any ItemID whose TileData has ImpassableSurface so the dynamic pass - // rejects the cell. Pinning to a specific ID would couple the test to UO art data. - ushort blockerItemId = 0; - for (ushort id = 1; id < TileData.MaxItemValue; id++) - { - if (TileData.ItemTable[id].ImpassableSurface) - { - blockerItemId = id; - break; - } - } - Assert.NotEqual(0, blockerItemId); + const ushort blockerItemId = TestTiles.Counter; map.GetAverageZ(sx, sy, out _, out var startZ, out _); var start = new Point3D(sx, sy, (sbyte)startZ); var goal = new Point3D(gx, gy, (sbyte)startZ); - var walker = new DefaultWalkerStub(); + var walker = new WalkerStub(); walker.MoveToWorld(start, map); map.GetAverageZ(blockX, blockY, out _, out var blockZ, out _); @@ -197,28 +195,20 @@ public class BitmapAStarAlgorithmTests var map = Map.Maps[1]; Assert.NotNull(map); - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(1500, 1600, out _, out var startZ, out _); var start = new Point3D(1500, 1600, (sbyte)startZ); var goal = new Point3D(1498, 1598, (sbyte)startZ); stub.MoveToWorld(start, map); var statsBefore = StepCache.Instance.GetStats(); - var prevFlag = ContentFeatureFlags.BitmapPathfindingCache; - try - { - ContentFeatureFlags.BitmapPathfindingCache = false; - var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); - Assert.NotNull(result); - Assert.NotEmpty(result); - } - finally - { - ContentFeatureFlags.BitmapPathfindingCache = prevFlag; - } + ContentFeatureFlags.BitmapPathfindingCache = false; + var result = BitmapAStarAlgorithm.Instance.Find(stub, map, start, goal); var statsAfter = StepCache.Instance.GetStats(); stub.Delete(); + Assert.NotNull(result); + Assert.NotEmpty(result); Assert.Equal(statsBefore.BuildsTotal, statsAfter.BuildsTotal); } @@ -346,7 +336,7 @@ public class BitmapAStarAlgorithmTests StepCache.Instance.MissPromotionThreshold = 2; var map = Map.Maps[1]; - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(1500, 1600, out _, out var startZ, out _); var start = new Point3D(1500, 1600, (sbyte)startZ); var goal = new Point3D(1498, 1598, (sbyte)startZ); @@ -376,7 +366,7 @@ public class BitmapAStarAlgorithmTests StepCache.Instance.MissPromotionWindowMs = 30_000; var map = Map.Maps[1]; - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(1500, 1600, out _, out var startZ, out _); var start = new Point3D(1500, 1600, (sbyte)startZ); var goal = new Point3D(1498, 1598, (sbyte)startZ); @@ -401,18 +391,6 @@ public class BitmapAStarAlgorithmTests ); } - /// - /// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache - /// fast path on every expansion. - /// - private sealed class DefaultWalkerStub : Mobile - { - public DefaultWalkerStub() - { - Body = 0xC9; - } - } - /// /// Mobile with Player=true and default AccessLevel (Player). Triggers the strict /// AND-rule for diagonal corner-cut while still using the cache. @@ -426,20 +404,6 @@ public class BitmapAStarAlgorithmTests } } - /// - /// BaseCreature with CanSwim=true — uses the cache via capability overlay (walk OR - /// (wet AND canSwim)). Use the Serial constructor (deserialization path) to bypass - /// NPCSpeeds init, which requires the npc-speeds.json table loaded — not available - /// in tests. - /// - private sealed class SwimmingStub : BaseCreature - { - public SwimmingStub(Serial serial) : base(serial) - { - Body = 0xC9; - } - } - /// /// BaseCreature with CanFly=true — RequiresSlowPath returns true (Z-jumping is beyond /// the cache's static-only scope), so GetSuccessors short-circuits to the slow path. diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs index 694374757..32a0c2a85 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs @@ -1,5 +1,6 @@ using Server.Engines.Pathing.Cache; using Server.Items; +using Server.Tests.Terrain; using Xunit; using Xunit.Abstractions; @@ -12,20 +13,20 @@ public class BoatPathTests public BoatPathTests(ITestOutputHelper output) => _output = output; - // Open water in the south-Britain bay (Trammel), used by the existing swim-bake test. + // Open water: the middle of the DeepSea prefab. private const int MapId = 1; - private const int WaterX = 1450; - private const int WaterY = 1770; + private static int WaterX => Prefabs.DeepSea.Anchor.X; + private static int WaterY => Prefabs.DeepSea.Anchor.Y; - [SkippableFact] + [Fact] public void BoatDeck_HasWalkableSurfaceCells() { - TileDataRequirement.SkipIfMissing(); + Prefabs.DeepSea.AssertPresent(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; // SmallBoat North heading = multiID 0x0. A deck is made of surface tiles. - var boat = new TestMulti(0x0); + var boat = new TestMulti(TestMultis.SmallBoatNorth); map.GetAverageZ(WaterX, WaterY, out _, out var z, out _); boat.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)z), map); @@ -45,31 +46,6 @@ public class BoatPathTests } } - [SkippableFact] - public void BoatDeck_FootprintShape_IsPositionInvariant() - { - TileDataRequirement.SkipIfMissing(); - // The property Phase 2's local-frame, movement-invariant boat cache must preserve: - // the deck's covered-cell shape (in local coords) is identical at two world positions. - var map = Map.Maps[MapId]; - - var a = new TestMulti(0x0); - map.GetAverageZ(WaterX, WaterY, out _, out var za, out _); - a.MoveToWorld(new Point3D(WaterX, WaterY, (sbyte)za), map); - var shapeA = LocalShape(a); - a.Delete(); - - var b = new TestMulti(0x0); - map.GetAverageZ(WaterX + 5, WaterY + 3, out _, out var zb, out _); - b.MoveToWorld(new Point3D(WaterX + 5, WaterY + 3, (sbyte)zb), map); - var shapeB = LocalShape(b); - b.Delete(); - - Assert.Equal(shapeA, shapeB); - Assert.NotEmpty(shapeA); - _output.WriteLine($"boat local deck shape stable across positions: {shapeA.Count} cells"); - } - private static System.Collections.Generic.HashSet<(int lx, int ly)> LocalShape(BaseMulti multi) { var mcl = multi.Components; diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs index e82520ce9..1f28950dc 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs @@ -1,3 +1,4 @@ +using Server.Tests.Terrain; using Server; using Server.Engines.Pathing.Cache; using Server.Items; @@ -17,37 +18,16 @@ public class FoundationRedesignTests private const int PlaceX = 1500; private const int PlaceY = 1600; - [SkippableFact] - public void SwappingComponents_ChangesFootprint() - { - TileDataRequirement.SkipIfMissing(); - var foundation = new SwappableFoundation(0x74); // GuildHouse footprint - try - { - var beforeCount = MultiArt.FootprintCells(foundation).Count; - Assert.True(beforeCount > 0, "non-vacuity: initial footprint must cover cells"); - - foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape) - var afterCount = MultiArt.FootprintCells(foundation).Count; - - Assert.NotEqual(beforeCount, afterCount); - _output.WriteLine($"redesign footprint {beforeCount} -> {afterCount} cells"); - } - finally - { - foundation.Delete(); - } - } - - [SkippableFact] + [Fact] public void RedesignReRegistered_RoutesNewFootprintToLivePath() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; Assert.NotNull(map); - var foundation = new SwappableFoundation(0x74); + var foundation = new SwappableFoundation(TestMultis.GuildHouse); + var shared = MultiData.GetComponents(TestMultis.GuildHouse); + var seededShape = (shared.Width, shared.Height, shared.List.Length); map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); var loc = new Point3D(PlaceX, PlaceY, (sbyte)z); foundation.MoveToWorld(loc, map); @@ -65,7 +45,7 @@ public class FoundationRedesignTests // the OLD footprint's sector registration. We then swap the MCL and MoveToWorld back, // which fires Map.OnEnter -> AddMulti against the fresh Components (new footprint). foundation.Internalize(); - foundation.Redesign(MultiData.GetComponents(0x7A)); + foundation.Redesign(MultiData.GetComponents(TestMultis.Tower)); foundation.MoveToWorld(loc, map); var after = MultiArt.FindFloorCell(foundation); @@ -76,6 +56,10 @@ public class FoundationRedesignTests var maskAfter = StepCache.Instance.TryGetMask(map, after.Value.X, after.Value.Y, (sbyte)z); Assert.Equal(CacheHitKind.Fallthrough_Multi, maskAfter.HitKind); + // The redesign swapped this foundation's list; the shared footprint every other GuildHouse reads is untouched. + var seeded = MultiData.GetComponents(TestMultis.GuildHouse); + Assert.Equal(seededShape, (seeded.Width, seeded.Height, seeded.List.Length)); + _output.WriteLine($"redesign re-registered: before {before.Value.X},{before.Value.Y} after {after.Value.X},{after.Value.Y}"); } finally diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs index 8ecbd0591..f2ae9a751 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs @@ -1,3 +1,5 @@ +using Server.Tests.Terrain; +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Server.Mobiles; @@ -9,25 +11,21 @@ using CalcMoves = Server.Movement.Movement; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class HousePathRoutingTests +public class HousePathRoutingTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public HousePathRoutingTests(ITestOutputHelper output) => _output = output; + public void Dispose() => _cacheState.Dispose(); + private const int MapId = 1; - private sealed class WalkerStub : Mobile - { - public WalkerStub() => Body = 0xC9; - } - - [SkippableFact] + [Fact] public void PathAround_NeverTraversesAWallCell() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); - var prevThreshold = StepCache.Instance.MissPromotionThreshold; StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[MapId]; Assert.NotNull(map); @@ -38,7 +36,7 @@ public class HousePathRoutingTests const int gx = 1480, gy = 1610; map.GetAverageZ(hx, hy, out _, out var hz, out _); - var multi = new TestMulti(0x74); + var multi = new TestMulti(TestMultis.GuildHouse); multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); var wall = MultiArt.FindWallCell(multi); @@ -69,23 +67,21 @@ public class HousePathRoutingTests } finally { - StepCache.Instance.MissPromotionThreshold = prevThreshold; walker.Delete(); multi.Delete(); } } - [SkippableFact] + [Fact] public void Demolish_ReopensCoveredCells() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; Assert.NotNull(map); const int hx = 1480, hy = 1620; map.GetAverageZ(hx, hy, out _, out var hz, out _); - var multi = new TestMulti(0x74); + var multi = new TestMulti(TestMultis.GuildHouse); multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); // Find a wall cell that is blocked purely by the multi — not by underlying static/land diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs index fc9e58898..09bbbae78 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs @@ -1,3 +1,5 @@ +using Server.Tests.Terrain; +using System; using Server.Engines.Pathing.Cache; using Server.PathAlgorithms; using Server.Systems.FeatureFlags; @@ -6,10 +8,14 @@ using Xunit; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class MultiCacheUsedTests +public class MultiCacheUsedTests : IDisposable { private const int MapId = 1; // Trammel - private const int GuildHouseId = 0x74; + private const int GuildHouseId = TestMultis.GuildHouse; + + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); [Fact] public void PathNearMulti_IncrementsMultiLocalHits() @@ -19,7 +25,6 @@ public class MultiCacheUsedTests var houseLoc = new Point3D(1480, 1620, (sbyte)z); var multi = new TestMulti(GuildHouseId); - var cacheWas = ContentFeatureFlags.BitmapPathfindingCache; ContentFeatureFlags.BitmapPathfindingCache = true; var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(1480, 1630, (sbyte)z)); @@ -44,7 +49,6 @@ public class MultiCacheUsedTests { mover.Delete(); multi.Delete(); - ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs index 8aa0b8d19..6c3a6c324 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs @@ -1,5 +1,6 @@ using Server.Engines.Pathing.Cache; using Server.Items; +using Server.Tests.Terrain; using Xunit; namespace Server.Tests.Pathfinding; @@ -8,16 +9,16 @@ namespace Server.Tests.Pathfinding; public class MultiEdgeCaseTests { private const int MapId = 1; // Trammel - private const int GuildHouseId = 0x74; + private const int GuildHouseId = TestMultis.GuildHouse; - // GuildHouse placement (mirrors MultiMaskSynthesisTests) — open Trammel ground. + // GuildHouse placement (mirrors MultiMaskSynthesisTests) — open ground. private const int HouseX = 1480; private const int HouseY = 1620; - // Open water in the south-Britain bay (mirrors BoatPathTests). - private const int BoatMultiId = 0x0; // SmallBoat North heading - private const int WaterX = 1450; - private const int WaterY = 1770; + // Open water: the middle of the DeepSea prefab. + private const int BoatMultiId = TestMultis.SmallBoatNorth; + private static int WaterX => Prefabs.DeepSea.Anchor.X; + private static int WaterY => Prefabs.DeepSea.Anchor.Y; private const sbyte DeckZ = 0; // boat deck floor tiles stand at world Z 0 (not the water avgZ) /// @@ -25,10 +26,9 @@ public class MultiEdgeCaseTests /// GetStaticAndMultiTiles yields tiles from BOTH multis; the synthesizer must still /// agree with CheckMovement everywhere over the union footprint + halo. /// - [SkippableFact] + [Fact] public void OverlappingMultis_SynthesizerMatchesCheckMovement() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; @@ -76,10 +76,10 @@ public class MultiEdgeCaseTests /// blocks the rest — that vacuity is a fixture concern, not a synthesizer divergence (the /// synthesizer agrees with the oracle at every direction either way). /// - [SkippableFact] + [Fact] public void BoatOverWater_SynthesizerMatchesCheckMovement() { - TileDataRequirement.SkipIfMissing(); + Prefabs.DeepSea.AssertPresent(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; @@ -100,10 +100,9 @@ public class MultiEdgeCaseTests /// re-registration pattern HouseFoundation uses on commit) and assert the synthesizer reads /// the LIVE, post-redesign Components — i.e. it matches CheckMovement on the NEW shape. /// - [SkippableFact] + [Fact] public void RedesignedFoundation_SynthesizerMatchesNewFootprint() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; @@ -117,7 +116,7 @@ public class MultiEdgeCaseTests // Internalize() fires Map.OnLeave (removes the OLD footprint's registration); we swap // the MCL and MoveToWorld back, firing Map.OnEnter -> AddMulti against the new shape. foundation.Internalize(); - foundation.Redesign(MultiData.GetComponents(0x7A)); // Tower footprint (different shape) + foundation.Redesign(MultiData.GetComponents(TestMultis.Tower)); // Tower footprint (different shape) foundation.MoveToWorld(loc, map); MultiTestSupport.AssertSynthesizerMatchesCheckMovement(foundation, map); @@ -130,33 +129,4 @@ public class MultiEdgeCaseTests } } } - - /// - /// Extensible slot for repo-owner-supplied gnarly placements. The assertion already covers - /// any (map,x,y) by construction — only the coordinates need filling in. - /// - /// TODO(coords): repo owner to supply (map,x,y) for a static tree inside a footprint and a - /// dungeon cave-wall corner; add InlineData rows here — the assertion already covers them by - /// construction. (Left intentionally unhunted: do not invent tree/dungeon coords.) - /// - [SkippableTheory] - [InlineData(MapId, HouseX, HouseY)] // known-good open Trammel placement (passes today) - public void UserSuppliedScenarios_SynthesizerMatchesCheckMovement(int mapId, int x, int y) - { - TileDataRequirement.SkipIfMissing(); - StepCache.Instance.Clear(); - var map = Map.Maps[mapId]; - - var multi = new TestMulti(GuildHouseId); - map.GetAverageZ(x, y, out _, out var z, out _); - multi.MoveToWorld(new Point3D(x, y, (sbyte)z), map); - try - { - MultiTestSupport.AssertSynthesizerMatchesCheckMovement(multi, map); - } - finally - { - multi.Delete(); - } - } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs index 7487ce8de..8bad6655b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs @@ -1,21 +1,26 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; +using Server.Tests.Terrain; using Xunit; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class MultiMaskCacheTests +public class MultiMaskCacheTests : IDisposable { private const int MapId = 1; - private const int GuildHouseId = 0x74; + private const int GuildHouseId = TestMultis.GuildHouse; private const int PlaceX = 1480; private const int PlaceY = 1620; - [SkippableFact] + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); + + [Fact] public void TryResolveCoveringMulti_FindsPlacedMulti_AndLocalIndices() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); @@ -39,45 +44,6 @@ public class MultiMaskCacheTests } } - [SkippableFact] - public void IsInteriorLocalCell_TrueDeepInside_FalseAtEdge() - { - TileDataRequirement.SkipIfMissing(); - var multi = new TestMulti(GuildHouseId); - try - { - var mcl = multi.Components; - - var foundInterior = false; - var foundEdge = false; - for (var ly = 0; ly < mcl.Height && (!foundInterior || !foundEdge); ly++) - { - for (var lx = 0; lx < mcl.Width; lx++) - { - if (mcl.Tiles[lx][ly].Length == 0) - { - continue; - } - if (MultiMaskCache.IsInteriorLocalCell(mcl, lx, ly)) - { - foundInterior = true; - } - else - { - foundEdge = true; - } - } - } - - Assert.True(foundInterior, "a guild house must have at least one interior cell"); - Assert.True(foundEdge, "a guild house must have at least one edge/perimeter cell"); - } - finally - { - multi.Delete(); - } - } - [Fact] public void LocalWorldZ_RoundTrips_AndPreservesMasks() { @@ -124,32 +90,25 @@ public class MultiMaskCacheTests Assert.False(MultiMaskCache.TerrainTopBelow(map, PlaceX, PlaceY, (sbyte)(ground - 50))); } - [SkippableFact] + [Fact] public void PathThroughHouseInterior_IncrementsMultiMaskCacheHits() { - TileDataRequirement.SkipIfMissing(); - // (PlaceX,PlaceY)=(1480,1620) is cluttered (footprint overlaps tall map statics → dirty), so it - // would never serve the interior cache under the footprint-clean gate. Use a known flat/clear - // spot so a house there is footprint-clean and its interior cells serve from the cache. - const int CleanX = 1560; - const int CleanY = 1616; - + // On flat default ground the house is footprint-clean, so its interior cells serve from the cache. var map = Map.Maps[MapId]; - map.GetAverageZ(CleanX, CleanY, out _, out var z, out _); - var houseLoc = new Point3D(CleanX, CleanY, (sbyte)z); + map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); + var houseLoc = new Point3D(PlaceX, PlaceY, (sbyte)z); var multi = new TestMulti(GuildHouseId); - var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache; Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true; - var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(CleanX, CleanY + 10, (sbyte)z)); + var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(PlaceX, PlaceY + 10, (sbyte)z)); try { multi.MoveToWorld(houseLoc, map); Assert.True(MultiMaskCache.ComputeFootprintClean(map, multi), - "precondition: (1560,1616) must be footprint-clean for the cache to serve"); + "precondition: the house must be footprint-clean for the cache to serve"); StepCache.Instance.Clear(); - var start = new Point3D(CleanX, CleanY + 10, (sbyte)z); - var goal = new Point3D(CleanX, CleanY - 10, (sbyte)z); + var start = new Point3D(PlaceX, PlaceY + 10, (sbyte)z); + var goal = new Point3D(PlaceX, PlaceY - 10, (sbyte)z); // First pass builds the interior cache (live synth); second pass should hit it. Server.PathAlgorithms.BitmapAStarAlgorithm.Instance.Find(mover, map, start, goal); @@ -163,25 +122,27 @@ public class MultiMaskCacheTests { mover.Delete(); multi.Delete(); - Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } [Fact] public void ClutteredHouse_DoesNotServeCache_ButStillPaths() { + // A rock taller than the house floor, inside the footprint, dirties it. + using var terrain = TestTerrain.Scope(); + TestTerrain.AddStatic(PlaceX + 1, PlaceY + 1, TestTiles.Rock, 0); + var map = Map.Maps[MapId]; map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); var multi = new TestMulti(GuildHouseId); - var cacheWas = Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache; Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = true; var mover = MultiTestSupport.GetWalkerOracle(map, new Point3D(PlaceX, PlaceY + 10, (sbyte)z)); try { - // (1480,1620) overlaps tall map statics → footprint dirty → cache must NOT serve. + // The rock pokes through the floor → footprint dirty → cache must NOT serve. multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); Assert.False(MultiMaskCache.ComputeFootprintClean(map, multi), - "precondition: (1480,1620) must be footprint-dirty for this test to be meaningful"); + "precondition: the rock must make the house footprint-dirty for this test to be meaningful"); StepCache.Instance.Clear(); var start = new Point3D(PlaceX, PlaceY + 10, (sbyte)z); @@ -198,7 +159,6 @@ public class MultiMaskCacheTests { mover.Delete(); multi.Delete(); - Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } @@ -236,7 +196,7 @@ public class MultiMaskCacheTests // Changing ItemID swaps the footprint (e.g. a boat changing heading), so the gate must // reset to recompute cleanliness for the new shape. - multi.ItemID = 0x7A; // Tower + multi.ItemID = TestMultis.Tower; Assert.Equal(MultiInteriorCacheState.Unknown, multi.PathInteriorCacheState); } finally @@ -245,27 +205,21 @@ public class MultiMaskCacheTests } } - [SkippableFact] + [Fact] public void ComputeFootprintClean_TrueAtNormalPlacement_FalseWhenSunk() { - TileDataRequirement.SkipIfMissing(); - // (PlaceX,PlaceY) is a cluttered spot whose footprint overlaps tall map statics, so a guild - // house there is never footprint-clean. Use a known flat/clear spot for the clean assertion. - const int CleanX = 1560; - const int CleanY = 1616; - StepCache.Instance.Clear(); var map = Map.Maps[MapId]; - map.GetAverageZ(CleanX, CleanY, out _, out var ground, out _); + map.GetAverageZ(PlaceX, PlaceY, out _, out var ground, out _); var normal = new TestMulti(GuildHouseId); var sunk = new TestMulti(GuildHouseId); try { - normal.MoveToWorld(new Point3D(CleanX, CleanY, (sbyte)ground), map); + normal.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)ground), map); Assert.True(MultiMaskCache.ComputeFootprintClean(map, normal)); - sunk.MoveToWorld(new Point3D(CleanX + 60, CleanY, (sbyte)(ground - 40)), map); + sunk.MoveToWorld(new Point3D(PlaceX + 60, PlaceY, (sbyte)(ground - 40)), map); Assert.False(MultiMaskCache.ComputeFootprintClean(map, sunk)); } finally diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs index b415ec3d6..1ad7839e0 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskSynthesisTests.cs @@ -1,4 +1,5 @@ using Server.Engines.Pathing.Cache; +using Server.Tests.Terrain; using Xunit; namespace Server.Tests.Pathfinding; @@ -7,14 +8,13 @@ namespace Server.Tests.Pathfinding; public class MultiMaskSynthesisTests { private const int MapId = 1; // Trammel - private const int GuildHouseId = 0x74; // static house: walls, door aperture, floor + private const int GuildHouseId = TestMultis.GuildHouse; // static house: walls, door aperture, floor private const int PlaceX = 1480; private const int PlaceY = 1620; - [SkippableFact] + [Fact] public void ComputeMultiMaskAt_MatchesCheckMovement_OverFootprintAndHalo() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs index 9a920282f..723ae5988 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs @@ -1,3 +1,5 @@ +using Server.Tests.Terrain; +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Server.Mobiles; @@ -9,33 +11,23 @@ using Xunit.Abstractions; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class MultiPathInvariantTests +public class MultiPathInvariantTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public MultiPathInvariantTests(ITestOutputHelper output) => _output = output; - private const int MapId = 1; + public void Dispose() => _cacheState.Dispose(); - private sealed class WalkerStub : Mobile - { - public WalkerStub() => Body = 0xC9; - } + private const int MapId = 1; private static Direction[] FindWithFlag(Mobile m, Map map, Point3D start, Point3D goal, bool cacheOn) { - var prev = ContentFeatureFlags.BitmapPathfindingCache; - try - { - ContentFeatureFlags.BitmapPathfindingCache = cacheOn; - StepCache.Instance.Clear(); - StepCache.Instance.MissPromotionThreshold = 1; - return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal); - } - finally - { - ContentFeatureFlags.BitmapPathfindingCache = prev; - } + ContentFeatureFlags.BitmapPathfindingCache = cacheOn; + StepCache.Instance.Clear(); + StepCache.Instance.MissPromotionThreshold = 1; + return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal); } [Theory] @@ -51,7 +43,7 @@ public class MultiPathInvariantTests var hx = (sx + gx) / 2; var hy = (sy + gy) / 2; map.GetAverageZ(hx, hy, out _, out var hz, out _); - var multi = new TestMulti(0x74); + var multi = new TestMulti(TestMultis.GuildHouse); multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); var walker = new WalkerStub(); @@ -100,11 +92,11 @@ public class MultiPathInvariantTests { var on = FindWithFlag(walker, map, start, goal, cacheOn: true); var off = FindWithFlag(walker, map, start, goal, cacheOn: false); - Assert.Equal(off == null, on == null); - if (on != null) - { - Assert.Equal(off, on); - } + + // Two nulls would agree about nothing: the control route must exist. + Assert.NotNull(off); + Assert.NotNull(on); + Assert.Equal(off, on); } finally { @@ -118,15 +110,14 @@ public class MultiPathInvariantTests var map = Map.Maps[MapId]; Assert.NotNull(map); - // Open area at (1480,1620,z=20): paths exist in all 8 directions (probe confirmed). - // N-S route: start=(1480,1630) goal=(1480,1610). House at (1480,1620) on direct line. - // Detour exists E (~1488+) and W (~1472-). Both cache-on and cache-off must agree. + // Flat default ground. N-S route: start=(1480,1630) goal=(1480,1610), house at (1480,1620) on + // the direct line; the detour runs round its east or west wall. Cache-on and cache-off must agree. const int hx = 1480, hy = 1620; const int sx = 1480, sy = 1630; const int gx = 1480, gy = 1610; map.GetAverageZ(hx, hy, out _, out var hz, out _); - var multi = new TestMulti(0x74); + var multi = new TestMulti(TestMultis.GuildHouse); multi.MoveToWorld(new Point3D(hx, hy, (sbyte)hz), map); var walker = new WalkerStub(); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs index 1103cdb83..343892de0 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs @@ -1,3 +1,5 @@ +using Server.Tests.Terrain; +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Xunit; @@ -6,27 +8,29 @@ using Xunit.Abstractions; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class MultiSplitRoutingTests +public class MultiSplitRoutingTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public MultiSplitRoutingTests(ITestOutputHelper output) => _output = output; + public void Dispose() => _cacheState.Dispose(); + // Open plain on Trammel used by existing pathfinding tests; room for a 7x7 house. private const int MapId = 1; private const int PlaceX = 1500; private const int PlaceY = 1600; - [SkippableFact] + [Fact] public void PlacedMulti_RoutesFootprintAndHalo_ToFallthroughMulti() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; Assert.NotNull(map); map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); - var multi = new TestMulti(0x74); // GuildHouse footprint + var multi = new TestMulti(TestMultis.GuildHouse); // GuildHouse footprint multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); try @@ -60,32 +64,24 @@ public class MultiSplitRoutingTests public void CleanMap_AfterMultiRemoved_ServesStaticHitAgain() { StepCache.Instance.Clear(); - var prevThreshold = StepCache.Instance.MissPromotionThreshold; StepCache.Instance.MissPromotionThreshold = 1; // build on first touch var map = Map.Maps[MapId]; Assert.NotNull(map); map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); - var multi = new TestMulti(0x74); + var multi = new TestMulti(TestMultis.GuildHouse); multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); var sector = map.GetRealSector(PlaceX >> 4, PlaceY >> 4); Assert.True(sector.HasMultis, "placing a multi must set Sector.HasMultis"); - try - { - multi.Delete(); - Assert.False(sector.HasMultis, "removing the only multi must clear HasMultis"); + multi.Delete(); + Assert.False(sector.HasMultis, "removing the only multi must clear HasMultis"); - // A cell that is NOT a static-fallthrough kind (e.g. off-map / multi) should now be - // eligible for a real static answer. Use the placement center, which is open plain. - var mask = StepCache.Instance.TryGetMask(map, PlaceX, PlaceY, (sbyte)z); - Assert.True(mask.IsHit, $"expected a real static answer after removal, got {mask.HitKind}"); - _output.WriteLine($"post-removal hitKind at center: {mask.HitKind}"); - } - finally - { - StepCache.Instance.MissPromotionThreshold = prevThreshold; - } + // A cell that is NOT a static-fallthrough kind (e.g. off-map / multi) should now be + // eligible for a real static answer. Use the placement center, which is open plain. + var mask = StepCache.Instance.TryGetMask(map, PlaceX, PlaceY, (sbyte)z); + Assert.True(mask.IsHit, $"expected a real static answer after removal, got {mask.HitKind}"); + _output.WriteLine($"post-removal hitKind at center: {mask.HitKind}"); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs index 092b28711..0f06a44aa 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiTestSupport.cs @@ -20,12 +20,6 @@ public sealed class TestMulti : BaseMulti } } -/// Default walker body, shared across pathfinding test fixtures. -public sealed class WalkerStub : Mobile -{ - public WalkerStub() => Body = 0xC9; -} - /// /// Stand-in for a customizable foundation: Components is a swappable MCL, exactly the /// runtime-mutation shape HouseFoundation uses (it replaces its MCL wholesale on redesign diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs index d8d46e7f8..d69cffa8b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs @@ -1,6 +1,7 @@ using Server.Engines.Pathing.Cache; using Server.Items; using Server.Mobiles; +using Server.Tests.Terrain; using Xunit; using Xunit.Abstractions; using CalcMoves = Server.Movement.Movement; @@ -18,20 +19,14 @@ public class MultiWalkabilityTests private const int PlaceX = 1500; private const int PlaceY = 1600; - private sealed class WalkerStub : Mobile - { - public WalkerStub() => Body = 0xC9; - } - - [SkippableFact] + [Fact] public void WallCell_CannotBeEnteredFromAnyAdjacentCell() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); - var multi = new TestMulti(0x74); + var multi = new TestMulti(TestMultis.GuildHouse); multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); var walker = new WalkerStub(); @@ -87,15 +82,14 @@ public class MultiWalkabilityTests } } - [SkippableFact] + [Fact] public void FloorCell_IsStandable() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[MapId]; map.GetAverageZ(PlaceX, PlaceY, out _, out var z, out _); - var multi = new TestMulti(0x74); + var multi = new TestMulti(TestMultis.GuildHouse); multi.MoveToWorld(new Point3D(PlaceX, PlaceY, (sbyte)z), map); var walker = new WalkerStub(); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs index 0930bad7a..efb434738 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathfindRecorderTests.cs @@ -19,7 +19,7 @@ public class PathfindRecorderTests try { - var stub = new RecorderStub(World.NewMobile); + var stub = new BareCreatureStub(World.NewMobile); stub.DefaultMobileInit(); stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]); @@ -48,7 +48,7 @@ public class PathfindRecorderTests { Assert.True(PathfindRecorder.Enabled); - var stub = new RecorderStub(World.NewMobile); + var stub = new BareCreatureStub(World.NewMobile); stub.DefaultMobileInit(); stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]); @@ -89,7 +89,7 @@ public class PathfindRecorderTests try { - var stub = new RecorderStub(World.NewMobile); + var stub = new BareCreatureStub(World.NewMobile); stub.DefaultMobileInit(); stub.MoveToWorld(new Point3D(1500, 1600, 0), Map.Maps[1]); stub.CanSwim = true; @@ -100,13 +100,17 @@ public class PathfindRecorderTests ); PathfindRecorder.SetEnabled(false); + + // BaseCreature's own defaults for these two; the recording must carry whatever they are. + var canOpenDoors = JsonBool(stub.CanOpenDoors); + var canMoveOverObstacles = JsonBool(stub.CanMoveOverObstacles); stub.Delete(); var content = File.ReadAllText(path); Assert.Contains("\"CanSwim\":true", content); Assert.Contains("\"CanFly\":false", content); - Assert.Contains("\"CanOpenDoors\":", content); // value depends on BaseCreature defaults - Assert.Contains("\"CanMoveOverObstacles\":", content); // — same. + Assert.Contains($"\"CanOpenDoors\":{canOpenDoors}", content); + Assert.Contains($"\"CanMoveOverObstacles\":{canMoveOverObstacles}", content); } finally { @@ -142,8 +146,5 @@ public class PathfindRecorderTests } } - private sealed class RecorderStub : Server.Mobiles.BaseCreature - { - public RecorderStub(Serial serial) : base(serial) => Body = 0xC9; - } + private static string JsonBool(bool value) => value ? "true" : "false"; } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs index 9027332c7..aa6a065cb 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs @@ -4,8 +4,8 @@ using Server.Engines.Pathing.Cache; namespace Server.Tests.Pathfinding; /// -/// Shared fixtures for the step-cache tests: the walker the parity tests measure against, the -/// cell-index arithmetic, and builders for the chunk state several tests inject by hand. +/// Shared fixtures for the step-cache tests: the test map, the cell-index arithmetic, and builders +/// for the chunk state several tests inject by hand. /// internal static class PathingTestSupport { @@ -16,12 +16,15 @@ internal static class PathingTestSupport public static Map TestMap => Map.Maps[1]; /// - /// A cell in open Britain countryside — flat, walkable in all directions, no statics. The + /// A cell of the synthetic map's default ground — flat, walkable in all directions, no statics. The /// default subject when a test needs a chunk to exist and doesn't care what's in it. /// public const int PlainX = 1500; public const int PlainY = 1600; + /// The height of the default ground at every cell nothing was stamped on. + public const sbyte PlainZ = 0; + /// Index of world cell (x, y) within its own chunk. public static int CellIndex(int x, int y) => ((y & 15) << 4) | (x & 15); @@ -53,17 +56,4 @@ internal static class PathingTestSupport return data; } - - /// - /// The default static walker. Deriving straight from rather than - /// BaseCreature is the point: MovementImpl then sees no creature capabilities (no swim, no fly, - /// no door-opening), which is exactly the walker the cache bakes for. - /// - public sealed class StaticWalker : Mobile - { - public StaticWalker() - { - Body = 0xC9; - } - } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileFormatTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileFormatTests.cs index 3fe72b74a..14958d9f1 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileFormatTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileFormatTests.cs @@ -228,17 +228,6 @@ public class StepCacheFileFormatTests Assert.True(fileLength < 1100, $"expected every base Z array to elide; file was {fileLength} bytes"); } - [Fact] - public void CoastlineChunk_ElidesEveryZArray() - { - // Partial walkability with a non-zero SourceZ: the shape that defeats an unmasked predictor. - var src = FlatFullChunk(baseZ: 25); - var rt = RoundTrip(src, out var fileLength); - - AssertIdentical(src, rt); - Assert.True(fileLength < 1100, $"masked predictor should elide every array; file was {fileLength} bytes"); - } - [Fact] public void SlopeInOneDirection_StoresOnlyThatZArray() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs index d29b79ef9..4584b4464 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -2,12 +2,17 @@ using System; using System.IO; using Server.Engines.Pathing.Cache; using Xunit; +using static Server.Tests.Pathfinding.PathingTestSupport; namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] -public class StepCacheFileTests +public class StepCacheFileTests : IDisposable { + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); + /// /// Round-trip a populated cache through a .swb file under lazy loading: build chunks, /// save, clear (closes lazy readers + drops residents), open as lazy backing store, @@ -191,7 +196,7 @@ public class StepCacheFileTests cache.Clear(); var map = Map.Maps[1]; - cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ); var path = Path.Combine(Path.GetTempPath(), $"step-cache-stalehash-{Guid.NewGuid():N}.swb"); try @@ -242,7 +247,7 @@ public class StepCacheFileTests }; foreach (var (x, y) in coords) { - cache.TryGetMask(map, x, y, sourceZ: 10); + cache.TryGetMask(map, x, y, sourceZ: PlainZ); } var path = Path.Combine(Path.GetTempPath(), $"step-cache-lazy-{Guid.NewGuid():N}.swb"); @@ -257,7 +262,7 @@ public class StepCacheFileTests Assert.Equal(0, cache.GetStats().ResidentChunks); // Query one specific chunk: only that chunk lands in the resident set. - cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10); + cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: PlainZ); Assert.Equal(1, cache.GetStats().ResidentChunks); Assert.Equal(0L, cache.GetStats().BuildsTotal); // resolved from file, not baker } @@ -287,7 +292,7 @@ public class StepCacheFileTests Assert.NotNull(map); // Build a chunk and inject a synthetic swim layer onto one cell. - cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ); var chunk = cache.GetResidentChunk(map.MapID, 1500 >> 4, 1600 >> 4); Assert.NotNull(chunk); @@ -336,10 +341,9 @@ public class StepCacheFileTests /// to which proves the /// default lazy behavior. /// - [SkippableFact] + [Fact] public void TryOpenLazyReader_WithPreloadFlag_MaterializesAllChunksImmediately() { - TileDataRequirement.SkipIfMissing(); var cache = StepCache.Instance; cache.Clear(); cache.MissPromotionThreshold = 1; @@ -353,7 +357,7 @@ public class StepCacheFileTests }; foreach (var (x, y) in coords) { - cache.TryGetMask(map, x, y, sourceZ: 10); + cache.TryGetMask(map, x, y, sourceZ: PlainZ); } var path = Path.Combine(Path.GetTempPath(), $"step-cache-preload-{Guid.NewGuid():N}.swb"); @@ -363,23 +367,16 @@ public class StepCacheFileTests cache.Clear(); cache.PreloadOnLazyOpen = true; - try - { - Assert.True(cache.TryOpenLazyReader(path, map.MapID)); + Assert.True(cache.TryOpenLazyReader(path, map.MapID)); - // Every chunk should be resident — no further queries needed. - Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); - Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker + // Every chunk should be resident — no further queries needed. + Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); + Assert.Equal(0L, cache.GetStats().BuildsTotal); // came from file, not baker - // Subsequent query is a clean Hit, not a Miss_NotBuilt. - var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: 10); - Assert.Equal(CacheHitKind.Hit, lookup.HitKind); - Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); - } - finally - { - cache.PreloadOnLazyOpen = false; - } + // Subsequent query is a clean Hit, not a Miss_NotBuilt. + var lookup = cache.TryGetMask(map, coords[0].Item1, coords[0].Item2, sourceZ: PlainZ); + Assert.Equal(CacheHitKind.Hit, lookup.HitKind); + Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); } finally { @@ -397,10 +394,9 @@ public class StepCacheFileTests /// first pathfind through a region to use the cache rather than the slow path — the gate would /// otherwise defer that first touch and defeat the whole point of shipping the bake. /// - [SkippableFact] + [Fact] public void LazyReaderHit_BypassesMissTrackerOnFirstTouch() { - TileDataRequirement.SkipIfMissing(); var cache = StepCache.Instance; cache.Clear(); cache.MissPromotionThreshold = 1; // eager build for save phase @@ -408,7 +404,7 @@ public class StepCacheFileTests var map = Map.Maps[1]; // Build + save one chunk. - cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ); var path = Path.Combine(Path.GetTempPath(), $"step-cache-bypass-{Guid.NewGuid():N}.swb"); try { @@ -422,7 +418,7 @@ public class StepCacheFileTests // First touch must NOT return Fallthrough_NotBuilt — the lazy reader has the // chunk and serves it before the miss tracker is consulted. - var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: 10); + var lookup = cache.TryGetMask(map, 1500, 1600, sourceZ: PlainZ); Assert.True(lookup.IsHit); Assert.Equal(CacheHitKind.Miss_NotBuilt, lookup.HitKind); Assert.Equal(1, cache.GetStats().ResidentChunks); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs index dee698e59..1c9a4c5a6 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs @@ -1,4 +1,6 @@ +using System.IO; using Server.Engines.Pathing.Cache; +using Server.Tests.Terrain; using Xunit; namespace Server.Tests.Pathfinding; @@ -22,7 +24,7 @@ public class StepCacheFingerprintTests var before = StepCacheFile.ComputeFingerprint(mapId); - const int probeId = 0x2A0; + const int probeId = TestTiles.Wall; var original = TileData.ItemTable[probeId].Flags; try { @@ -40,4 +42,30 @@ public class StepCacheFingerprintTests TileData.ItemTable[probeId].Flags = original; } } + + /// + /// The other half of the contract: the fingerprint does change when the file does, so a client patch + /// that shifts one tile flag invalidates a bake made against the old file. + /// + [Fact] + public void TileDataFileHash_ChangesWhenOneFlagChanges() + { + var directory = Directory.CreateTempSubdirectory("modernuo-fingerprint-").FullName; + try + { + var original = SyntheticTileData.WriteTo(Directory.CreateDirectory(Path.Combine(directory, "a")).FullName); + var rewritten = SyntheticTileData.WriteTo(Directory.CreateDirectory(Path.Combine(directory, "b")).FullName); + var patched = SyntheticTileData.WriteTo( + Directory.CreateDirectory(Path.Combine(directory, "c")).FullName, + flags => flags[TestTiles.Wall] ^= TileFlag.NoShoot + ); + + Assert.Equal(StepCacheFile.HashTileDataFile(original), StepCacheFile.HashTileDataFile(rewritten)); + Assert.NotEqual(StepCacheFile.HashTileDataFile(original), StepCacheFile.HashTileDataFile(patched)); + } + finally + { + Directory.Delete(directory, true); + } + } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index b4257f112..abe9a87d2 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -17,11 +17,9 @@ namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] public class StepCacheLifecycleTests : IDisposable { - // The window is a knob on the singleton and Clear() leaves it alone, so a test that narrows - // it would otherwise leak the narrow window into every later test in the collection. - private readonly uint _savedWindowMs = StepCache.Instance.MissPromotionWindowMs; + private readonly StepCacheStateScope _cacheState = new(); - public void Dispose() => StepCache.Instance.MissPromotionWindowMs = _savedWindowMs; + public void Dispose() => _cacheState.Dispose(); /// Resets to a known state and returns the singleton. private static StepCache FreshCache(int promotionThreshold) @@ -35,23 +33,13 @@ public class StepCacheLifecycleTests : IDisposable /// Builds the plain chunk and hands it back for a test to inject state into. private static StepChunk BuiltPlainChunk(StepCache cache, Map map) { - cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); var chunk = cache.GetResidentChunk(map.MapID, PlainX >> 4, PlainY >> 4); Assert.NotNull(chunk); return chunk; } - [Fact] - public void Clear_OnEmptyCache_LeavesStatsZero() - { - var stats = FreshCache(2).GetStats(); - - Assert.Equal(0, stats.ResidentChunks); - Assert.Equal(0L, stats.Hits); - Assert.Equal(0L, stats.BuildsTotal); - } - // ---- promotion gate ---- /// @@ -65,7 +53,7 @@ public class StepCacheLifecycleTests : IDisposable var cache = FreshCache(promotionThreshold: 2); var map = TestMap; - var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.False(lookup.IsHit); Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind); @@ -77,21 +65,19 @@ public class StepCacheLifecycleTests : IDisposable Assert.Equal(1L, stats.FallthroughNotBuilt); } - [SkippableFact] + [Fact] public void SecondTouchInsideWindow_PromotesAndServes() { - TileDataRequirement.SkipIfMissing(); - var cache = FreshCache(promotionThreshold: 2); var map = TestMap; - Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit); + Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit); - var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.True(promoted.IsHit); Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind); - Assert.Equal((byte)0xC1, promoted.WalkMask); // pinned: open plain, walkable N/NE/... per the bake - Assert.Equal((sbyte)10, promoted.WalkZ_N); + Assert.Equal((byte)0xFF, promoted.WalkMask); // flat default ground: walkable in all eight directions + Assert.Equal(PlainZ, promoted.WalkZ_N); var stats = cache.GetStats(); Assert.Equal(1, stats.ResidentChunks); @@ -100,9 +86,9 @@ public class StepCacheLifecycleTests : IDisposable Assert.Equal(1L, stats.FallthroughNotBuilt); // Now resident: a third query is a clean hit, not another miss. - var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.Equal(CacheHitKind.Hit, hit.HitKind); - Assert.Equal((byte)0xC1, hit.WalkMask); + Assert.Equal((byte)0xFF, hit.WalkMask); } /// @@ -118,10 +104,10 @@ public class StepCacheLifecycleTests : IDisposable var map = TestMap; - Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit); + Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit); Thread.Sleep(20); // outrun the window - var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.False(second.IsHit); Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind); Assert.Equal(0, cache.GetStats().ResidentChunks); @@ -133,11 +119,9 @@ public class StepCacheLifecycleTests : IDisposable /// there, so counting probes would cross any threshold on the second cell and gate nothing at /// all — the deferral would be dead code. /// - [SkippableFact] + [Fact] public void ManyProbesInOneFind_CountAsOneTouch() { - TileDataRequirement.SkipIfMissing(); - var cache = FreshCache(promotionThreshold: 2); var map = TestMap; @@ -145,7 +129,7 @@ public class StepCacheLifecycleTests : IDisposable for (var i = 0; i < 8; i++) { // Eight different cells, all inside the same chunk. - var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: 10); + var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: PlainZ); Assert.False(lookup.IsHit); Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind); } @@ -156,7 +140,7 @@ public class StepCacheLifecycleTests : IDisposable // A second Find is the second distinct touch, and crosses the threshold. cache.BeginFindGeneration(); - var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind); Assert.Equal(1, cache.GetStats().ResidentChunks); @@ -173,12 +157,12 @@ public class StepCacheLifecycleTests : IDisposable var map = TestMap; cache.BeginFindGeneration(); - Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit); + Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit); Thread.Sleep(20); cache.BeginFindGeneration(); - var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind); Assert.Equal(0, cache.GetStats().ResidentChunks); @@ -191,8 +175,8 @@ public class StepCacheLifecycleTests : IDisposable var map = TestMap; // One touch each, in two different chunks: neither reaches the threshold on its own. - Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit); - Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: 10).IsHit); + Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit); + Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: PlainZ).IsHit); Assert.Equal(0, cache.GetStats().ResidentChunks); Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt); @@ -214,16 +198,14 @@ public class StepCacheLifecycleTests : IDisposable /// A multi's cells fall through, and so does the 1-cell halo around it: a cell's mask encodes /// the edges TO its neighbours, so a wall one cell over has to block them. /// - [SkippableFact] + [Fact] public void MultiCoveredCell_AndItsHalo_FallThrough() { - TileDataRequirement.SkipIfMissing(); - var cache = FreshCache(promotionThreshold: 1); var map = TestMap; // A cell nowhere near a multi still serves from the static cache. - Assert.True(cache.TryGetMask(map, PlainX, PlainY, 10).IsHit); + Assert.True(cache.TryGetMask(map, PlainX, PlainY, PlainZ).IsHit); // Mark an isolated sector as multi-bearing. Sector.HasMultis only tests Count > 0 and the // fallthrough never dereferences the multi, so a single null entry is enough — no real @@ -266,7 +248,7 @@ public class StepCacheLifecycleTests : IDisposable var cache = FreshCache(promotionThreshold: 1); var map = TestMap; - cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); var before = cache.GetStats().FallthroughSourceZMismatch; var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 100); @@ -308,8 +290,8 @@ public class StepCacheLifecycleTests : IDisposable offsets[CellIndex(PlainX, PlainY)] = 0; chunk.SetStrata(offsets, OneStratum(zCenter: 42)); - // 10 is more than StepHeight from the only stratum, so nothing can answer. - var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + // The plain is more than StepHeight below the only stratum (Z 42), so nothing can answer. + var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.False(lookup.IsHit); Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind); @@ -333,7 +315,7 @@ public class StepCacheLifecycleTests : IDisposable offsets[CellIndex(PlainX, PlainY)] = 0; chunk.SetStrata(offsets, [0]); // a record declaring zero strata - var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.False(lookup.IsHit); Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind); @@ -406,26 +388,19 @@ public class StepCacheLifecycleTests : IDisposable var cache = FreshCache(promotionThreshold: 1); cache.MaxResidentChunks = 4; - try + var map = TestMap; + + // Five chunks into a cache that holds four. Which one goes doesn't matter here, only that + // the count comes down and the key list stays in step, so equal touch ticks are fine. + for (var i = 0; i < 5; i++) { - var map = TestMap; - - // Five chunks into a cache that holds four. - for (var i = 0; i < 5; i++) - { - cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10); - Thread.Sleep(2); // separate their LastTouchedTicks so LRU has something to order by - } - - cache.EnforceLruCap(); - - Assert.Equal(4, cache.GetStats().ResidentChunks); - Assert.True(cache.GetStats().EvictionsByLruCap >= 1L); - Assert.True(cache.ResidentIndexInSync(), "eviction desynced the key list from the resident set"); - } - finally - { - cache.MaxResidentChunks = 8192; + cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: PlainZ); } + + cache.EnforceLruCap(); + + Assert.Equal(4, cache.GetStats().ResidentChunks); + Assert.True(cache.GetStats().EvictionsByLruCap >= 1L); + Assert.True(cache.ResidentIndexInSync(), "eviction desynced the key list from the resident set"); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs index b38c15277..12d4b0ed4 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Server.Engines.Pathing.Cache; +using Server.Tests.Terrain; using Xunit; using Xunit.Abstractions; using static Server.Tests.Pathfinding.PathingTestSupport; @@ -20,12 +21,31 @@ namespace Server.Tests.Pathfinding; /// falls through on everything agrees with the slow path perfectly and is worthless. /// [Collection("Sequential Pathfinding Tests")] -public class StepCacheParityTests +public class StepCacheParityTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public StepCacheParityTests(ITestOutputHelper output) => _output = output; + public void Dispose() => _cacheState.Dispose(); + + // "Plain" is the synthetic map's default ground (flat, no statics); every other name is a prefab. + private static Rectangle2D Region(string scene) + { + if (scene == "Plain") + { + return new Rectangle2D(PlainX, PlainY, 32, 32); + } + + var prefab = Prefabs.Named(scene); + prefab.AssertPresent(); + return prefab.Bounds; + } + + private static Point3D Seed(string scene) => + scene == "Plain" ? new Point3D(PlainX, PlainY, PlainZ) : Prefabs.Named(scene).Anchor; + // ---- layer 1: the bake agrees with MovementImpl ---- /// @@ -33,17 +53,18 @@ public class StepCacheParityTests /// The probe stores raw masks and leaves the diagonal corner-cut to the caller, so the rule has /// to be applied here before the two are comparable. /// - [SkippableTheory] - [InlineData("britain_inn_dense", 1480, 1610, 32)] - [InlineData("trammel_open_plain", 1500, 1600, 32)] - public void ProbeMatchesSlowPath(string label, int xStart, int yStart, int size) + [Theory] + [InlineData("Town")] + [InlineData("Plain")] + public void ProbeMatchesSlowPath(string label) { - TileDataRequirement.SkipIfMissing(); + var region = Region(label); + int xStart = region.X, yStart = region.Y, size = region.Width; var map = TestMap; Assert.NotNull(map); - var walker = new StaticWalker(); + var walker = new WalkerStub(); walker.MoveToWorld(new Point3D(xStart, yStart, 0), map); var disagreements = 0; @@ -98,7 +119,7 @@ public class StepCacheParityTests // The dense region must contain a mix. All-walkable or all-blocked would mean the sweep // agreed about nothing interesting. - if (label == "britain_inn_dense") + if (label == "Town") { Assert.NotEqual(0, walkable); Assert.NotEqual(samples, walkable); @@ -112,18 +133,15 @@ public class StepCacheParityTests /// WetMask everywhere would pass every parity test above — walkers would still agree — while /// leaving every swimming creature unable to move. /// - [SkippableFact] - public void ProbeBakesWetCells_OnAKnownCoastline() + [Fact] + public void ProbeBakesWetCells_OnACoastline() { - TileDataRequirement.SkipIfMissing(); - var map = TestMap; Assert.NotNull(map); - // South Britain into Britain bay: 64x64 straddling the Atlantic shoreline. - const int xStart = 1430; - const int yStart = 1740; - const int size = 64; + var coast = Prefabs.Coastline; + coast.AssertPresent(); + int xStart = coast.Bounds.X, yStart = coast.Bounds.Y, size = coast.Bounds.Width; var wetCells = 0; for (var x = xStart; x < xStart + size; x++) @@ -139,7 +157,7 @@ public class StepCacheParityTests } } - _output.WriteLine($"south-britain coastline: wetCells={wetCells} of {size * size}"); + _output.WriteLine($"coastline: wetCells={wetCells} of {size * size}"); Assert.True(wetCells > 0, $"swim bake produced zero wet cells across a {size}x{size} coastal region"); } @@ -155,11 +173,16 @@ public class StepCacheParityTests /// walkway) and report a fallthrough that is correct behaviour rather than a fault. /// [Theory] - [InlineData("britain_inn_dense", 1480, 1610, 32)] - [InlineData("trammel_open_plain", 1500, 1600, 32)] - [InlineData("britain_causeway", 1475, 1641, 32)] - public void CacheMatchesProbe(string label, int xStart, int yStart, int size) + [InlineData("Town")] + [InlineData("Plain")] + [InlineData("Causeway")] + public void CacheMatchesProbe(string label) { + // A storage fault (crossed cell index, swapped Z array) only shows against varied terrain; + // on flat ground every cell bakes the same answer, so the plain alone proves nothing. + var region = Region(label); + int xStart = region.X, yStart = region.Y, size = region.Width; + var cache = StepCache.Instance; cache.Clear(); cache.MissPromotionThreshold = 1; // build on first touch: every cell should get a real answer @@ -228,7 +251,7 @@ public class StepCacheParityTests // Guard the sweep itself: if every cell fell through as multi-Z, the comparison above never // actually ran and a zero disagreement count would mean nothing. - if (label == "britain_inn_dense") + if (label == "Town") { Assert.True(samples - multiZ > 0, "no cell produced a real cache answer — the sweep proved nothing"); } @@ -254,14 +277,18 @@ public class StepCacheParityTests /// raw diagonal bit legitimately differs from MovementImpl's diagonal answer. /// [Theory] - // Seeds chosen for the terrain classes the standable-surface bake has to get right. Each one + // Scenes chosen for the terrain classes the standable-surface bake has to get right. Each seed // floods across a wide local area, so a handful covers thousands of states without a map walk. - [InlineData("brit_sewer_walkway", 6034, 1476, 5, 2500)] // static walkway over impassable land - [InlineData("brit_inn_stairs_to_floors", 1495, 1628, 10, 2500)] // stairs up to multi-Z upper floors - [InlineData("brit_town_cobblestones", 1494, 1626, 10, 2500)] // mixed buildings, stairs, raised floors - [InlineData("trammel_open_plain", 1500, 1600, 10, 2500)] // flat ground: catches clearance false-positives - public void CacheServesReachableWalkStates(string label, int sx, int sy, int sz, int maxStates) + [InlineData("SewerWalkway", 2500)] // static walkway over impassable land + [InlineData("Town", 2500)] // buildings, and a ramp up to a deck over open ground (two Zs per cell) + [InlineData("Causeway", 2500)] // bridge over impassable land + [InlineData("Plain", 2500)] // flat ground: catches clearance false-positives + public void CacheServesReachableWalkStates(string label, int maxStates) { + Region(label); + var seed = Seed(label); + int sx = seed.X, sy = seed.Y, sz = seed.Z; + var cache = StepCache.Instance; cache.Clear(); cache.MissPromotionThreshold = 1; // build on first touch: every reached state should be answered @@ -269,7 +296,7 @@ public class StepCacheParityTests var map = TestMap; Assert.NotNull(map); - var walker = new StaticWalker(); + var walker = new WalkerStub(); walker.MoveToWorld(new Point3D(sx, sy, sz), map); var startIsWalkable = false; diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs index 9a32d66bf..140cacb8a 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepProbeTests.cs @@ -78,174 +78,4 @@ public class StepProbeTests Assert.False(result.IsWalkable(Direction.South), "S should be off-map"); Assert.False(result.IsWalkable(Direction.Left), "SW should be off-map"); } - - [Fact] - public void ComputeMaskAt_TrammelOpenPlain_HasFlatCellWithAllDirectionsWalkable() - { - // Invariant: somewhere in the open-plain region, there must be a fully-flat cell - // whose mask is 0xFF and all 8 destZ values equal sourceZ (no slope). - // This protects against a regression where the baker stops detecting flat cells. - var map = Map.Maps[1]; - Assert.NotNull(map); - - var found = false; - - for (var x = 1500; x < 1532 && !found; x++) - { - for (var y = 1600; y < 1632 && !found; y++) - { - map.GetAverageZ(x, y, out _, out var avgZ, out _); - var sourceZ = (sbyte)avgZ; - var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ); - - if (result.WalkMask != 0xFF) - { - continue; - } - - var allSameZ = true; - for (var d = 0; d < 8; d++) - { - if (result.GetWalkZ((Direction)d) != sourceZ) - { - allSameZ = false; - break; - } - } - - if (allSameZ) - { - found = true; - _output.WriteLine($"Flat-open cell found at ({x}, {y}, {sourceZ})"); - } - } - } - - Assert.True(found, "Expected at least one fully-flat open cell in (1500..1532, 1600..1632)"); - } - - [SkippableFact] - public void ComputeMaskAt_BritainInnDense_HasCellWithBlockedDirections() - { - TileDataRequirement.SkipIfMissing(); - // Invariant: inside the dense Britain inn region, at least one cell must have - // at least one direction blocked by a static. Protects against a regression - // where the baker reports everything as walkable (the original false-pass bug). - var map = Map.Maps[1]; - Assert.NotNull(map); - - var blockedCellCount = 0; - - for (var x = 1480; x < 1512; x++) - { - for (var y = 1610; y < 1642; y++) - { - map.GetAverageZ(x, y, out _, out var avgZ, out _); - var sourceZ = (sbyte)avgZ; - var result = StepProbe.ComputeMaskAt(map, x, y, sourceZ); - - if (result.WalkMask != 0xFF) - { - blockedCellCount++; - } - } - } - - _output.WriteLine($"Cells with at least one blocked direction: {blockedCellCount} / 1024"); - Assert.True(blockedCellCount > 0, "Expected at least one cell with a blocked direction in the dense region"); - } - - [Theory] - [InlineData(1500, 1600)] - [InlineData(1480, 1610)] - [InlineData(0, 0)] - public void ComputeMaskAt_IsDeterministic(int x, int y) - { - // Same inputs must always produce identical output. Catches accidental - // statefulness in the baker (e.g., a static cache that gets corrupted). - var map = Map.Maps[1]; - Assert.NotNull(map); - - map.GetAverageZ(x, y, out _, out var avgZ, out _); - var sourceZ = (sbyte)avgZ; - - var first = StepProbe.ComputeMaskAt(map, x, y, sourceZ); - var second = StepProbe.ComputeMaskAt(map, x, y, sourceZ); - var third = StepProbe.ComputeMaskAt(map, x, y, sourceZ); - - Assert.Equal(first.WalkMask, second.WalkMask); - Assert.Equal(first.WalkMask, third.WalkMask); - for (var d = 0; d < 8; d++) - { - Assert.Equal(first.GetWalkZ((Direction)d), second.GetWalkZ((Direction)d)); - Assert.Equal(first.GetWalkZ((Direction)d), third.GetWalkZ((Direction)d)); - } - } - - [SkippableFact] - public void ComputeMaskAt_PinnedCell_TrammelOpenPlainOrigin() - { - TileDataRequirement.SkipIfMissing(); - // PINNING test: locks specific output for Trammel (1500, 1600). - // Cell at z=10 with mask 0xC1 (N + W + NW only walkable). The other five - // directions are blocked by water (E/SE/S/SW are wet tiles, NE is shore). - // If this changes, either the baker logic changed or the underlying tile - // data changed; re-run the parity test to confirm before updating expected values. - var map = Map.Maps[1]; - Assert.NotNull(map); - - map.GetAverageZ(1500, 1600, out _, out var avgZ, out _); - var sourceZ = (sbyte)avgZ; - - var result = StepProbe.ComputeMaskAt(map, 1500, 1600, sourceZ); - - Assert.Equal((sbyte)10, sourceZ); - Assert.Equal((byte)0xC1, result.WalkMask); - - Assert.True(result.IsWalkable(Direction.North)); - Assert.False(result.IsWalkable(Direction.Right)); - Assert.False(result.IsWalkable(Direction.East)); - Assert.False(result.IsWalkable(Direction.Down)); - Assert.False(result.IsWalkable(Direction.South)); - Assert.False(result.IsWalkable(Direction.Left)); - Assert.True(result.IsWalkable(Direction.West)); - Assert.True(result.IsWalkable(Direction.Up)); - - Assert.Equal((sbyte)10, result.GetWalkZ(Direction.North)); - Assert.Equal((sbyte)10, result.GetWalkZ(Direction.West)); - Assert.Equal((sbyte)10, result.GetWalkZ(Direction.Up)); - } - - [SkippableFact] - public void ComputeMaskAt_PinnedCell_BritainInnDenseOrigin() - { - TileDataRequirement.SkipIfMissing(); - // PINNING test: locks specific output for Trammel (1480, 1610). - // Cell at z=20 with mask 0x3F (N/NE/E/SE/S/SW walkable, W/NW blocked by - // a wall to the west). All walkable directions stay flat at z=20. - var map = Map.Maps[1]; - Assert.NotNull(map); - - map.GetAverageZ(1480, 1610, out _, out var avgZ, out _); - var sourceZ = (sbyte)avgZ; - - var result = StepProbe.ComputeMaskAt(map, 1480, 1610, sourceZ); - - Assert.Equal((sbyte)20, sourceZ); - Assert.Equal((byte)0x3F, result.WalkMask); - - Assert.True(result.IsWalkable(Direction.North)); - Assert.True(result.IsWalkable(Direction.Right)); - Assert.True(result.IsWalkable(Direction.East)); - Assert.True(result.IsWalkable(Direction.Down)); - Assert.True(result.IsWalkable(Direction.South)); - Assert.True(result.IsWalkable(Direction.Left)); - Assert.False(result.IsWalkable(Direction.West)); - Assert.False(result.IsWalkable(Direction.Up)); - - for (var d = 0; d < 6; d++) - { - Assert.Equal((sbyte)20, result.GetWalkZ((Direction)d)); - } - } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs index 9eb9e1909..946e83e36 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/AllSpawnFilesLoadTests.cs @@ -20,10 +20,9 @@ public class AllSpawnFilesLoadTests public void EverySpawnFile_DeserializesAndBuildsSpawners() { var root = Path.Combine(Core.BaseDirectory, "Data", "Spawns"); - if (!Directory.Exists(root)) - { - return; // distribution data not present in this checkout - } + // Data/ is copied into the test output by the build; a missing folder means the build was run + // without SolutionDir, and passing would hide that nothing was checked. + Assert.True(Directory.Exists(root), $"Spawn data not found at {root}"); var failures = new List(); var fileCount = 0; @@ -64,6 +63,7 @@ public class AllSpawnFilesLoadTests } } + Assert.NotEqual(0, fileCount); Assert.True( failures.Count == 0, $"Loaded {fileCount} files, built {spawnerCount} spawners. Failures:\n{string.Join("\n", failures)}" diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs index a9b7554fd..e30cc289d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs @@ -3,22 +3,24 @@ using System.Collections.Generic; using System.IO; using Server; using Server.Engines.Spawners; +using Server.Tests; using Xunit; namespace UOContent.Tests.Engines.Spawners.Json; [Collection("Sequential UOContent Tests")] -public class ImportCleanupTests +public class ImportCleanupTests : IDisposable { private const string SpawnerGuid = "11111111-1111-1111-1111-111111111111"; + private readonly TempDirectory _dir = new("muo-spawner-import"); + + public void Dispose() => _dir.Dispose(); + [Fact] public void Import_ValidFile_PlacesSpawner() { - var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - var path = Path.Combine(dir, "test.json"); - File.WriteAllText(path, """ + var path = _dir.WriteFile("test.json", """ [ { "$type": "Spawner", @@ -53,22 +55,15 @@ public class ImportCleanupTests finally { placedSpawner?.Delete(); - if (Directory.Exists(dir)) - { - Directory.Delete(dir, true); - } } } + // A DTO with no map entry deserializes with map == null. The importer rejects it before building + // a spawner, so nothing is registered and nothing lands on any live map. [Fact] - public void Import_NoMap_DeletesOrphanedSpawner() + public void Import_NoMap_SkipsTheSpawner() { - // A spawner with no map entry deserialized by STJ will have map == null (or Map.Internal). - // Verify it does NOT end up on any live map. - var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-nomap-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - var path = Path.Combine(dir, "test-nomap.json"); - File.WriteAllText(path, """ + var path = _dir.WriteFile("test-nomap.json", """ [ { "$type": "Spawner", @@ -81,71 +76,28 @@ public class ImportCleanupTests ] """); - try - { - var all = new Dictionary(); - ImportSpawnersCommand.ImportFile(new FileInfo(path), all); + var guid = new Guid("22222222-2222-2222-2222-222222222222"); + var all = new Dictionary(); + ImportSpawnersCommand.ImportFile(new FileInfo(path), all); - // The spawner must NOT be in allSpawners (rejected due to missing map). - Assert.DoesNotContain(new Guid("22222222-2222-2222-2222-222222222222"), all.Keys); - } - finally + Assert.DoesNotContain(guid, all.Keys); + + var maps = Map.AllMaps; + for (var i = 0; i < maps.Count; i++) { - if (Directory.Exists(dir)) + foreach (var spawner in maps[i].GetItemsAt(new Point3D(400, 400, 0))) { - Directory.Delete(dir, true); + Assert.NotEqual(guid, spawner.Guid); } } } - [Fact] - public void Import_MalformedFile_LeaksNoWorldItems() - { - // First entry is syntactically valid JSON for a Spawner, second entry is truncated/garbage. - // STJ throws a JsonException during array deserialization before any Items are created - // (the DTO path is GC-only), so zero new spawners should appear at the valid entry's location. - var dir = Path.Combine(Path.GetTempPath(), "muo-import-malformed-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, "bad.json"), """ - [ { "$type": "Spawner", "location": [320, 320, 0], "map": "Felucca", "count": 1, - "entries": [ { "name": "Fisherman", "maxCount": 1, "probability": 100 } ] }, - { "$type": "Spawner", "location": [ THIS IS NOT JSON - """); - try - { - var before = CountSpawnersNear(new Point3D(320, 320, 0)); - ImportSpawnersCommand.ImportFile(new FileInfo(Path.Combine(dir, "bad.json")), new Dictionary()); - // Malformed parse must construct zero Items (DTO is GC-only); nothing placed or orphaned. - Assert.Equal(before, CountSpawnersNear(new Point3D(320, 320, 0))); - } - finally - { - Directory.Delete(dir, true); - } - } - - private static int CountSpawnersNear(Point3D p) - { - var n = 0; - foreach (var _ in Map.Felucca.GetItemsAt(p)) - { - n++; - } - - return n; - } - [Fact] public void Import_DuplicateLocation_ReplacesExistingSpawner() { // Verifies that an existing spawner at the same location and type is removed and replaced. - var dir = Path.Combine(Path.GetTempPath(), "muo-spawner-import-dup-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - var path = Path.Combine(dir, "test-dup.json"); - var newGuid = new Guid("33333333-3333-3333-3333-333333333333"); - // Use a non-interpolated raw string literal; the guid is a known constant so hardcode it. - File.WriteAllText(path, """ + var path = _dir.WriteFile("test-dup.json", """ [ { "$type": "Spawner", @@ -159,13 +111,13 @@ public class ImportCleanupTests ] """); - // Place a pre-existing spawner at the same location. var existing = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); - existing.MoveToWorld(new Point3D(310, 310, 0), Map.Felucca); - BaseSpawner placedSpawner = null; + try { + existing.MoveToWorld(new Point3D(310, 310, 0), Map.Felucca); + var all = new Dictionary { [existing.Guid] = existing }; ImportSpawnersCommand.ImportFile(new FileInfo(path), all); @@ -186,15 +138,8 @@ public class ImportCleanupTests } finally { - if (!existing.Deleted) - { - existing.Delete(); - } + existing.Delete(); placedSpawner?.Delete(); - if (Directory.Exists(dir)) - { - Directory.Delete(dir, true); - } } } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/MigratedDataLoadTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/MigratedDataLoadTests.cs deleted file mode 100644 index b0846a795..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/MigratedDataLoadTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Regression guard: verifies that a real migrated spawn file (post-uoml/felucca/Vendors.json) -// can be deserialized as List and each DTO produces a valid spawner via ToSpawner(). -// Exercises the $type discriminator path introduced by the data migration. - -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using Server; -using Server.Engines.Spawners; -using Xunit; - -namespace UOContent.Tests.Engines.Spawners.Json; - -[Collection("Sequential UOContent Tests")] -public class MigratedDataLoadTests -{ - [Fact] - public void MigratedFile_LoadsWithDollarType() - { - var path = Path.Combine(Core.BaseDirectory, "Data", "Spawns", "post-uoml", "felucca", "Vendors.json"); - if (!File.Exists(path)) - { - return; // distribution data not present in this checkout - } - - var dtos = JsonSerializer.Deserialize>(File.ReadAllText(path), SpawnerJsonSerializer.Options); - Assert.NotEmpty(dtos); - - var spawners = new List(dtos.Count); - try - { - foreach (var dto in dtos) - { - spawners.Add(dto.ToSpawner()); - } - - Assert.Equal(dtos.Count, spawners.Count); - } - finally - { - foreach (var s in spawners) - { - s?.Delete(); - } - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs index 8a25c4b0f..930f059ce 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoEntryTests.cs @@ -15,15 +15,21 @@ public class SpawnerDtoEntryTests public void Export_WritesEntriesAtSameJsonPosition_WithDisabledOnlyWhenSet() { var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit", "Bird"); - spawner.Entries[1].Disabled = true; - var json = SpawnerJsonSerializer.SerializeCompact(new List { spawner.ToDto() }); + try + { + spawner.Entries[1].Disabled = true; - Assert.Contains("\"entries\": [\n { \"name\": \"Rabbit\"", json); - Assert.Contains("{ \"name\": \"Bird\"", json); - Assert.Equal(1, CountOccurrences(json, "\"disabled\": true")); + var json = SpawnerJsonSerializer.SerializeCompact(new List { spawner.ToDto() }); - spawner.Delete(); + Assert.Contains("\"entries\": [\n { \"name\": \"Rabbit\"", json); + Assert.Contains("{ \"name\": \"Bird\"", json); + Assert.Equal(1, CountOccurrences(json, "\"disabled\": true")); + } + finally + { + spawner.Delete(); + } } [Fact] @@ -35,11 +41,16 @@ public class SpawnerDtoEntryTests var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); var spawner = dtos![0].ToSpawner(); - Assert.Single(spawner.Entries); - Assert.True(spawner.Entries[0].Disabled); - Assert.Same(dtos[0].EntryView[0], spawner.Entries[0]); - - spawner.Delete(); + try + { + Assert.Single(spawner.Entries); + Assert.True(spawner.Entries[0].Disabled); + Assert.Same(dtos[0].EntryView[0], spawner.Entries[0]); + } + finally + { + spawner.Delete(); + } } private static int CountOccurrences(string source, string value) diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs index 13ba62da3..42b4ee9be 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/SpawnerDtoRoundTripTests.cs @@ -44,33 +44,6 @@ public class SpawnerDtoRoundTripTests } } - [Fact] - public void Spawner_HomeRangeSquare_WritesHomeRange_RoundTrips() - { - Spawner original = null; - BaseSpawner rebuilt = null; - try - { - original = new Spawner("Fisherman"); - original.MoveToWorld(new Point3D(200, 200, 0), Map.Felucca); - // Exactly what homeRange 5 reconstructs (centered square, standard z/depth). - original.SpawnBounds = new Rectangle3D(195, 195, -128, 11, 11, 256); - - var json = JsonSerializer.Serialize(new List { original.ToDto() }, SpawnerJsonSerializer.Options); - Assert.Contains("\"homeRange\": 5", json); - Assert.DoesNotContain("spawnBounds", json); - - var dtos = JsonSerializer.Deserialize>(json, SpawnerJsonSerializer.Options); - rebuilt = Assert.Single(dtos).ToSpawner(); - Assert.Equal(new Rectangle3D(195, 195, -128, 11, 11, 256), rebuilt.SpawnBounds); - } - finally - { - rebuilt?.Delete(); - original?.Delete(); - } - } - [Fact] public void Spawner_WritesMandatory_OmitsOptionalDefaults() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs index bbf8e2112..ad67e96b9 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs @@ -7,14 +7,6 @@ namespace UOContent.Tests; public class BitMask256Tests { - [Fact] - public void BitMask256_InitialState_AllBitsZero() - { - var mask = BitMask256.AllClear(); - - Assert.Equal(0, mask.PopCount()); - } - [Theory] [InlineData(0)] [InlineData(63)] @@ -110,41 +102,6 @@ public class SectorSpawnCacheManagerTests SectorSpawnCacheManager.ClearAll(); } - [Fact] - public void ClearAll_ResetsCache() - { - SectorSpawnCacheManager.ClearAll(); - Assert.Equal(0, SectorSpawnCacheManager.CachedSectorCount); - Assert.Equal(0, SectorSpawnCacheManager.LandCacheCount); - Assert.Equal(0, SectorSpawnCacheManager.WaterCacheCount); - } - - [Fact] - public void SetValid_CreatesSectorCache() - { - var map = Map.Felucca; - var pos = new Point3D(100, 100, 0); - - SectorSpawnCacheManager.SetValid(map, pos, isWater: false); - - Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); - Assert.Equal(1, SectorSpawnCacheManager.LandCacheCount); - Assert.Equal(0, SectorSpawnCacheManager.WaterCacheCount); - } - - [Fact] - public void SetValid_Water_CreatesWaterCache() - { - var map = Map.Felucca; - var pos = new Point3D(100, 100, 0); - - SectorSpawnCacheManager.SetValid(map, pos, isWater: true); - - Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); - Assert.Equal(0, SectorSpawnCacheManager.LandCacheCount); - Assert.Equal(1, SectorSpawnCacheManager.WaterCacheCount); - } - [Fact] public void SetValid_LandAndWater_SeparateCaches() { @@ -251,19 +208,6 @@ public class SectorSpawnCacheManagerTests Assert.False(result); } - [Fact] - public void InvalidateSectors_RemovesCachedData() - { - var map = Map.Felucca; - - SectorSpawnCacheManager.SetValid(map, new Point3D(50, 50, 0), isWater: false); - Assert.Equal(1, SectorSpawnCacheManager.CachedSectorCount); - - SectorSpawnCacheManager.InvalidateSectors(map, new Rectangle2D(0, 0, 100, 100)); - - Assert.Equal(0, SectorSpawnCacheManager.CachedSectorCount); - } - [Fact] public void InvalidateSectors_RemovesBothLandAndWater() { @@ -316,14 +260,8 @@ public class SectorSpawnCacheManagerTests } } -[Collection("SectorSpawnCache")] public class SpiralScanTests { - public SpiralScanTests() - { - SectorSpawnCacheManager.ClearAll(); - } - [Theory] [InlineData(1, 0, -1, -1)] // Ring 1, position 0: top-left [InlineData(1, 1, 0, -1)] // Ring 1, position 1: top @@ -341,54 +279,6 @@ public class SpiralScanTests Assert.Equal(expectedDy, dy); } - [Fact] - public void SpiralPattern_Ring1Has8Positions() - { - // Ring N has 8*N positions - const int ring1Positions = 1 * 8; - Assert.Equal(8, ring1Positions); - } - - [Fact] - public void SpiralPattern_Ring2Has16Positions() - { - const int ring2Positions = 2 * 8; - Assert.Equal(16, ring2Positions); - } - - [Fact] - public void SpiralPattern_Ring10Has80Positions() - { - const int ring10Positions = 10 * 8; - Assert.Equal(80, ring10Positions); - } - - [Fact] - public void SpiralPattern_Ring1_CoversAllAdjacentTiles() - { - // Ring 1 should cover all 8 adjacent tiles - var expectedOffsets = new HashSet<(int dx, int dy)> - { - (-1, -1), (0, -1), (1, -1), // Top row - (1, 0), // Right - (1, 1), (0, 1), (-1, 1), // Bottom row - (-1, 0) // Left - }; - - var actualOffsets = new HashSet<(int dx, int dy)>(); - - for (var position = 0; position < 8; position++) - { - actualOffsets.Add(SectorSpawnCacheManager.GetSpiralOffset(1, position)); - } - - Assert.Equal(expectedOffsets.Count, actualOffsets.Count); - foreach (var expected in expectedOffsets) - { - Assert.Contains(expected, actualOffsets); - } - } - [Fact] public void SpiralPattern_Ring2_CoversAllExpectedTiles() { @@ -414,16 +304,6 @@ public class SpiralScanTests public class SpawnPositionStateTests { - [Fact] - public void SpawnPositionState_InitialState_ZeroCounts() - { - var state = new SpawnPositionState(); - - Assert.False(state.SpiralComplete); - Assert.Equal(0, state.SpiralRing); - Assert.Equal(0, state.SpiralRingPosition); - } - [Fact] public void Reset_ClearsAllState() { @@ -457,14 +337,6 @@ public class SpawnPositionStateTests Assert.False(state.ShouldCachePositions(SpawnPositionMode.Disabled)); } - [Fact] - public void ShouldCachePositions_Automatic_FalseWithNoFailures() - { - var state = new SpawnPositionState(); - - Assert.False(state.ShouldCachePositions(SpawnPositionMode.Automatic)); - } - [Fact] public void ShouldCachePositions_Automatic_TrueAfterThresholdFailures() { @@ -522,30 +394,6 @@ public class SpawnPositionStateTests Assert.True(state.ShouldAbandon()); } - [Fact] - public void ShouldAbandon_FalseWithUsefulCacheHit() - { - var state = new SpawnPositionState(); - state.SpiralComplete = true; - - // Record 24 useless results - for (var i = 0; i < 24; i++) - { - state.RecordUselessResult(); - } - - // Record a useful cache hit - resets counter - state.RecordUsefulCacheHit(); - - // Record a few more useless results - for (var i = 0; i < 5; i++) - { - state.RecordUselessResult(); - } - - Assert.False(state.ShouldAbandon()); // Counter was reset, only 5 now - } - [Fact] public void RecordUsefulCacheHit_ResetsUselessCounter() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs index 767a3289d..6d9e8e711 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text.Json; @@ -11,26 +12,43 @@ using Xunit; namespace UOContent.Tests.Engines.Spawners; [Collection("Sequential UOContent Tests")] -public class SpawnerEntryOwnershipTests +public class SpawnerEntryOwnershipTests : IDisposable { + // Deleted in Dispose so a failed assertion cannot leave spawners registered in the world. + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private T Track(T item) where T : Item + { + _created.Add(item); + return item; + } + + private Spawner NewSpawner(params string[] names) => + Track(new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, names)); + [Fact] public void Disabled_DefaultsFalse_AndRoundTripsBinary() { - var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + var spawner = NewSpawner("Rabbit", "Bird"); Assert.False(spawner.Entries[0].Disabled); Assert.True(spawner.Entries[0].Enabled); spawner.Entries[1].Disabled = true; var bytes = SpawnerBlob.Write(spawner); - var loaded = SpawnerBlob.Read(bytes, (Serial)0x40001234); + var loaded = Track(SpawnerBlob.Read(bytes, (Serial)0x40001234)); Assert.Equal(2, loaded.Entries.Count); Assert.False(loaded.Entries[0].Disabled); Assert.True(loaded.Entries[1].Disabled); - - spawner.Delete(); - loaded.Delete(); } [Fact] @@ -48,7 +66,7 @@ public class SpawnerEntryOwnershipTests [Fact] public void AddRemoveClear_OperateOnOwnerList() { - var spawner = new Spawner(); + var spawner = Track(new Spawner()); Assert.Empty(spawner.Entries); var a = spawner.AddEntry("Rabbit", 100, 2, false); @@ -63,29 +81,15 @@ public class SpawnerEntryOwnershipTests spawner.RemoveAllEntries(); Assert.Empty(spawner.Entries); - - spawner.Delete(); - } - - [Fact] - public void Start_WorksAfterStop_WhenOwnerHasEntries() - { - var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); - Assert.True(spawner.Running); - spawner.Stop(); - Assert.False(spawner.Running); - spawner.Start(); - Assert.True(spawner.Running); - spawner.Delete(); } [Fact] public void Dupe_ClonesEntriesIntoIndependentList() { - var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + var source = NewSpawner("Rabbit", "Bird"); source.Entries[1].Disabled = true; - var copy = new Spawner(); + var copy = Track(new Spawner()); source.Dupe(copy); Assert.Equal(2, copy.Entries.Count); @@ -96,31 +100,25 @@ public class SpawnerEntryOwnershipTests source.AddEntry("Orc", 100, 1, false); Assert.Equal(2, copy.Entries.Count); - - source.Delete(); - copy.Delete(); } [Fact] public void CopyEntriesTo_ReplacesTargetEntries() { - var source = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); - var target = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Orc", "Troll"); + var source = NewSpawner("Rabbit"); + var target = NewSpawner("Orc", "Troll"); source.CopyEntriesTo(target); Assert.Single(target.Entries); Assert.Equal("Rabbit", target.Entries[0].SpawnedName); Assert.NotSame(source.Entries[0], target.Entries[0]); - - source.Delete(); - target.Delete(); } [Fact] public void CopyEntriesTo_Self_IsNoOp() { - var spawner = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird"); + var spawner = NewSpawner("Rabbit", "Bird"); var first = spawner.Entries[0]; var second = spawner.Entries[1]; @@ -129,18 +127,16 @@ public class SpawnerEntryOwnershipTests Assert.Equal(2, spawner.Entries.Count); Assert.Same(first, spawner.Entries[0]); Assert.Same(second, spawner.Entries[1]); - - spawner.Delete(); } [Fact] public void RemoveEntry_ForeignEntry_IsIgnored() { - var a = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit"); - var b = new Spawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Bird"); + var a = NewSpawner("Rabbit"); + var b = NewSpawner("Bird"); var foreign = b.Entries[0]; - var spawned = new Item(0x1f13); + var spawned = Track(new Item(0x1f13)); foreign.AddToSpawned(spawned); a.RemoveEntry(foreign); @@ -152,92 +148,7 @@ public class SpawnerEntryOwnershipTests Assert.False(spawned.Deleted); Assert.Single(foreign.Spawned); - - spawned.Delete(); - a.Delete(); - b.Delete(); } // Manual: remove the Skip, run, and read the numbers from the thrown assertion. - [Fact(Skip = "manual benchmark")] - public void Benchmark_SpawnPath_Manual() - { - const int iterations = 100_000; - const int warmup = 1_000; - - var report = new ValueStringBuilder(stackalloc char[512]); - report.Append('\n', 1); - - foreach (var entryCount in new[] { 1, 10, 50 }) - { - var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); - spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); - - // SpawnedMaxCount = 0 keeps every entry full, so Spawn() does selection only. - for (var i = 0; i < entryCount; i++) - { - spawner.AddEntry("Rabbit", 100, 0, false); - } - - for (var i = 0; i < warmup; i++) - { - spawner.Spawn(); - } - - var sw = Stopwatch.StartNew(); - for (var i = 0; i < iterations; i++) - { - spawner.Spawn(); - } - - sw.Stop(); - - var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations; - report.Append( - $"Spawn() entries={entryCount,2}: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n" - ); - - spawner.Delete(); - } - - { - var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); - spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); - - for (var i = 0; i < 10; i++) - { - spawner.AddEntry("Rabbit", 100, 1, false); - } - - spawner.Spawn(); - Assert.Single(spawner.Spawned); - var rabbit = spawner.Spawned.Keys.First(); - - for (var i = 0; i < warmup; i++) - { - spawner.Remove(rabbit); - } - - var sw = Stopwatch.StartNew(); - for (var i = 0; i < iterations; i++) - { - spawner.Remove(rabbit); - } - - sw.Stop(); - - var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations; - report.Append( - $"Remove(ISpawnable) entries=10: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n" - ); - - (rabbit as Mobile)?.Delete(); - spawner.Delete(); - } - - var summary = report.ToString(); - report.Dispose(); - - throw new Xunit.Sdk.XunitException(summary); - } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs deleted file mode 100644 index d3f48792a..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using Server.Engines.Spawners; -using Server.Tests; -using Xunit; - -namespace UOContent.Tests.Engines.Spawners; - -[Collection("Sequential UOContent Tests")] -public class SpawnerFixtureCapture -{ - // Freezes the BaseSpawner v12 save layout as test input; refuses to run against newer code. - [SkippableFact] - public void CaptureLegacyBlobs() - { - Skip.If(Environment.GetEnvironmentVariable("MODERNUO_CAPTURE_SPAWNER_FIXTURES") != "1"); - - var version = (int)typeof(BaseSpawner) - .GetField("SerializationVersion", BindingFlags.NonPublic | BindingFlags.Static)! - .GetRawConstantValue()!; - Assert.True(version == 12, $"Fixtures must be captured with BaseSpawner v12; current version is {version}."); - - var dir = Path.Combine(AppContext.BaseDirectory, "Tests", "Engines", "Spawners", "Fixtures"); - Directory.CreateDirectory(dir); - - var spawner = new Spawner(2, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird") - { - Name = "FixtureSpawner" - }; - spawner.Entries[1].Properties = "Hue 33"; - File.WriteAllBytes(Path.Combine(dir, "spawner.v12-v1.bin"), SpawnerBlob.Write(spawner)); - - var proximity = new ProximitySpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, 5, "boo", true, "Rat", "Bird") - { - Name = "FixtureProximity" - }; - File.WriteAllBytes(Path.Combine(dir, "proximity.v12-v1-v0.bin"), SpawnerBlob.Write(proximity)); - - var region = new RegionSpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, "Orc", "Troll") - { - Name = "FixtureRegion" - }; - File.WriteAllBytes(Path.Combine(dir, "region.v12-v1-v0.bin"), SpawnerBlob.Write(region)); - - spawner.Delete(); - proximity.Delete(); - region.Delete(); - } -} diff --git a/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs b/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs index 0f4518289..629d7f16e 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs @@ -103,45 +103,6 @@ public class GridLayoutTests Assert.Equal(800, sizes[1]); } - [Fact] - public void TestGridCell_Properties() - { - var cell = new GridCell(10, 20, 100, 50); - - Assert.Equal(10, cell.X); - Assert.Equal(20, cell.Y); - Assert.Equal(100, cell.Width); - Assert.Equal(50, cell.Height); - Assert.Equal(110, cell.Right); - Assert.Equal(70, cell.Bottom); - Assert.Equal(60, cell.CenterX); - Assert.Equal(45, cell.CenterY); - } - - [Fact] - public void TestGridCell_Inset() - { - var cell = new GridCell(10, 20, 100, 50); - var inset = cell.Inset(5); - - Assert.Equal(15, inset.X); - Assert.Equal(25, inset.Y); - Assert.Equal(90, inset.Width); - Assert.Equal(40, inset.Height); - } - - [Fact] - public void TestGridCell_Offset() - { - var cell = new GridCell(10, 20, 100, 50); - var offset = cell.Offset(5, 10); - - Assert.Equal(15, offset.X); - Assert.Equal(30, offset.Y); - Assert.Equal(100, offset.Width); - Assert.Equal(50, offset.Height); - } - [Fact] public void TestListViewLayout_Pagination() { diff --git a/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs b/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs index c4a944cd0..b8dff707b 100644 --- a/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs @@ -73,34 +73,6 @@ public class T2AJewelGemCraftTests } } - [Fact] - public void OnCraft_WithUnsetGemContext_LeavesPlainPiece() - { - var map = Map.Felucca; - var player = CreatePlayerMobile(map, new Point3D(4120, 500, 0)); - var ring = new GoldRing(); - - try - { - player.Backpack.AddItem(new IronIngot(10)); - - var system = GetOrInitTinkeringSystem(); - var context = system.GetContext(player); - context.PendingGemType = GemType.None; // no gem targeted - context.PendingGemCount = 0; - - ring.OnCraft(1, false, player, system, typeof(IronIngot), null, MakeRingRecipe(), 0); - - Assert.Equal(GemType.None, ring.GemType); - Assert.Equal(0, ring.GemCount); - } - finally - { - ring.Delete(); - player.Delete(); - } - } - [Fact] public void OnCraft_WhenGemsUnavailableAtCraftTime_CraftsPlainPiece() { diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs index 01a58a697..ca3c7399f 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs @@ -17,30 +17,42 @@ public class CorpseChildPublicTests private static VikingSword EquipSword(Mobile owner) { var sword = new VikingSword { Layer = Layer.OneHanded }; - Assert.True(owner.EquipItem(sword)); + var equipped = owner.EquipItem(sword); + if (!equipped) + { + sword.Delete(); + } + + Assert.True(equipped); return sword; } + private static Mobile NewOwner(int body) + { + var owner = new Mobile(World.NewMobile); + owner.DefaultMobileInit(); + owner.Body = body; + return owner; + } + [Fact] public void IsChildPublic_HumanCorpseWithWornGear_ReturnsTrue() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + Assert.Equal(0x2006, corpse.ItemID); Assert.True(((Body)corpse.Amount).IsHuman); Assert.True(corpse.IsChildPublic(wornItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -48,26 +60,24 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_HumanCorpseWithLootItem_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); - - var lootItem = new Gold(100); - corpse.DropItem(lootItem); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + + var lootItem = new Gold(100); + corpse.DropItem(lootItem); + Assert.Equal(0x2006, corpse.ItemID); Assert.True(((Body)corpse.Amount).IsHuman); Assert.False(corpse.IsChildPublic(lootItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -75,16 +85,14 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_BoneCorpse_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + corpse.ItemID = 0xECA; Assert.True(((Body)corpse.Amount).IsHuman); @@ -93,7 +101,7 @@ public class CorpseChildPublicTests } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -101,23 +109,21 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_NonHumanCorpse_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0xC9; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0xC9); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + Assert.Equal(0x2006, corpse.ItemID); Assert.False(((Body)corpse.Amount).IsHuman); Assert.False(corpse.IsChildPublic(wornItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -129,36 +135,48 @@ public class CorpseChildPublicTests return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; } + private static Mobile NewBystander(NetState ns, Point3D location) + { + ns.Account = new MockAccount(); + var bystander = new Mobile(World.NewMobile); + bystander.DefaultMobileInit(); + ns.Mobile = bystander; + bystander.NetState = ns; + bystander.MoveToWorld(location, Map.Felucca); + return bystander; + } + + private static void DeleteBystander(NetState ns, Mobile bystander) + { + ns.Mobile = null; + ns.Dispose(); + bystander?.Delete(); + } + [Fact] public void HumanCorpse_WornGearRemovalBroadcasts_LootRemovalDoesNot() { var corpseLoc = new Point3D(1500, 1500, 0); var bystanderNs = PacketTestUtilities.CreateTestNetState(); - bystanderNs.Account = new MockAccount(); - var bystander = new Mobile(World.NewMobile); - bystander.DefaultMobileInit(); - bystanderNs.Mobile = bystander; - bystander.NetState = bystanderNs; - bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var sword = new VikingSword(); - var corpse = new Corpse(owner, [sword]); - corpse.AddItem(sword); - - var lootItem = new Gold(100); - corpse.DropItem(lootItem); - - corpse.MoveToWorld(corpseLoc, Map.Felucca); - + Mobile bystander = null; + var owner = NewOwner(0x190); + Corpse corpse = null; Container pouch = null; try { + bystander = NewBystander(bystanderNs, new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z)); + + var sword = new VikingSword(); + corpse = new Corpse(owner, [sword]); + corpse.AddItem(sword); + + var lootItem = new Gold(100); + corpse.DropItem(lootItem); + + corpse.MoveToWorld(corpseLoc, Map.Felucca); + // A container not in the world is a valid destination; only the removal from the corpse matters here. pouch = new Container(0xE75); pouch.AddItem(sword); @@ -171,11 +189,9 @@ public class CorpseChildPublicTests finally { pouch?.Delete(); - corpse.Delete(); + corpse?.Delete(); owner.Delete(); - bystanderNs.Mobile = null; - bystanderNs.Dispose(); - bystander.Delete(); + DeleteBystander(bystanderNs, bystander); } } @@ -185,25 +201,20 @@ public class CorpseChildPublicTests var corpseLoc = new Point3D(1500, 1500, 0); var bystanderNs = PacketTestUtilities.CreateTestNetState(); - bystanderNs.Account = new MockAccount(); - var bystander = new Mobile(World.NewMobile); - bystander.DefaultMobileInit(); - bystanderNs.Mobile = bystander; - bystander.NetState = bystanderNs; - bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - - var owner = new Mobile(World.NewMobile); - owner.DefaultMobileInit(); - owner.Body = 0x190; - owner.MoveToWorld(new Point3D(corpseLoc.X - 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - + Mobile bystander = null; + var owner = NewOwner(0x190); var sword = new VikingSword { Layer = Layer.OneHanded }; - var corpse = new Corpse(owner, [sword]); - corpse.AddItem(sword); - corpse.MoveToWorld(corpseLoc, Map.Felucca); + Corpse corpse = null; try { + bystander = NewBystander(bystanderNs, new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z)); + owner.MoveToWorld(new Point3D(corpseLoc.X - 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); + + corpse = new Corpse(owner, [sword]); + corpse.AddItem(sword); + corpse.MoveToWorld(corpseLoc, Map.Felucca); + // Lifting takes the piece off the worn list before the remove is sent; the remove must // still reach everyone who was shown it worn. owner.Lift(sword, sword.Amount, out var rejected, out _); @@ -216,11 +227,9 @@ public class CorpseChildPublicTests { owner.Holding = null; sword.Delete(); - corpse.Delete(); + corpse?.Delete(); owner.Delete(); - bystanderNs.Mobile = null; - bystanderNs.Dispose(); - bystander.Delete(); + DeleteBystander(bystanderNs, bystander); } } } diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseHairMigrationTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseHairMigrationTests.cs index 3538ca931..81452623d 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseHairMigrationTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseHairMigrationTests.cs @@ -45,15 +45,4 @@ public class CorpseHairMigrationTests // The entire block must be consumed - the loader validates exact byte length. Assert.Equal(buffer.Length, reader.Position); } - - [Fact] - public void LegacyHairTail_Absent_ConsumesOnlyPresenceBool() - { - var buffer = WriteLegacyHairTail(false, 0, 0); - var reader = new BufferReader(buffer); - - Assert.False(reader.ReadBool()); - // Absent case: nothing else to read, and the presence bool was the only byte. - Assert.Equal(buffer.Length, reader.Position); - } } diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs index 26556155e..9cfec050a 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Server; using Server.Items; using Xunit; @@ -11,19 +10,22 @@ public class CorpseLootTests [Fact] public void LiftedWornGear_PutBack_StaysLoot() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - // Weapons take their layer from tiledata, which CI doesn't load. - var sword = new VikingSword { Layer = Layer.OneHanded }; - Assert.True(owner.EquipItem(sword)); - - var corpse = new Corpse(owner, new List { sword }); - corpse.DropItem(sword); + var owner = new Mobile(World.NewMobile); + VikingSword sword = null; + Corpse corpse = null; try { + owner.DefaultMobileInit(); + owner.Body = 0x190; + + // Weapons take their layer from tiledata, which CI doesn't load. + sword = new VikingSword { Layer = Layer.OneHanded }; + Assert.True(owner.EquipItem(sword)); + + corpse = new Corpse(owner, [sword]); + corpse.DropItem(sword); + Assert.Contains(sword, corpse.EquipItems); corpse.OnItemLifted(owner, sword); @@ -35,7 +37,8 @@ public class CorpseLootTests } finally { - corpse.Delete(); + corpse?.Delete(); + sword?.Delete(); owner.Delete(); } } diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs index b5cec8aa6..1669147f2 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs @@ -10,19 +10,34 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class CorpsePacketTests { + // Weapons take their layer from tiledata, which CI doesn't load; without an explicit layer the + // equip is refused and the packets would only cover an empty item list. + private static VikingSword EquipSword(Mobile m) + { + var sword = new VikingSword { Layer = Layer.OneHanded }; + var equipped = m.EquipItem(sword); + if (!equipped) + { + sword.Delete(); + } + + Assert.True(equipped); + return sword; + } + [Fact] public void TestCorpseEquipPacket() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - - var weapon = new VikingSword(); - m.EquipItem(weapon); - - var c = new Corpse(m, m.Items); + var m = new Mobile(World.NewMobile); + VikingSword weapon = null; + Corpse c = null; try { + m.DefaultMobileInit(); + weapon = EquipSword(m); + c = new Corpse(m, m.Items); + var expected = new CorpseEquip(m, c).Compile(); using var ns = PacketTestUtilities.CreateTestNetState(); @@ -33,8 +48,9 @@ public class CorpsePacketTests } finally { - c.Delete(); - weapon.Delete(); + c?.Delete(); + weapon?.Delete(); + m.Delete(); } } @@ -43,16 +59,16 @@ public class CorpsePacketTests [InlineData(ProtocolChanges.ContainerGridLines)] public void TestCorpseContainerPacket(ProtocolChanges changes) { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - - var weapon = new VikingSword(); - m.EquipItem(weapon); - - var c = new Corpse(m, m.Items); + var m = new Mobile(World.NewMobile); + VikingSword weapon = null; + Corpse c = null; try { + m.DefaultMobileInit(); + weapon = EquipSword(m); + c = new Corpse(m, m.Items); + using var ns = PacketTestUtilities.CreateTestNetState(); ns.ProtocolChanges = changes; @@ -65,8 +81,9 @@ public class CorpsePacketTests } finally { - c.Delete(); - weapon.Delete(); + c?.Delete(); + weapon?.Delete(); + m.Delete(); } } } diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureChestDecayTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureChestDecayTests.cs index 31a839ca0..942065a77 100644 --- a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureChestDecayTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureChestDecayTests.cs @@ -19,27 +19,6 @@ public class TreasureChestDecayTests // DecayScheduler reads ScheduledDecayTime repeatedly and needs the reads to agree, so the // random interval must be rolled once per chest rather than per read. - [Theory] - [MemberData(nameof(ChestTypes))] - public void DecayTimeIsStableAcrossReads(Type chestType) - { - var chest = chestType.CreateInstance(); - - try - { - var expected = chest.DecayTime; - - for (var i = 0; i < 100; i++) - { - Assert.Equal(expected, chest.DecayTime); - } - } - finally - { - chest.Delete(); - } - } - [Theory] [MemberData(nameof(ChestTypes))] public void ScheduledDecayTimeIsStableAcrossReads(Type chestType) diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs index 633025042..7a3145d70 100644 --- a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs @@ -51,13 +51,11 @@ public class TreasureMapChestGuardianTests // Drives the real dig, one DigTimer tick per call, until the chest surfaces and its guardians // spawn. Levels 0 and 2+ spawn guardians; level 1 spawns none. - [SkippableTheory] + [Theory] [InlineData(0)] [InlineData(2)] public void Dig_AddsSpawnedGuardiansToTheChest(int level) { - TileDataRequirement.SkipIfMissing(); - var map = Map.Felucca; var site = new Point3D(SiteX, SiteY, map.GetAverageZ(SiteX, SiteY)); var player = CreatePlayerMobile(map, new Point3D(site.X + 1, site.Y, site.Z)); diff --git a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs index cc0ccad42..b9f13a2f1 100644 --- a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs @@ -87,14 +87,14 @@ public class VendorBuybackPackTests var pack = new VendorBuybackPack(); var items = new Item[10]; - for (var i = 0; i < items.Length; i++) - { - items[i] = new Item(0x1234); - pack.AddBuyback(items[i], 250); - } - try { + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.AddBuyback(items[i], 250); + } + pack.Purge(); Assert.Empty(pack.Items); diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs index da896ba8b..745922d08 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs @@ -10,13 +10,19 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class ThrowingTests { - // Exposes protected ModifyHitChance so the math can be verified directly. + // Exposes protected ModifyHitChance so the math can be verified directly. The requirement and + // throw band are pinned here so retuning a shipped weapon cannot move these expectations. private class TestThrown : BaseThrown { - public TestThrown() : base(0x8FF) { } + // Weapons take their layer from tiledata, which CI doesn't load; without it AddItem is refused. + public TestThrown() : base(0x8FF) => Layer = Layer.OneHanded; + + public override int AosStrengthReq => 60; public override int MinThrowRange => 4; + public override int MaxThrowRange => 7; + public double TestModifyHitChance(Mobile attacker, Mobile defender, double chance) => ModifyHitChance(attacker, defender, chance); @@ -220,18 +226,19 @@ public class ThrowingTests } } - // DefMaxRange STR scaling (uses SoulGlaive: StrReq 60, Min 8, Max 11) + // DefMaxRange STR scaling (TestThrown: StrReq 60, Min 4, Max 7, so one tile per 26 Str) [Fact] public void DefMaxRange_AtStrReq_EqualsMinThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5800, 500, 0)); try { attacker.RawStr = 60; // == AosStrengthReq attacker.AddItem(weapon); - Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 8 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 4 } finally { @@ -243,13 +250,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_At140Str_EqualsMaxThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5810, 500, 0)); try { attacker.RawStr = 140; attacker.AddItem(weapon); - Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 11 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 7 } finally { @@ -261,13 +269,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_AboveMaxStr_IsCappedAtMaxThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5820, 500, 0)); try { - attacker.RawStr = 200; // uncapped formula would give 13 + attacker.RawStr = 200; // uncapped formula would give 9 attacker.AddItem(weapon); - Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 11, not 13 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 7, not 9 } finally { @@ -279,13 +288,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_BelowStrReq_FlooredAtMinThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5830, 500, 0)); try { attacker.RawStr = 10; // below StrReq 60 attacker.AddItem(weapon); - Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 8, not 6 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 4, not 3 } finally { @@ -305,6 +315,7 @@ public class ThrowingTests { attacker.RawStr = 140; // MaxRange == MaxThrowRange == 7 attacker.AddItem(weapon); + Assert.Same(attacker, weapon.Parent); var defender = CreateMobile(map, new Point3D(5907, 500, 0)); // distance 7 == MaxRange try { @@ -333,6 +344,7 @@ public class ThrowingTests { attacker.RawStr = 140; // MaxRange 7 attacker.AddItem(weapon); + Assert.Same(attacker, weapon.Parent); var defender = CreateMobile(map, new Point3D(5925, 500, 0)); // distance 5 < 7 try { diff --git a/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs index a46c56003..68be84e7b 100644 --- a/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs @@ -17,22 +17,23 @@ public class CorpseNotorietyTests public void PlayerCorpseNotorietyIgnoresTransformedBody(int bodyMod, int expected, int kills = 0) { var victim = new PlayerMobile(World.NewMobile); - victim.DefaultMobileInit(); - victim.Kills = kills; - victim.BodyMod = bodyMod; - var looter = new PlayerMobile(World.NewMobile); - looter.DefaultMobileInit(); - - var corpse = new Corpse(victim, []); + Corpse corpse = null; try { + victim.DefaultMobileInit(); + victim.Kills = kills; + victim.BodyMod = bodyMod; + looter.DefaultMobileInit(); + + corpse = new Corpse(victim, []); + Assert.Equal(expected, NotorietyHandlers.CorpseNotoriety(looter, corpse)); } finally { - corpse.Delete(); + corpse?.Delete(); victim.Delete(); looter.Delete(); } diff --git a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs index de6825c9f..06d940512 100644 --- a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Reflection; using Server.Configurations; using Server.Misc; using Xunit; @@ -7,13 +8,22 @@ using Xunit; namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] -public class EmailTests +public class EmailTests : IDisposable { + private static readonly PropertyInfo _emailEnabled = + typeof(EmailConfiguration).GetProperty(nameof(EmailConfiguration.EmailEnabled)); + + private readonly bool _savedEmailEnabled = EmailConfiguration.EmailEnabled; + + // Pinned off rather than read from the host's email.json, so a developer config with email + // enabled cannot turn these into real sends. + public EmailTests() => _emailEnabled.SetValue(null, false); + + public void Dispose() => _emailEnabled.SetValue(null, _savedEmailEnabled); + [Fact] public void SendCrashEmailDoesNothingWhileEmailIsDisabled() { - Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); - // Missing input must be ignored while email is disabled. var report = Path.Combine(Path.GetTempPath(), $"crash-email-test-{Guid.NewGuid():N}", "Crash.log"); @@ -23,8 +33,7 @@ public class EmailTests [Fact] public void SendQueueEmailDoesNothingWhileEmailIsDisabled() { - Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); - + // A null entry would throw on the enabled path, so returning cleanly shows the guard held. Email.SendQueueEmail(null, "Other"); } diff --git a/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs index f596282f6..86787e0f2 100644 --- a/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs @@ -22,40 +22,55 @@ public class GuildSerializePurityTests public void SerializeDoesNotRecalculateFealtyOrMutateState() { var leader = CreatePlayer(); - var guild = new Guild(leader, "Purity Test Guild", "PTG"); - var staleFealty = Core.Now - TimeSpan.FromDays(3); - guild.LastFealty = staleFealty; + Guild guild = null; - var writer = new BufferWriter(true); - guild.Serialize(writer); + try + { + guild = new Guild(leader, "Purity Test Guild", "PTG"); + var staleFealty = Core.Now - TimeSpan.FromDays(3); + guild.LastFealty = staleFealty; - Assert.Equal(staleFealty, guild.LastFealty); - Assert.Same(leader, guild.Leader); + var writer = new BufferWriter(true); + guild.Serialize(writer); - var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); - writer.Seek(0, SeekOrigin.Begin); - guild.Serialize(writer); - var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); + Assert.Equal(staleFealty, guild.LastFealty); + Assert.Same(leader, guild.Leader); - Assert.Equal(first, second); + var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); + writer.Seek(0, SeekOrigin.Begin); + guild.Serialize(writer); + var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); - writer.Close(); - guild.Disband(); - leader.Delete(); + Assert.Equal(first, second); + + writer.Close(); + } + finally + { + guild?.Disband(); + leader.Delete(); + } } [Fact] public void RunMaintenanceRecalculatesStaleFealty() { var leader = CreatePlayer(); - var guild = new Guild(leader, "Maintenance Test Guild", "MTG"); - guild.LastFealty = Core.Now - TimeSpan.FromDays(3); + Guild guild = null; - guild.RunMaintenance(); + try + { + guild = new Guild(leader, "Maintenance Test Guild", "MTG"); + guild.LastFealty = Core.Now - TimeSpan.FromDays(3); - Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1)); + guild.RunMaintenance(); - guild.Disband(); - leader.Delete(); + Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1)); + } + finally + { + guild?.Disband(); + leader.Delete(); + } } } diff --git a/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs b/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs index a9e4f5c27..233086f04 100644 --- a/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs @@ -16,15 +16,6 @@ public class NameVerificationTests Assert.True(NameVerification.ValidatePlayerName(name)); } - [Theory] - [InlineData("Rex")] - [InlineData("MrWhiskers")] - [InlineData("Fido")] - public void ValidatePetName_ValidNames_ReturnsTrue(string name) - { - Assert.True(NameVerification.ValidatePetName(name)); - } - [Theory] [InlineData("Mrs-Smith")] [InlineData("Mr. Whiskers")] @@ -47,7 +38,7 @@ public class NameVerificationTests [InlineData("Blacksmith 123")] [InlineData("Baker-Smith")] [InlineData("*Innkeeper*")] - public void ValidateVendorName_ValidNames_ReturnsFalse(string name) + public void ValidateVendorName_DisallowedCharacters_ReturnsFalse(string name) { Assert.False(NameVerification.ValidateVendorName(name)); } @@ -128,10 +119,4 @@ public class NameVerificationTests var exceptions = SearchValues.Create(' ', '-', '.'); Assert.False(NameVerification.Validate("-John", 2, 16, true, false, true, 1, exceptions)); } - - [Fact] - public void Validate_DisallowedCharacters_ReturnsFalse() - { - Assert.False(NameVerification.Validate("John123", 2, 16, true, false)); - } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs index 3ee516dcf..e06571b63 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs @@ -60,9 +60,14 @@ public class AcquisitionTests : IDisposable bc.NextReacquireTime = Core.TickCount; Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); - Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000); + Assert.Equal(ReacquireDelayMs(bc), bc.NextReacquireTime - Core.TickCount); } + private static long ReacquireDelayMs(BaseCreature bc) => (long)bc.ReacquireDelay.TotalMilliseconds; + + // A legal, fully armed deadline: as far out as a scan ever sets it. Movement can only pull it in. + private static void ArmFullDelay(BaseCreature bc) => bc.NextReacquireTime = Core.TickCount + ReacquireDelayMs(bc); + [Fact] public void WedgedGate_SelfHeals() { @@ -78,7 +83,7 @@ public class AcquisitionTests : IDisposable _created.Add(target); // Illegal deadline (beyond ReacquireDelay): must read as open, not block forever. - bc.NextReacquireTime = Core.TickCount + 60000; + bc.NextReacquireTime = Core.TickCount + ReacquireDelayMs(bc) * 6; Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); Assert.Equal(target, bc.FocusMob); @@ -96,7 +101,8 @@ public class AcquisitionTests : IDisposable map.GetAverageZ(1500, 1600, out _, out var z, out _); var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - bc.NextReacquireTime = Core.TickCount + 8000; + ArmFullDelay(bc); + var armed = bc.NextReacquireTime; Mobile mover; if (wildMover) @@ -117,12 +123,12 @@ public class AcquisitionTests : IDisposable if (notices) { - // Clamped to the approach delay (2s), never opened outright. + // Clamped to the approach delay, never opened outright. Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds); } else { - Assert.True(remaining > 5000); + Assert.Equal(armed, bc.NextReacquireTime); } } @@ -150,7 +156,7 @@ public class AcquisitionTests : IDisposable bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); bc.AIObject.AITimer?.Stop(); _created.Add(bc); - bc.NextReacquireTime = Core.TickCount + 8000; + ArmFullDelay(bc); var mover = new TargetStub { Player = true }; mover.DefaultMobileInit(); @@ -173,7 +179,7 @@ public class AcquisitionTests : IDisposable map.GetAverageZ(1500, 1600, out _, out var z, out _); var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - bc.NextReacquireTime = Core.TickCount + 8000; + ArmFullDelay(bc); var mover = new TargetStub { Player = true }; mover.DefaultMobileInit(); @@ -206,6 +212,6 @@ public class AcquisitionTests : IDisposable Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); Assert.Equal(target, bc.FocusMob); - Assert.True(bc.NextReacquireTime - Core.TickCount > 5000); + Assert.Equal(ReacquireDelayMs(bc), bc.NextReacquireTime - Core.TickCount); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs index fe8e397a1..ff7211755 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs @@ -1,89 +1,82 @@ +using Server.Tests.Terrain; +using System; using System.Collections.Generic; using Server; using Server.Engines.Pathing.Cache; using Server.Mobiles; using Server.Tests; -using Server.Tests.Mobiles.AI; using Xunit; namespace UOContent.Tests.Mobiles.AI; // Driven manually against live map statics; the pathfinder's buffers are not reentrant. [Collection("Sequential Pathfinding Tests")] -public class ApproachOutcomeTests +public class ApproachOutcomeTests : IDisposable { - private sealed class Stub : BaseCreature + private readonly List _created = []; + + public void Dispose() { - public Stub(Serial serial) : base(serial) => Body = 0xC9; + for (var i = _created.Count - 1; i >= 0; i--) + { + _created[i].Delete(); + } + + _created.Clear(); } - private sealed class Target : Mobile - { - public Target() => Body = 0xC9; - } - - private static (Stub bc, BaseAI ai) NewFollower(Map map, int x, int y) + private (BareCreatureStub bc, BaseAI ai) NewFollower(Map map, int x, int y) { map.GetAverageZ(x, y, out _, out var z, out _); - var bc = new Stub(World.NewMobile); + var bc = new BareCreatureStub(World.NewMobile); bc.DefaultMobileInit(); bc.MoveToWorld(new Point3D(x, y, (sbyte)z), map); + _created.Add(bc); BaseAI ai = new AnimalAI(bc); ai.AITimer?.Stop(); return (bc, ai); } - private static Target NewTarget(Map map, int x, int y) + private WalkerStub NewTarget(Map map, int x, int y) { map.GetAverageZ(x, y, out _, out var z, out _); - var t = new Target(); + var t = new WalkerStub(); t.MoveToWorld(new Point3D(x, y, (sbyte)z), map); + _created.Add(t); return t; } - [SkippableFact] + [Fact] public void OpenGround_ReportsDirectProgress_ThenArrived() { - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; - var (bc, ai) = NewFollower(map, 1500, 1600); + var (_, ai) = NewFollower(map, 1500, 1600); var target = NewTarget(map, 1497, 1600); StepCache.Instance.Clear(); - try - { - ai.NextMove = 0; - ai.MoveTo(target, 1); - Assert.Equal(ApproachOutcome.DirectProgress, ai.LastApproach); + ai.NextMove = 0; + ai.MoveTo(target, 1); + Assert.Equal(ApproachOutcome.DirectProgress, ai.LastApproach); - ai.MoveTo(target, 1); // budget consumed: no step - Assert.Equal(ApproachOutcome.Waiting, ai.LastApproach); + ai.MoveTo(target, 1); // budget consumed: no step + Assert.Equal(ApproachOutcome.Waiting, ai.LastApproach); - ai.NextMove = 0; - ai.MoveTo(target, 1); // lands adjacent: still a progress step - Assert.Equal(ApproachOutcome.DirectProgress, ai.LastApproach); + ai.NextMove = 0; + ai.MoveTo(target, 1); // lands adjacent: still a progress step + Assert.Equal(ApproachOutcome.DirectProgress, ai.LastApproach); - ai.NextMove = 0; - ai.MoveTo(target, 1); - Assert.Equal(ApproachOutcome.Arrived, ai.LastApproach); - } - finally - { - bc.Delete(); - target.Delete(); - } + ai.NextMove = 0; + ai.MoveTo(target, 1); + Assert.Equal(ApproachOutcome.Arrived, ai.LastApproach); } - [SkippableFact] + [Fact] public void WalledTarget_ReportsGaveUp() { - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; - var (bc, ai) = NewFollower(map, 1500, 1601); + var (_, ai) = NewFollower(map, 1500, 1601); var target = NewTarget(map, 1500, 1596); - var ring = new List(); - var id = ApproachTargetTests.FirstImpassableItemId(); - Assert.NotEqual(0, id); + const ushort id = TestTiles.Counter; // Impassable ring around the target: unreachable. for (var x = 1499; x <= 1501; x++) @@ -96,35 +89,22 @@ public class ApproachOutcomeTests } map.GetAverageZ(x, y, out _, out var rz, out _); - ring.Add(new Item(World.NewItem) { ItemID = id, Map = map, Location = new Point3D(x, y, (sbyte)rz) }); + _created.Add(new Item(World.NewItem) { ItemID = id, Map = map, Location = new Point3D(x, y, (sbyte)rz) }); } } StepCache.Instance.Clear(); - try + var sawRouting = false; + + for (var i = 0; i < 120; i++) { - var sawRouting = false; - - for (var i = 0; i < 120; i++) - { - ai.NextMove = 0; - ai.MoveTo(target, 1); - sawRouting |= ai.LastApproach is ApproachOutcome.Routing or ApproachOutcome.Blocked; - } - - Assert.True(sawRouting, "a walled target must route or block before giving up"); - Assert.Equal(ApproachOutcome.GaveUp, ai.LastApproach); + ai.NextMove = 0; + ai.MoveTo(target, 1); + sawRouting |= ai.LastApproach is ApproachOutcome.Routing or ApproachOutcome.Blocked; } - finally - { - bc.Delete(); - target.Delete(); - for (var i = 0; i < ring.Count; i++) - { - ring[i].Delete(); - } - } + Assert.True(sawRouting, "a walled target must route or block before giving up"); + Assert.Equal(ApproachOutcome.GaveUp, ai.LastApproach); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index f3869ffef..aa8e7d27b 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -1,3 +1,5 @@ +using Server.Tests.Terrain; +using System; using System.Collections.Generic; using Server.Engines.Pathing.Cache; using Server.Mobiles; @@ -9,29 +11,45 @@ namespace Server.Tests.Mobiles.AI; // pathfinder uses non-reentrant static buffers, so we share the pathfinding sequential // collection to avoid parallel interference. [Collection("Sequential Pathfinding Tests")] -public class ApproachTargetTests +public class ApproachTargetTests : IDisposable { - private sealed class FollowerStub : BaseCreature + private readonly List _created = []; + + public void Dispose() { - // Serial ctor + DefaultMobileInit bypasses NPCSpeeds JSON (absent in tests). - public FollowerStub(Serial serial) : base(serial) => Body = 0xC9; + for (var i = _created.Count - 1; i >= 0; i--) + { + _created[i].Delete(); + } + + _created.Clear(); } - private sealed class TargetStub : Mobile + private (BareCreatureStub bc, BaseAI ai) NewFollower(Map map, Point3D loc) { - public TargetStub() => Body = 0xC9; - } - - private static (FollowerStub bc, BaseAI ai) NewFollower(Map map, Point3D loc) - { - var bc = new FollowerStub(World.NewMobile); + var bc = new BareCreatureStub(World.NewMobile); bc.DefaultMobileInit(); bc.MoveToWorld(loc, map); + _created.Add(bc); BaseAI ai = new AnimalAI(bc); ai.AITimer?.Stop(); // we drive movement manually; no background timer return (bc, ai); } + private WalkerStub NewTarget(Map map, Point3D loc) + { + var target = new WalkerStub(); + target.MoveToWorld(loc, map); + _created.Add(target); + return target; + } + + private void PlaceBlocker(Map map, ushort itemId, int x, int y) + { + map.GetAverageZ(x, y, out _, out var z, out _); + _created.Add(new Item(World.NewItem) { ItemID = itemId, Map = map, Location = new Point3D(x, y, (sbyte)z) }); + } + // Drives the pet follow primitive once per tick; returns true once it reaches within // arriveDist tiles. Uses InRange (Chebyshev) to match the game's own range semantics — // a diagonal neighbor at Euclidean ~2.24 is "within 2" to the engine. @@ -49,18 +67,6 @@ public class ApproachTargetTests return false; } - internal static ushort FirstImpassableItemId() - { - for (ushort id = 1; id < TileData.MaxItemValue; id++) - { - if (TileData.ItemTable[id].ImpassableSurface) - { - return id; - } - } - return 0; - } - [Fact] public void OpenTerrain_ReachesTarget_WithoutPathfinding() { @@ -69,8 +75,7 @@ public class ApproachTargetTests map.GetAverageZ(1500, 1600, out _, out var z, out _); var (bc, ai) = NewFollower(map, new Point3D(1500, 1600, (sbyte)z)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map); // 5 tiles west, open + var target = NewTarget(map, new Point3D(1495, 1600, (sbyte)z)); // 5 tiles west, open StepCache.Instance.Clear(); var buildsBefore = StepCache.Instance.GetStats().BuildsTotal; @@ -78,44 +83,45 @@ public class ApproachTargetTests var arrived = DriveFollow(ai, bc, target, 2, 40); var buildsAfter = StepCache.Instance.GetStats().BuildsTotal; - bc.Delete(); - target.Delete(); Assert.True(arrived, "creature should reach an open-terrain target by stepping"); Assert.Equal(buildsBefore, buildsAfter); // greedy fast path: no chunk builds } + // An upstairs room with an L-shaped counter: the pet is south of it, the master north. [Fact] - public void BritainInnDesk_PetReachesMaster() + public void InnCounter_PetReachesMaster() { + var inn = Prefabs.InnUpstairs; + inn.AssertPresent(); var map = Map.Maps[1]; Assert.NotNull(map); - // Exact in-game repro: pet south of the L-desk, master north of it. - var (bc, ai) = NewFollower(map, new Point3D(1493, 1614, 20)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1494, 1605, 21), map); + var (bc, ai) = NewFollower(map, inn.Anchor); + var target = NewTarget(map, inn.Target); + + // The straight line north runs into the counter, so arriving means the pet went round it. + var belowCounter = new Point3D(inn.Anchor.X, inn.Target.Y + 6, inn.Anchor.Z); + Assert.False(Server.Movement.Movement.CheckMovement(bc, map, belowCounter, Direction.North, out _)); StepCache.Instance.Clear(); - // 200 ticks is generous for the ~17-step detour at one step per tick. + // 200 ticks is generous for the ~20-step detour at one step per tick. var arrived = DriveFollow(ai, bc, target, 2, 200); - bc.Delete(); - target.Delete(); - - Assert.True(arrived, "pet must navigate around the L-desk to reach the master"); + Assert.True(arrived, "pet must navigate around the L-shaped counter to reach the master"); } [Fact] - public void MoveTo_ReachesTargetAroundDesk() + public void MoveTo_ReachesTargetAroundCounter() { + var inn = Prefabs.InnUpstairs; + inn.AssertPresent(); var map = Map.Maps[1]; Assert.NotNull(map); - var (bc, ai) = NewFollower(map, new Point3D(1493, 1614, 20)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1494, 1605, 21), map); + var (bc, ai) = NewFollower(map, inn.Anchor); + var target = NewTarget(map, inn.Target); StepCache.Instance.Clear(); @@ -131,10 +137,7 @@ public class ApproachTargetTests } } - bc.Delete(); - target.Delete(); - - Assert.True(arrived, "MoveTo chaser must reach the target around the desk"); + Assert.True(arrived, "MoveTo chaser must reach the target around the counter"); } [Fact] @@ -145,8 +148,7 @@ public class ApproachTargetTests map.GetAverageZ(1500, 1600, out _, out var z, out _); var (bc, ai) = NewFollower(map, new Point3D(1500, 1600, (sbyte)z)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1498, 1600, (sbyte)z), map); + var target = NewTarget(map, new Point3D(1498, 1600, (sbyte)z)); StepCache.Instance.Clear(); @@ -170,28 +172,21 @@ public class ApproachTargetTests } } - bc.Delete(); - target.Delete(); - Assert.True(caught, "chaser must catch a target that walks away then stops"); } - [SkippableFact] + [Fact] public void UnreachableTarget_GivesUp_AndIdles() { - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; Assert.NotNull(map); map.GetAverageZ(1500, 1601, out _, out var z, out _); var (bc, ai) = NewFollower(map, new Point3D(1500, 1601, (sbyte)z)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1500, 1596, (sbyte)z), map); + var target = NewTarget(map, new Point3D(1500, 1596, (sbyte)z)); // Full impassable ring around the target cell -> no path to within range 1. - var id = FirstImpassableItemId(); - Assert.NotEqual(0, id); - var ring = new List(); + const ushort id = TestTiles.Counter; for (var x = 1499; x <= 1501; x++) { for (var y = 1595; y <= 1597; y++) @@ -200,11 +195,8 @@ public class ApproachTargetTests { continue; // leave the target's own cell } - map.GetAverageZ(x, y, out _, out var rz, out _); - ring.Add(new Item(World.NewItem) - { - ItemID = id, Map = map, Location = new Point3D(x, y, (sbyte)rz) - }); + + PlaceBlocker(map, id, x, y); } } @@ -231,55 +223,31 @@ public class ApproachTargetTests } } - var reached = bc.InRange(target, 1); - - bc.Delete(); - target.Delete(); - foreach (var it in ring) - { - it.Delete(); - } - - Assert.False(reached, "walled-off target must not be reached"); + Assert.False(bc.InRange(target, 1), "walled-off target must not be reached"); Assert.True(stayedIdle, "after giving up, the creature must idle, not shuffle"); } - [SkippableFact] + [Fact] public void WallBetween_RoutesAround_ReachesTarget() { - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; Assert.NotNull(map); map.GetAverageZ(1500, 1600, out _, out var z, out _); var (bc, ai) = NewFollower(map, new Point3D(1500, 1600, (sbyte)z)); - var target = new TargetStub(); - target.MoveToWorld(new Point3D(1500, 1595, (sbyte)z), map); // 5 tiles north + var target = NewTarget(map, new Point3D(1500, 1595, (sbyte)z)); // 5 tiles north // Impassable wall on Y=1598 from X=1497..1503: blocks the straight line; ends open. - var id = FirstImpassableItemId(); - Assert.NotEqual(0, id); - var wall = new List(); + const ushort id = TestTiles.Counter; for (var x = 1497; x <= 1503; x++) { - map.GetAverageZ(x, 1598, out _, out var wz, out _); - wall.Add(new Item(World.NewItem) - { - ItemID = id, Map = map, Location = new Point3D(x, 1598, (sbyte)wz) - }); + PlaceBlocker(map, id, x, 1598); } StepCache.Instance.Clear(); var arrived = DriveFollow(ai, bc, target, 2, 200); - bc.Delete(); - target.Delete(); - foreach (var it in wall) - { - it.Delete(); - } - Assert.True(arrived, "creature must route around the wall to reach the target"); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs index 383e2e103..7bf6ffb5c 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs @@ -1,6 +1,7 @@ using System; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles.AI; @@ -26,7 +27,7 @@ public class DetectHiddenTimerTests public override void DetectHidden() => Detections++; } - [SkippableTheory] + [Theory] [InlineData(0, 108000, 132000)] [InlineData(1, 108000, 132000)] [InlineData(100, 108000, 132000)] @@ -37,9 +38,7 @@ public class DetectHiddenTimerTests public void DetectHidden_SchedulesBoundedCooldown_AndWaitsBeforeRetry( int intelligence, long minDelay, long maxDelay) { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); - Core._tickCount = 0; - Timer.Init(0); + using var clock = new TimerWheelScope(); var creature = new DetectingCreature(); DetectingAI ai = null; @@ -63,12 +62,8 @@ public class DetectHiddenTimerTests Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay); Assert.True(ai.AITimer.Running); - var deadline = ai._nextDetectHidden; - while (Core.TickCount + 8 < deadline) - { - Core._tickCount += 8; - Timer.Slice(Core.TickCount); - } + // Stop one wheel step short of the cooldown: no retry may have fired yet. + TimerWheelScope.RunFor(ai._nextDetectHidden - TimerWheelScope.Step - Core.TickCount); Assert.Equal(1, ai.Detections); AdvanceUntil(() => ai.Detections > 1, 1024); @@ -82,14 +77,9 @@ public class DetectHiddenTimerTests } } - private static void AdvanceUntil(Func condition, int milliseconds) - { - for (var elapsed = 0; !condition() && elapsed < milliseconds; elapsed += 8) - { - Core._tickCount += 8; - Timer.Slice(Core.TickCount); - } - - Assert.True(condition(), "The AI timer did not reach the expected detection."); - } + private static void AdvanceUntil(Func condition, int milliseconds) => + Assert.True( + TimerWheelScope.RunUntil(condition, milliseconds), + "The AI timer did not reach the expected detection." + ); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs index 7d39e2963..5b366ddab 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs @@ -1,11 +1,12 @@ +using Server.Tests.Terrain; using System; using System.Collections.Generic; using Server; using Server.Items; using Server.Mobiles; using Server.Tests; -using Server.Tests.Mobiles.AI; using Xunit; +using static Server.Tests.TimerWheelScope; namespace UOContent.Tests.Mobiles.AI; @@ -50,12 +51,11 @@ public class FamiliarAITests : IDisposable private static readonly Map _map = Map.Maps[1]; private readonly List _created = new(); + private readonly TimerWheelScope _clock; public FamiliarAITests() { - TileDataRequirement.SkipIfMissing(); - Core._tickCount = 0; - Timer.Init(0); + _clock = new TimerWheelScope(); } public void Dispose() @@ -64,6 +64,8 @@ public class FamiliarAITests : IDisposable { _created[i].Delete(); } + + _clock.Dispose(); } private static Point3D At(int x, int y) @@ -109,49 +111,7 @@ public class FamiliarAITests : IDisposable return e; } - // 8ms lockstep keeps the wheel and Core.TickCount in sync. - private static void RunFor(long ms) - { - var deadline = Core._tickCount + ms; - - while (Core._tickCount - deadline < 0) - { - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - } - - private static bool RunUntil(Func condition, long maxMs) - { - var deadline = Core._tickCount + maxMs; - - while (Core._tickCount - deadline < 0) - { - if (condition()) - { - return true; - } - - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - - return condition(); - } - - [SkippableFact] - public void UsesFamiliarAI_AndStaysOnComeOrder() - { - var p = Master(1500, 1600); - var f = Familiar(0, p, 1500, 1600); - - Assert.IsType(f.AIObject); - Assert.Equal(OrderType.Come, f.ControlOrder); - Assert.Equal(0.1, f.ActiveSpeed); - Assert.Equal(0.1, f.PassiveSpeed); - } - - [SkippableFact] + [Fact] public void Follows_WithoutBacktracking() { var p = Master(1500, 1600); @@ -183,7 +143,7 @@ public class FamiliarAITests : IDisposable Assert.Equal(Point3D.Zero, f.Home); } - [SkippableFact] + [Fact] public void Orders_AreInert() { var p = Master(1500, 1600); @@ -199,7 +159,7 @@ public class FamiliarAITests : IDisposable Assert.True(RunUntil(() => f.InRange(p, 1), 3000), "a familiar under a Stay order still follows"); } - [SkippableTheory] + [Theory] [InlineData(0, true)] // dark wolf [InlineData(1, true)] // vampire bat [InlineData(2, false)] // shadow wisp @@ -233,7 +193,7 @@ public class FamiliarAITests : IDisposable } } - [SkippableFact] + [Fact] public void DropsTarget_WhenMasterHides() { var p = Master(1500, 1600); @@ -248,7 +208,7 @@ public class FamiliarAITests : IDisposable Assert.True(RunUntil(() => f.Combatant == null && f.Hidden, 1000)); } - [SkippableFact] + [Fact] public void Leash_NeverEngagesATargetFarFromTheMaster() { var p = Master(1500, 1600); @@ -263,7 +223,7 @@ public class FamiliarAITests : IDisposable Assert.True(f.InRange(p, 1), "assist must not carry the familiar past the leash"); } - [SkippableFact] + [Fact] public void Retaliates_IfCombatCapable_AndStaysOnComeOrder() { var p = Master(1500, 1600); @@ -284,7 +244,7 @@ public class FamiliarAITests : IDisposable Assert.False(wisp.Warmode); } - [SkippableFact] + [Fact] public void KeepUp_SnapsWhenFarOnOpenGround() { var p = Master(1500, 1600); @@ -297,14 +257,16 @@ public class FamiliarAITests : IDisposable Assert.True(f.InRange(p, 1), "familiar must snap adjacent to a master 12 tiles away on open ground"); } - [SkippableFact] + [Fact] public void KeepUp_DoesNotSnapWhileRouting() { - // Britain inn L-desk: master north, familiar south; ~17-step detour. - var p = Master(1494, 1605); - var f = Familiar(0, p, 1493, 1614); - p.MoveToWorld(new Point3D(1494, 1605, 21), _map); - f.MoveToWorld(new Point3D(1493, 1614, 20), _map); + // An upstairs room with an L-shaped counter: master north, familiar south; ~20-step detour. + var inn = Prefabs.InnUpstairs; + inn.AssertPresent(); + var p = Master(inn.Target.X, inn.Target.Y); + var f = Familiar(0, p, inn.Anchor.X, inn.Anchor.Y); + p.MoveToWorld(inn.Target, _map); + f.MoveToWorld(inn.Anchor, _map); Server.Engines.Pathing.Cache.StepCache.Instance.Clear(); // Observed from the first tick so an early snap cannot hide behind "arrived". @@ -328,15 +290,14 @@ public class FamiliarAITests : IDisposable Assert.False(teleported, "a working detour must not be short-circuited by a snap"); } - [SkippableFact] + [Fact] public void KeepUp_SnapsAfterGiveUp() { var p = Master(1500, 1596); var f = Familiar(0, p, 1500, 1602); // 5x5 ring, open 3x3 interior: a landing tile exists, no route reaches it. - var id = ApproachTargetTests.FirstImpassableItemId(); - Assert.NotEqual(0, id); + const ushort id = TestTiles.Counter; for (var x = 1498; x <= 1502; x++) { @@ -361,7 +322,7 @@ public class FamiliarAITests : IDisposable ); } - [SkippableFact] + [Fact] public void MirrorsHidden_EvenWhileWalking() { var p = Master(1500, 1600); @@ -389,7 +350,7 @@ public class FamiliarAITests : IDisposable Assert.True(RunUntil(() => !f.Hidden, 500)); } - [SkippableFact] + [Fact] public void Herding_TargetLocation_WinsAndClears() { var p = Master(1500, 1600); @@ -403,7 +364,7 @@ public class FamiliarAITests : IDisposable Assert.True(f.InRange(goal, 1)); } - [SkippableFact] + [Fact] public void NonCombatFamiliar_RefusesAnyCombatant() { var p = Master(1500, 1600); @@ -414,7 +375,7 @@ public class FamiliarAITests : IDisposable Assert.Null(wisp.Combatant); } - [SkippableFact] + [Fact] public void HiddenCaster_FamiliarRefusesRetaliation() { var p = Master(1500, 1600); @@ -434,7 +395,7 @@ public class FamiliarAITests : IDisposable Assert.True(wolf.Hidden); } - [SkippableFact] + [Fact] public void LeftBehind_StandsDown() { var p = Master(1500, 1600); @@ -449,7 +410,7 @@ public class FamiliarAITests : IDisposable Assert.True(RunUntil(() => wolf.Combatant == null && !wolf.Warmode, 500)); } - [SkippableFact] + [Fact] public void Herding_OutranksCombat_AndStandsDown() { var p = Master(1500, 1600); @@ -473,7 +434,7 @@ public class FamiliarAITests : IDisposable Assert.Same(e, p.Combatant); } - [SkippableFact] + [Fact] public void AssistsAgainstWhatTheCastersPetIsFighting() { var p = Master(1500, 1600); @@ -497,7 +458,7 @@ public class FamiliarAITests : IDisposable ); } - [SkippableFact] + [Fact] public void DefendsAnAttackedCaster_WhoseOwnCombatantExpired() { var p = Master(1500, 1600); @@ -515,7 +476,7 @@ public class FamiliarAITests : IDisposable ); } - [SkippableFact] + [Fact] public void DropsATarget_ThatNoLongerFightsAnyone() { var p = Master(1500, 1600); @@ -534,7 +495,7 @@ public class FamiliarAITests : IDisposable Assert.True(RunUntil(() => f.Combatant == null && f.InRange(p, 1), 4000), "familiar disengages and returns"); } - [SkippableFact] + [Fact] public void AssistEnd_LeavesNoStaleMoveIntent() { var p = Master(1500, 1600); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index 6765b07f9..f76a0e4db 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Server; using Server.Mobiles; @@ -7,8 +8,20 @@ namespace UOContent.Tests.Mobiles.AI; // Guard-following may pathfind, so this shares the pathfinding collection. [Collection("Sequential Pathfinding Tests")] -public class GuardFollowTests +public class GuardFollowTests : IDisposable { + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + + _created.Clear(); + } + [Fact] public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() { @@ -16,13 +29,14 @@ public class GuardFollowTests Assert.NotNull(map); map.GetAverageZ(1500, 1600, out _, out var z, out _); - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain - pet.SetControlMaster(master); + // Pet 6 tiles east of the master, open terrain. + var (master, pet) = PetTestSetup.SpawnControlledPet( + new Point3D(1494, 1600, (sbyte)z), + new Point3D(1500, 1600, (sbyte)z), + map + ); + _created.Add(master); + _created.Add(pet); var ai = pet.AIObject; ai.AITimer?.Stop(); // drive manually @@ -33,65 +47,13 @@ public class GuardFollowTests ai.NextMove = 0; ai.Obey(); - var moved = pet.Location != start; - var hasIntent = ai.TryGetMoveWake(out _); - var currentSpeed = pet.CurrentSpeed; - var currentMoveSpeed = pet.CurrentMoveSpeed; - - pet.Delete(); - master.Delete(); - - Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); + Assert.True(pet.Location != start, "a guarding pet beyond guard range must step toward its master"); // Without a move intent, guard-following only steps on the think grid. - Assert.True(hasIntent, "guard-following must register a move intent"); + Assert.True(ai.TryGetMoveWake(out _), "guard-following must register a move intent"); // The return is paced by FollowMoveSpeed; the think clock stays on the active value // and the per-step speed flip must not undo either. - Assert.Equal(0.2, currentSpeed); - Assert.Equal(0.1, currentMoveSpeed); - } - - [Fact] - public void GuardReturn_PreAOS_RunsActive() - { - var previous = Core.Expansion; - - try - { - Core.Expansion = Expansion.UOR; - - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - pet.SetControlMaster(master); - - var ai = pet.AIObject; - ai.AITimer?.Stop(); - pet.ControlOrder = OrderType.Guard; - ai.AITimer?.Stop(); - pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist - - ai.NextMove = 0; - ai.Obey(); - - var currentSpeed = pet.CurrentSpeed; - - pet.Delete(); - master.Delete(); - - // No sprint pre-AOS: the return runs active. - Assert.Equal(0.2, currentSpeed); - } - finally - { - Core.Expansion = previous; - } + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs index ec11a3ba5..0161ae2dd 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs @@ -11,10 +11,23 @@ public class GuardFriendlyFireTests : IDisposable { private readonly List _created = []; + // AI_Mage stands in for a shipped caster: MageAI's combat loop has its own reacquire path. + private sealed class MagePet : BaseCreature + { + public MagePet() : base(AIType.AI_Mage, FightMode.Closest, 10, 1) => Body = 0xC8; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } + private (PlayerMobile owner, PetTestStub pet) Spawn() { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); var z = Map.Felucca.GetAverageZ(1500, 1600); + // The constructed PlayerMobile, not PetTestSetup's deserialization-shaped one: the harmful + // checks read PermaFlags, which only the full constructor allocates. var owner = new PlayerMobile(); owner.MoveToWorld(new Point3D(1500, 1600, z), Map.Felucca); _created.Add(owner); @@ -34,7 +47,7 @@ public class GuardFriendlyFireTests : IDisposable return pet; } - [SkippableFact] + [Fact] public void GuardFallbackDoesNotAcquireItsOwner() { var (owner, pet) = Spawn(); @@ -47,7 +60,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.Combatant); } - [SkippableFact] + [Fact] public void GuardDropsStaleCombatAgainstAnotherPetOfItsOwner() { var (owner, pet) = Spawn(); @@ -61,7 +74,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Equal(OrderType.Guard, pet.ControlOrder); } - [SkippableFact] + [Fact] public void GuardDoesNotRetaliateAgainstOwnerFriendlyDamage() { var (owner, pet) = Spawn(); @@ -70,7 +83,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Equal(OrderType.Guard, pet.ControlOrder); } - [SkippableFact] + [Fact] public void GuardDoesNotRetaliateAgainstSiblingFriendlyDamage() { var (owner, pet) = Spawn(); @@ -79,7 +92,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.Combatant); } - [SkippableFact] + [Fact] public void GuardReacquiresAnEnemyAttackingItsOwner() { var (owner, pet) = Spawn(); @@ -93,7 +106,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(enemy, pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardStillDefendsAgainstAnotherPlayersPet() { var (owner, pet) = Spawn(); @@ -106,7 +119,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(enemy, pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardIgnoresSiblingInOwnersAggressorHistory() { var (owner, pet) = Spawn(); @@ -117,7 +130,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardIgnoresOwnControlledSummonAttackingOwner() { var (owner, pet) = Spawn(); @@ -132,7 +145,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardDefendsAgainstOwnUncontrolledSummonAttackingOwner() { var (owner, pet) = Spawn(); @@ -146,7 +159,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(vortex, pet.FocusMob); } - [SkippableFact] + [Fact] public void FriendlyDamageDoesNotInterruptGuardCombat() { var (owner, pet) = Spawn(); @@ -159,7 +172,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(enemy, pet.Combatant); } - [SkippableFact] + [Fact] public void ExplicitAttackStillAcquiresTheCommandedTarget() { var (owner, pet) = Spawn(); @@ -169,7 +182,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(sibling, pet.FocusMob); } - [SkippableFact] + [Fact] public void BardProvocationStillOverridesGuard() { var (owner, pet) = Spawn(); @@ -179,7 +192,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(owner, pet.FocusMob); } - [SkippableTheory] + [Theory] [InlineData(OrderType.Follow)] [InlineData(OrderType.Come)] [InlineData(OrderType.Stay)] @@ -191,29 +204,29 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.FocusMob); } - [SkippableFact] - public void ActualWhiteWyrmDoesNotTurnOnOwnerWhenEnemyDisappears() + [Fact] + public void GuardingMageDoesNotTurnOnOwnerWhenEnemyDisappears() { var (owner, _) = Spawn(); - var wyrm = new WhiteWyrm(); - _created.Add(wyrm); - wyrm.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); - wyrm.SetControlMaster(owner); - wyrm.IssueOrder(OrderType.Guard, owner); - wyrm.AIObject.AITimer.Stop(); + var mage = new MagePet(); + _created.Add(mage); + mage.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + mage.SetControlMaster(owner); + mage.IssueOrder(OrderType.Guard, owner); + mage.AIObject.AITimer.Stop(); var enemy = new PetTestStub(); _created.Add(enemy); enemy.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map); enemy.AIObject.AITimer.Stop(); - wyrm.Combatant = enemy; + mage.Combatant = enemy; enemy.Hidden = true; - wyrm.NextReacquireTime = Core.TickCount - 1; - Assert.IsType(wyrm.AIObject); - wyrm.AIObject.DoActionCombat(); - Assert.NotSame(owner, wyrm.Combatant); - Assert.Null(wyrm.FocusMob); - wyrm.AIObject.DoOrderGuard(); - Assert.Null(wyrm.Combatant); + mage.NextReacquireTime = Core.TickCount - 1; + Assert.IsType(mage.AIObject); + mage.AIObject.DoActionCombat(); + Assert.NotSame(owner, mage.Combatant); + Assert.Null(mage.FocusMob); + mage.AIObject.DoOrderGuard(); + Assert.Null(mage.Combatant); } public void Dispose() diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs index 7d572786a..356994020 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles.AI; @@ -14,11 +15,6 @@ public class GuardOrderTests : IDisposable { private readonly List _created = new(); - private sealed class AggressorStub : Mobile - { - public AggressorStub() => Body = 0xC9; - } - public void Dispose() { foreach (var m in _created) @@ -35,14 +31,12 @@ public class GuardOrderTests : IDisposable Assert.NotNull(map); map.GetAverageZ(1500, 1600, out _, out z, out _); - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + var (master, pet) = PetTestSetup.SpawnControlledPet( + new Point3D(1500, 1600, (sbyte)z), + new Point3D(1499, 1600, (sbyte)z), + map + ); _created.Add(master); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map); - pet.SetControlMaster(master); _created.Add(pet); pet.AIObject.AITimer?.Stop(); // drive manually @@ -52,9 +46,9 @@ public class GuardOrderTests : IDisposable return (master, pet); } - private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) + private WalkerStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) { - var aggr = new AggressorStub(); + var aggr = new WalkerStub(); aggr.MoveToWorld(loc, pet.Map); _created.Add(aggr); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs index 357a39979..13119ca03 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs @@ -10,19 +10,13 @@ namespace UOContent.Tests.Mobiles.AI; [Collection("Sequential Pathfinding Tests")] public class HerdingTests { - private sealed class Stub : BaseCreature - { - public Stub(Serial serial) : base(serial) => Body = 0xC9; - } - - [SkippableFact] + [Fact] public void HerdedCreature_ReachesTheTile_AndClearsTargetLocation() { - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; map.GetAverageZ(1500, 1600, out _, out var z, out _); - var bc = new Stub(World.NewMobile); + var bc = new BareCreatureStub(World.NewMobile); bc.DefaultMobileInit(); bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); BaseAI ai = new AnimalAI(bc); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs deleted file mode 100644 index 3ced177b5..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// A meer mage fights FightMode.Evil and its enraged creatures carry -1000 karma, so the karma rules alone -// make each an enemy of the other. -[Collection("Sequential UOContent Tests")] -public class MeerEnragedTests : IDisposable -{ - private readonly List _created = []; - - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } - - private T Track(T m) where T : Mobile - { - _created.Add(m); - return m; - } - - private static void Place(BaseCreature bc, int x) - { - var map = Map.Felucca; - bc.MoveToWorld(new Point3D(x, 1600, map.GetAverageZ(x, 1600)), map); // flat, clear line of sight both ways - bc.AIObject.AITimer.Stop(); - } - - private static bool Acquire(BaseCreature creature) - { - creature.NextReacquireTime = Core.TickCount - 1; - return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true); - } - - [Fact] - public void MeerAndItsEnragedCreature_AreNotEnemies() - { - var meer = Track(new MeerMage()); - var enraged = Track(new EnragedRabbit(meer)); - - Assert.False(enraged.IsEnemy(meer)); - Assert.False(meer.IsEnemy(enraged)); - } - - [Fact] - public void AnotherMeersEnragedCreature_StaysAnEnemy() - { - var meer = Track(new MeerMage()); - var other = Track(new MeerMage()); - var enraged = Track(new EnragedRabbit(other)); - - Assert.True(enraged.IsEnemy(meer)); - Assert.True(meer.IsEnemy(enraged)); - } - - [SkippableFact] - public void MeerAndItsEnragedCreature_NeverAcquireEachOther() - { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); - - var meer = Track(new MeerMage()); - var enraged = Track(new EnragedBlackBear(meer)); - Place(meer, 1600); - Place(enraged, 1601); - - Assert.False(Acquire(enraged)); - Assert.False(Acquire(meer)); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index 7d12ed744..288572eb7 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -210,10 +210,10 @@ public class PetOrderTests : IDisposable Assert.Equal(post, pet.Home); // anchor still the original post, not the corpse } - // NOTE: the test fixture does not load tile data, so Mobile.Move is blocked and Location - // never changes here. DoMoveImpl still sets Mobile.Direction before the (blocked) move, - // so an *attempted* wander is observable via Direction. The subjective wander cadence is - // covered by manual QA; these tests verify the gate/frozen wiring deterministically. + // A step may be blocked by whatever terrain (1000, 1000) holds, but DoMoveImpl sets + // Mobile.Direction before attempting the move, so an *attempted* wander is observable via + // Direction either way. The subjective wander cadence is covered by manual QA; these tests + // verify the gate/frozen wiring deterministically. [Fact] public void IdlePet_DoesNotAttemptToMove_WhileResting() { @@ -224,12 +224,18 @@ public class PetOrderTests : IDisposable pet.ForceIdle = true; // CheckIdle() reports resting -> idle wander must be skipped pet.Direction = Direction.North; + var start = pet.Location; + var ai = pet.AIObject; for (var i = 0; i < 40; i++) { - pet.AIObject.DoOrderNone(); + // Reopen the move clock every pass, so CheckIdle is the only gate left standing. + ai.NextMove = Core.TickCount; + Assert.True(ai.CanMoveNow(out _)); + ai.DoOrderNone(); } Assert.Equal(Direction.North, pet.Direction); // gated by CheckIdle -> never attempts a step + Assert.Equal(start, pet.Location); } [Fact] @@ -265,21 +271,6 @@ public class PetOrderTests : IDisposable Assert.Equal(pet.Location, pet.Home); } - [Fact] - public void LoyaltyRelease_ClearsTheMaster_AndStartsTheDeleteCountdown() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - var followers = master.Followers; - - // What the loyalty drain assigns when loyalty reaches zero. - pet.ControlOrder = OrderType.Release; - - Assert.False(pet.Controlled); - Assert.Null(pet.ControlMaster); - Assert.Equal(followers - pet.ControlSlots, master.Followers); - Assert.True(pet.PendingDeleteTimer?.Running); - } - [Fact] public void Release_WithoutSpawner_AnchorsHomeToCurrentLocation() { @@ -375,29 +366,6 @@ public class PetOrderTests : IDisposable Assert.Same(master, pet.ControlTarget); Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); } - - [Fact] - public void Stop_WhileFollowing_CancelsToIdle_NonML() - { - var previous = Core.Expansion; - try - { - Core.Expansion = Expansion.SE; // pre-ML: Core.ML is false - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0)); - pet.ControlOrder = OrderType.Follow; - - pet.ControlOrder = OrderType.Stop; - - // Stop/idle resolution is era-independent. - Assert.Equal(OrderType.None, pet.ControlOrder); - Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); - Assert.Equal(pet.Location, pet.Home); - } - finally - { - Core.Expansion = previous; - } - } // The parameterless ctor fully initializes a player; the Serial ctor leaves that to Deserialize. private PlayerMobile SpawnPlayer(Point3D loc) { @@ -991,19 +959,6 @@ public class PetOrderTests : IDisposable Assert.Same(friend, pet.ControlTarget); } - [Fact] - public void GMObey_TakesControlOfACommandablePet() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.Name = "Rex"; - var gm = SpawnPlayer(new Point3D(1002, 1000, 0)); - gm.AccessLevel = AccessLevel.GameMaster; - - pet.AIObject.OnSpeech(new SpeechEventArgs(gm, "Rex obey", MessageType.Regular, 0x3B2, [])); - - Assert.Same(gm, pet.ControlMaster); - } - [Fact] public void GMAllObey_DoesNotTakeControlledPets() { @@ -1021,42 +976,5 @@ public class PetOrderTests : IDisposable } // Release is relinquishing control, not exerting it: no roll, so no loyalty either way. - [Fact] - public void MenuRelease_TouchesNoLoyalty() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.Loyalty = 50; - - new InternalEntry(3006118, 14, OrderType.Release, true).OnClick(master, pet); // Release - - Assert.Equal(50, pet.Loyalty); // no roll: neither the +1 for passing nor the -3 for failing - } - - [Fact] - public void SpeechRelease_TouchesNoLoyalty() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.Name = "Rex"; - pet.Loyalty = 50; - - pet.AIObject.OnSpeech(new SpeechEventArgs(master, "Rex release", MessageType.Regular, 0x3B2, [0x16D])); - - Assert.Equal(50, pet.Loyalty); - } - // A creature nobody can command is not released either. - [Fact] - public void MenuRelease_OnAnUncontrollablePet_IsRefusedWithoutCost() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.MinTameSkill = 120.0; // control chance at or below zero - master.Skills.AnimalTaming.Base = 0; - master.Skills.AnimalLore.Base = 0; - pet.Loyalty = 50; - - new InternalEntry(3006118, 14, OrderType.Release, true).OnClick(master, pet); - - Assert.Equal(50, pet.Loyalty); // refused, but never punished - Assert.True(pet.Controlled); - } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index 12d978e6d..793fd3d6f 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; +using static Server.Tests.TimerWheelScope; namespace UOContent.Tests.Mobiles.AI; @@ -11,6 +13,7 @@ namespace UOContent.Tests.Mobiles.AI; public class PetPacingTests : IDisposable { private readonly List _created = new(); + private TimerWheelScope _clock; private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc) { @@ -28,6 +31,7 @@ public class PetPacingTests : IDisposable } _created.Clear(); + _clock?.Dispose(); } // Movement orders run active, resting orders run passive; the move clock follows. @@ -64,8 +68,8 @@ public class PetPacingTests : IDisposable pet.ControlTarget = master; pet.ControlOrder = OrderType.Follow; // fixture era is EJ - Assert.Equal(0.2, pet.CurrentSpeed); // active think, not the follow pace - Assert.Equal(0.1, pet.CurrentMoveSpeed); // capped at the follow pace + Assert.Equal(0.2, pet.CurrentSpeed); // active think, not the follow pace + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); // capped at the follow pace } // A creature configured faster than the follow pace keeps its own. @@ -73,12 +77,13 @@ public class PetPacingTests : IDisposable public void FollowMaster_KeepsAFasterConfiguredPace() { var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.05, 0.9); + var faster = pet.FollowMoveSpeed / 2; + pet.SetMoveSpeed(faster, 0.9); pet.ControlTarget = master; pet.ControlOrder = OrderType.Follow; - Assert.Equal(0.05, pet.CurrentMoveSpeed); + Assert.Equal(faster, pet.CurrentMoveSpeed); } // The move-clock override survives the order: it is capped while following, not overwritten. @@ -99,29 +104,6 @@ public class PetPacingTests : IDisposable } // Pre-AOS pets follow at their own pace; nothing caps them. - [Fact] - public void FollowMaster_PreAOS_KeepsItsOwnPace() - { - var previous = Core.Expansion; - - try - { - Core.Expansion = Expansion.UOR; - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); - - pet.ControlTarget = master; - pet.ControlOrder = OrderType.Follow; - - Assert.Equal(0.2, pet.CurrentSpeed); - Assert.Equal(0.3, pet.CurrentMoveSpeed); - } - finally - { - Core.Expansion = previous; - } - } - private sealed class SprintingPet : PetTestStub { public override double FollowMoveSpeed => 0.125; @@ -168,7 +150,7 @@ public class PetPacingTests : IDisposable pet.ControlOrder = OrderType.Guard; Assert.Equal(0.2, pet.CurrentSpeed); - Assert.Equal(0.1, pet.CurrentMoveSpeed); + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); } // Obeying the follow order must not write the pace into either clock. @@ -184,7 +166,7 @@ public class PetPacingTests : IDisposable pet.AIObject.Obey(); Assert.Equal(0.2, pet.CurrentSpeed); - Assert.Equal(0.1, pet.CurrentMoveSpeed); + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); } // At the master's side a guarding pet stays active: no stale-warmode passive, no sprint. @@ -204,35 +186,7 @@ public class PetPacingTests : IDisposable } // A pet chasing a combatant keeps the move table. - [Fact] - public void CombatChasingPet_KeepsMoveTable() - { - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - var target = new PetTestStub(); - target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca); - _created.Add(target); - - pet.SetMoveSpeed(0.3, 0.9); - pet.ControlOrder = OrderType.Guard; - pet.Combatant = target; - pet.SetCurrentSpeedToActive(); - - Assert.Equal(0.3, pet.CurrentMoveSpeed); - } - // Herding overrides order pacing. - [Fact] - public void HerdedObeyingPet_KeepsHerdingPace() - { - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.45, 0.9); - pet.SetCurrentSpeedToPassive(); - - pet.TargetLocation = new Point2D(1010, 1010); - - Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace - } - private sealed class ThinkProbe : PetTestStub { public int Thinks; @@ -259,41 +213,10 @@ public class PetPacingTests : IDisposable return (master, pet); } - // Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync. - private static void RunFor(long ms) - { - var deadline = Core._tickCount + ms; - - while (Core._tickCount < deadline) - { - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - } - - private static bool RunUntil(Func condition, long maxMs) - { - var deadline = Core._tickCount + maxMs; - - while (Core._tickCount < deadline) - { - if (condition()) - { - return true; - } - - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - - return condition(); - } - // Runs past the spawn stagger; returns right after a think with the next 0.4s away. private ThinkProbe SettledProbe(out PlayerMobile master) { - Core._tickCount = 0; - Timer.Init(0); + _clock = new TimerWheelScope(); var (m, pet) = SpawnProbe(); master = m; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs index 41b52a189..dec588d49 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs @@ -35,16 +35,10 @@ public class PetRetaliationTests : IDisposable private (PlayerMobile master, T pet) Spawn() where T : BaseCreature, new() { - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + var (master, pet) = PetTestSetup.SpawnControlledPet(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); _created.Add(master); - - var pet = new T(); - pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); - pet.SetControlMaster(master); - pet.AIObject.AITimer?.Stop(); _created.Add(pet); + pet.AIObject.AITimer?.Stop(); return (master, pet); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs index d15228590..7779384b6 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs @@ -49,17 +49,21 @@ public class PetTestStub : BaseCreature public static class PetTestSetup { - // Places a player master and a controlled pet on Felucca and returns both. + // Places a player master and a controlled pet (on Felucca unless a map is given) and returns both. public static (PlayerMobile master, PetTestStub pet) SpawnControlledPet( - Point3D masterLoc, Point3D petLoc) + Point3D masterLoc, Point3D petLoc, Map map = null) => + SpawnControlledPet(masterLoc, petLoc, map); + + public static (PlayerMobile master, T pet) SpawnControlledPet( + Point3D masterLoc, Point3D petLoc, Map map = null) where T : BaseCreature, new() { - var map = Map.Felucca; + map ??= Map.Felucca; var master = new PlayerMobile(World.NewMobile); master.DefaultMobileInit(); master.MoveToWorld(masterLoc, map); - var pet = new PetTestStub(); + var pet = new T(); pet.MoveToWorld(petLoc, map); pet.SetControlMaster(master); // sets Controlled, Home=Zero, ControlOrder=Come diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs index 28b311186..1a2f6d737 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs @@ -36,25 +36,9 @@ public class SummonAcquireRulesTests : IDisposable return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true); } - [SkippableTheory] - [InlineData(true)] - [InlineData(false)] - public void PreAosFeluccaVortexTurnsOnItsCaster(bool bladeSpirits) - { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); - Core.Expansion = Expansion.UOR; - - var caster = Player(Map.Felucca, 1500, 100); - var summon = Summon(bladeSpirits ? new BladeSpirits() : new EnergyVortex(), caster, 1499); - - Assert.True(Acquire(summon)); - Assert.Same(caster, summon.FocusMob); - } - - [SkippableFact] + [Fact] public void PreAosFeluccaVortexPrefersAnyoneOverItsCaster() { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); Core.Expansion = Expansion.UOR; // The caster outranks the bystander on the vortex's int-over-distance ranking. @@ -66,12 +50,11 @@ public class SummonAcquireRulesTests : IDisposable Assert.Same(bystander, vortex.FocusMob); } - [SkippableTheory] + [Theory] [InlineData(Expansion.UOR, 1)] // Trammel [InlineData(Expansion.AOS, 0)] // Felucca public void VortexSparesItsCasterOutsidePreAosFelucca(Expansion expansion, int mapIndex) { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); Core.Expansion = expansion; var caster = Player(Map.Maps[mapIndex], 1500, 100); @@ -81,10 +64,9 @@ public class SummonAcquireRulesTests : IDisposable Assert.Null(vortex.FocusMob); } - [SkippableFact] + [Fact] public void MasterlessSummonDoesNotBreakAcquisition() { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); Core.Expansion = Expansion.AOS; var player = Player(Map.Felucca, 1500, 100); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs index ba106218c..5d92fca85 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Server; using Server.Items; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -15,60 +16,14 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class BaseCreatureSerializationTests : IDisposable { - private readonly List _created = new(); - private readonly List _createdItems = new(); + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - for (var i = 0; i < _createdItems.Count; i++) - { - _createdItems[i].Delete(); - } - } - - private class CreatureStub : BaseCreature - { - public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9; - - public CreatureStub(Serial serial) : base(serial) => Body = 0xC9; - - public DateTime SummonEndValue => SummonEnd; - - // Stands in for the npc-speeds table (unconfigured in the test fixture). - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - - public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) - { - activeMoveSpeed = 0.6; - passiveMoveSpeed = 1.2; - } - } - - private CreatureStub NewCreature() - { - var bc = new CreatureStub(); - _created.Add(bc); - return bc; - } + private TestCreature NewCreature() => _harness.NewCreature(); // ReadEntity resolves references through the world table, so the master must be registered. - private PlayerMobile NewMaster() - { - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - World.AddEntity(master); - _created.Add(master); - return master; - } + private PlayerMobile NewMaster() => _harness.NewPlayer(); private static byte[] Snapshot(Mobile m) { @@ -80,10 +35,10 @@ public class BaseCreatureSerializationTests : IDisposable return buffer; } - private CreatureStub Load(byte[] buffer) + private TestCreature Load(byte[] buffer) { - var copy = new CreatureStub(World.NewMobile); - _created.Add(copy); + var copy = new TestCreature(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -219,7 +174,7 @@ public class BaseCreatureSerializationTests : IDisposable var bc = NewCreature(); var friend = NewMaster(); var wayPoint = new WayPoint(); - _createdItems.Add(wayPoint); + _harness.TrackItem(wayPoint); bc.AddPetFriend(friend); bc.CurrentWayPoint = wayPoint; @@ -245,49 +200,20 @@ public class BaseCreatureSerializationTests : IDisposable Assert.InRange(copy.DeleteTimeLeft, TimeSpan.FromDays(3.0) - TimeSpan.FromSeconds(5), TimeSpan.FromDays(3.0)); } - private sealed class VendorStub : BaseVendor - { - private static readonly List _sbInfos = []; - - public VendorStub() : base("the stub") - { - } - - public VendorStub(Serial serial) : base(serial) - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - } - // BaseVendor is generated on top of the generated BaseCreature; the chain must write // and read both sections in order with exact consumption. [Fact] public void GeneratedVendorChain_RoundTrips() { - var vendor = new VendorStub(); - _created.Add(vendor); + var vendor = new TestVendor(); + _harness.Track(vendor); vendor.Home = new Point3D(1500, 1600, 0); vendor.RangeHome = 2; var buffer = Snapshot(vendor); - var copy = new VendorStub(World.NewMobile); - _created.Add(copy); + var copy = new TestVendor(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -378,7 +304,7 @@ public class BaseCreatureSerializationTests : IDisposable writer.Write(0.0); // passive move speed (v22) } - private CreatureStub LoadLegacyV22( + private TestCreature LoadLegacyV22( bool controlled = false, Mobile controlMaster = null, bool summoned = false, @@ -390,7 +316,7 @@ public class BaseCreatureSerializationTests : IDisposable // Mobile donor produces a byte-authentic one. var donor = new MobileStub(); donor.DefaultMobileInit(); - _created.Add(donor); + _harness.Track(donor); var writer = new BufferWriter(true); donor.Serialize(writer); @@ -399,8 +325,8 @@ public class BaseCreatureSerializationTests : IDisposable var buffer = new byte[writer.Position]; writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); - var copy = new CreatureStub(World.NewMobile); - _created.Add(copy); + var copy = new TestCreature(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -499,7 +425,7 @@ public class BaseCreatureSerializationTests : IDisposable var donor = new MobileStub(); donor.DefaultMobileInit(); - _created.Add(donor); + _harness.Track(donor); var writer = new BufferWriter(true); donor.Serialize(writer); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs index f01e5c7bd..be314b575 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs @@ -1,8 +1,8 @@ using System; -using System.Collections.Generic; using Server; using Server.Mobiles; using Server.Spells.Necromancy; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -11,40 +11,13 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class DeletedMasterTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); - private sealed class Stub : BaseCreature - { - public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + private TestCreature NewCreature() => _harness.NewCreature(); - public Stub(Serial serial) : base(serial) - { - } - } - - private Stub NewCreature() - { - var bc = new Stub(); - _created.Add(bc); - return bc; - } - - private PlayerMobile NewPlayer() - { - var pm = new PlayerMobile(World.NewMobile); - pm.DefaultMobileInit(); - World.AddEntity(pm); - _created.Add(pm); - return pm; - } + private PlayerMobile NewPlayer() => _harness.NewPlayer(); [Fact] public void DeletingAPlayer_ReleasesAnUnbondedPet() diff --git a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs index e02abece7..922e33294 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs @@ -78,34 +78,6 @@ public class LootingRightsTests } } - [Fact] - public void HitsMaxBand_MovesTheRightsThreshold() - { - var victim = new TestMobile(); - var first = NewPlayer(); - var second = NewPlayer(); - - try - { - victim.RegisterDamage(100, first); - victim.RegisterDamage(40, second); - - // hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears. - var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200); - - Assert.Equal(2, rights.Count); - Assert.True(rights[0].m_HasRight); - Assert.True(rights[1].m_HasRight); - Assert.Same(second, rights[1].m_Mobile); - } - finally - { - victim.Delete(); - first.Delete(); - second.Delete(); - } - } - [Fact] public void PetDamage_CreditsTheMaster_NotThePet() { diff --git a/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs index 20578ecfc..bb027a951 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs @@ -1,7 +1,7 @@ using System; -using System.Collections.Generic; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -10,40 +10,13 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class MasterViewsTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); - private sealed class Stub : BaseCreature - { - public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + private TestCreature NewCreature() => _harness.NewCreature(); - public Stub(Serial serial) : base(serial) - { - } - } - - private Stub NewCreature() - { - var bc = new Stub(); - _created.Add(bc); - return bc; - } - - private PlayerMobile NewPlayer() - { - var pm = new PlayerMobile(World.NewMobile); - pm.DefaultMobileInit(); - World.AddEntity(pm); - _created.Add(pm); - return pm; - } + private PlayerMobile NewPlayer() => _harness.NewPlayer(); public enum Shape { diff --git a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs index f61ca6229..fac93cb54 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs @@ -9,15 +9,9 @@ namespace Server.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class VendorBuybackTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); private sealed class BandageSB : SBInfo { @@ -30,34 +24,9 @@ public class VendorBuybackTests : IDisposable public override List BuyInfo { get; } = []; } - private sealed class VendorStub : BaseVendor + private sealed class BandageVendor : TestVendor { - private readonly List _sbInfos = []; - - public VendorStub() : base("the stub") - { - } - - public VendorStub(Serial serial) : base(serial) - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - _sbInfos.Add(new BandageSB()); - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } + protected override void AddSBInfos(List sbInfos) => sbInfos.Add(new BandageSB()); } // Same sell price as BandageSB, but a buy list at the 250-entry limit so buyback capacity is 0. @@ -81,55 +50,36 @@ public class VendorBuybackTests : IDisposable public override List BuyInfo => _buyInfo; } - private sealed class FullStockVendorStub : BaseVendor + private sealed class FullStockVendor : TestVendor { - private readonly List _sbInfos = []; - - public FullStockVendorStub() : base("the stub") - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - _sbInfos.Add(new FullStockSB()); - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } + protected override void AddSBInfos(List sbInfos) => sbInfos.Add(new FullStockSB()); } - private VendorStub NewVendor() - { - var vendor = new VendorStub(); - _created.Add(vendor); - return vendor; - } + private BandageVendor NewVendor() => _harness.Track(new BandageVendor()); - private FullStockVendorStub NewFullStockVendor() - { - var vendor = new FullStockVendorStub(); - _created.Add(vendor); - return vendor; - } + private FullStockVendor NewFullStockVendor() => _harness.Track(new FullStockVendor()); private Mobile NewSeller() { - var seller = new Mobile(World.NewMobile); + var seller = _harness.Track(new Mobile(World.NewMobile)); seller.DefaultMobileInit(); seller.AddItem(new Backpack()); - _created.Add(seller); return seller; } + // A fresh seller offers a stack of 10 bandages and sells of them. + private Bandage SellBandages(BaseVendor vendor, int amount, out Mobile seller) + { + seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + + Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, amount)])); + return bandages; + } + + private Bandage SellBandages(BaseVendor vendor, int amount) => SellBandages(vendor, amount, out _); + [Theory] [InlineData(0.25, 1.0)] // restocked 15m ago: next boundary is the regular one [InlineData(5.0, 6.0)] // exactly on a boundary: strictly after now @@ -160,11 +110,7 @@ public class VendorBuybackTests : IDisposable public void PartialStackSale_SplitGoesToBuyback_SellerKeepsRemainder() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - - Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 4)])); + var bandages = SellBandages(vendor, 4, out var seller); Assert.Equal(6, bandages.Amount); Assert.Same(seller.Backpack, bandages.Parent); @@ -181,10 +127,7 @@ public class VendorBuybackTests : IDisposable public void Restock_PurgesBuyback() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + var bandages = SellBandages(vendor, 10); Assert.Single(vendor.BuyPack.Items); vendor.Restock(); @@ -197,20 +140,14 @@ public class VendorBuybackTests : IDisposable public void Restock_ClearsPurgeSchedule_AndASubsequentSaleRearmsIt() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); vendor.Restock(); Assert.False(vendor.BuybackPurgeScheduled); - var seller2 = NewSeller(); - var moreBandages = new Bandage(10); - seller2.Backpack.DropItem(moreBandages); - vendor.OnSellItems(seller2, [new SellItemResponse(moreBandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); } @@ -219,10 +156,7 @@ public class VendorBuybackTests : IDisposable public void Delete_CancelsBuybackPurgeTimer() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); vendor.Delete(); @@ -239,7 +173,8 @@ public class VendorBuybackTests : IDisposable var legacy = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; vendor.AddItem(legacy); - var children = new Item[20_000]; + // Several children, so a delete loop that skips entries as the list shrinks would show. + var children = new Item[3]; for (var i = 0; i < children.Length; i++) { children[i] = new Item(0x1234); @@ -261,11 +196,7 @@ public class VendorBuybackTests : IDisposable public void SaleWithNoBuybackCapacity_ConsumesItem_AndStillPays() { var vendor = NewFullStockVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - - Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)])); + var bandages = SellBandages(vendor, 10, out var seller); Assert.True(bandages.Deleted); Assert.Empty(vendor.BuyPack.Items); diff --git a/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs b/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs index b981d9f86..d603a0c12 100644 --- a/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs +++ b/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs @@ -216,7 +216,8 @@ public sealed class DesignStateDetailed : Packet if (deflatedLength == 0) { - Console.WriteLine("Compression error"); + // An oracle that silently emits a truncated plane would make the comparison meaningless. + throw new InvalidOperationException("Compression error"); } Write((byte)(0x20 | i)); @@ -248,7 +249,8 @@ public sealed class DesignStateDetailed : Packet if (deflatedLength == 0) { - Console.WriteLine("Compression error"); + // An oracle that silently emits a truncated plane would make the comparison meaningless. + throw new InvalidOperationException("Compression error"); } Write((byte)(9 + i)); diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs index b38364373..2faa8eba5 100644 --- a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs @@ -13,7 +13,9 @@ * along with this program. If not, see . * *************************************************************************/ +using System; using System.Net; +using System.Reflection; using System.Text; using Server.Network; using Server.Network.Bans; @@ -21,10 +23,18 @@ using Xunit; namespace Server.Tests.Network.Exemptions; -// Addresses come from TEST-NET-1 (192.0.2.0/24) to stay clear of the other ban test classes if xUnit runs -// them concurrently. -public class BanExemptionsTests +// ManualAllowlist is process-wide and BlocklistFilter reads it, so this runs in the sequential collection +// and puts the original snapshot back. Addresses come from TEST-NET-1 (192.0.2.0/24). +[Collection("Sequential UOContent Tests")] +public class BanExemptionsTests : IDisposable { + private static readonly FieldInfo _snapshotField = + typeof(ManualAllowlist).GetField("_snapshot", BindingFlags.Static | BindingFlags.NonPublic); + + private readonly object _savedSnapshot = _snapshotField.GetValue(null); + + public void Dispose() => ManualAllowlist.LoadForTesting((BlocklistSnapshot)_savedSnapshot); + private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10"); private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11"); @@ -74,15 +84,6 @@ public class BanExemptionsTests Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled)); } - [Fact] - public void Manual_allowlist_does_not_spend_the_earned_lists_strikes() - { - WithManualAllowlist("192.0.2.10"); - - // Unconditional, so the revocable list must not be consulted -- that would burn a strike. - Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); - } - [Fact] public void Falls_through_to_the_login_allowlist_when_not_file_listed() { diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs index 3d81c8d87..543ccddc6 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs @@ -59,12 +59,6 @@ public class BlocklistConfigurationTests } // The point of the flag: a shard that never opts in must not start the reload poll. - [Fact] - public void Blocklist_is_off_by_default() - { - Assert.False(new BlocklistSettings().Enabled); - } - // The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves // without the other, a shard silently enforces nothing. [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs index 1b7a7772c..991e49451 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs @@ -21,14 +21,13 @@ using Xunit; namespace Server.Tests.Network.Bans.Blocklist; -public class BlocklistFileTests +public class BlocklistFileTests : IDisposable { - private static string WriteTemp(string content) - { - var p = Path.Combine(Path.GetTempPath(), "bl-" + Guid.NewGuid().ToString("N") + ".txt"); - File.WriteAllText(p, content); - return p; - } + private readonly TempDirectory _temp = new("bl"); + + public void Dispose() => _temp.Dispose(); + + private string WriteTemp(string content) => _temp.WriteFile("blocklist.txt", content); [Fact] public void Reads_header_generated_and_count() @@ -38,13 +37,12 @@ public class BlocklistFileTests Assert.True(h.Present); Assert.Equal("2026-07-21T09:24:23Z", h.Generated); Assert.Equal(2, h.Count); - File.Delete(p); } [Fact] public void Missing_file_reports_absent_and_loads_empty() { - var p = Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N")); + var p = _temp.Combine("does-not-exist.txt"); Assert.False(BlocklistFile.TryReadHeader(p, out var h)); Assert.False(h.Present); var snap = BlocklistFile.Load(p, out _, out _); @@ -70,16 +68,5 @@ public class BlocklistFileTests Assert.Equal(0, skipped); Assert.True(snap.IsBanned(IPAddress.Parse("2.181.183.77"))); Assert.True(snap.IsBanned(IPAddress.Parse("223.169.4.9"))); - File.Delete(p); - } - - [Fact] - public void Load_parses_body() - { - var p = WriteTemp("# generated=x count=1\n8.8.8.0/24\n"); - var snap = BlocklistFile.Load(p, out var parsed, out _); - Assert.Equal(1, parsed); - Assert.True(snap.IsBanned(IPAddress.Parse("8.8.8.8"))); - File.Delete(p); } } diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs index 4f3ee5ad1..6e630ba49 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs @@ -20,8 +20,10 @@ using Xunit; namespace Server.Tests.Network.Bans.Blocklist; -// No [Collection] and no static reset hook: the filter is an instance, so each test owns its own -// snapshot and promote-guard. That is the point of it no longer being a static class. +// The filter is an instance, so each test owns its own snapshot and promote-guard. Evaluate still +// consults the process-wide ManualAllowlist and LoginAllowlist, which other tests rewrite, so this +// runs in the sequential collection. +[Collection("Sequential UOContent Tests")] public class BlocklistFilterTests { private static BlocklistFilter WithList(string list, bool reportHits = true, long suppressionMs = 5000) diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs index abdac1d11..9a9c98139 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs @@ -54,12 +54,6 @@ public class BlocklistSnapshotTests Assert.True(s.IsBanned(IPAddress.Parse("5.6.7.200"))); } - [Fact] - public void Empty_snapshot_matches_nothing() - { - Assert.False(BlocklistSnapshot.Empty.IsBanned(IPAddress.Parse("1.2.3.4"))); - } - [Fact] public void Ipv6_single_and_cidr_are_matched() { diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs index 756e19b1d..786598515 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs @@ -42,14 +42,6 @@ public class CrowdSecAlertClientTests Assert.Equal("/v1/decisions?origin=modern%20uo%2Ftest%26x%3D1&ip=1.2.3.4", query); } - [Fact] - public void BuildDeleteQuery_PlainOrigin_Ipv4() - { - var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("192.168.1.1")); - - Assert.Equal("/v1/decisions?origin=modernuo&ip=192.168.1.1", query); - } - [Fact] public void BuildDeleteQuery_Ipv6_IsEscaped() { diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs index 6ad850f34..0a6d956d6 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs @@ -218,14 +218,18 @@ public class CrowdSecReporterTests /// /// The drain task must track the loop's lifetime, not just its first await — a ValueTask-returning - /// drain loop passed to Task.Run yields a Task<ValueTask> that completes immediately, which makes - /// Stop()'s drain-exited handshake a no-op. With an empty queue the loop parks on WaitToReadAsync, - /// so a correctly unwrapped task cannot win this race; a slow pool only under-detects. + /// drain loop passed to Task.Run yields a Task<ValueTask> that completes at the first await + /// that suspends, which makes Stop()'s drain-exited handshake a no-op. The flush delay always + /// suspends before the first post, so once the client sees a post a wrapped task is already done. /// [Fact] public async Task Start_DrainTaskSpansLoopLifetime_NotJustTheFirstAwait() { - var reporter = new CrowdSecReporter(new NullAlertClient(), Settings()); + var settings = Settings(); + settings.FlushInterval = TimeSpan.FromMilliseconds(1); + + var client = new SignalingAlertClient(); + var reporter = new CrowdSecReporter(client, settings); using var cts = new CancellationTokenSource(); reporter.Start(cts.Token); @@ -233,14 +237,32 @@ public class CrowdSecReporterTests var drain = reporter.DrainTaskForTesting; Assert.NotNull(drain); - var first = await Task.WhenAny(drain, Task.Delay(TimeSpan.FromMilliseconds(500))); - Assert.False(ReferenceEquals(first, drain), "drain task completed while the loop was still running"); + reporter.Report(IPAddress.Parse("9.9.9.9"), TimeSpan.FromHours(1), "rate-limit"); + await client.Posted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.False(drain.IsCompleted, "drain task completed while the loop was still running"); reporter.Stop(); Assert.True(drain.IsCompleted, "Stop() returned before the drain loop exited"); } + private sealed class SignalingAlertClient : ICrowdSecAlertClient + { + public TaskCompletionSource Posted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) + { + Posted.TrySetResult(); + return ValueTask.CompletedTask; + } + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => + ValueTask.CompletedTask; + + public void Dispose() { } + } + private sealed class NullAlertClient : ICrowdSecAlertClient { public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs index 01691e223..c5617c0d3 100644 --- a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs @@ -21,8 +21,11 @@ using Xunit; namespace Server.Tests.Network.Firewall; [Collection("Sequential UOContent Tests")] -public class FirewallPersistenceTests +public class FirewallPersistenceTests : IDisposable { + // The test host never configures the firewall, so empty is the state every other test expects. + public void Dispose() => Server.Network.Firewall.ResetForTesting(); + [Fact] public void ToSettings_RoundTrips_PermanentAndTtl() { diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs index fabf6d4ba..7dba7508c 100644 --- a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs @@ -21,8 +21,11 @@ using Xunit; namespace Server.Tests.Network.Firewall; [Collection("Sequential UOContent Tests")] -public class FirewallTests +public class FirewallTests : IDisposable { + // The test host never configures the firewall, so empty is the state every other test expects. + public void Dispose() => Server.Network.Firewall.ResetForTesting(); + private static IPAddress Ip(string s) => IPAddress.Parse(s); [Fact] @@ -76,14 +79,4 @@ public class FirewallTests Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0-10.0.0.255")); Assert.Null(Server.Network.Firewall.ToFirewallEntry("not-an-ip")); } - - [Fact] - public void ReadFirewallSet_SurfacesAddedEntries() - { - Server.Network.Firewall.ResetForTesting(); - var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); - Server.Network.Firewall.Add(entry); - - Server.Network.Firewall.ReadFirewallSet(set => Assert.Contains(entry, set)); - } } diff --git a/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs index 7f3780e58..b48933ec4 100644 --- a/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs @@ -13,17 +13,74 @@ * along with this program. If not, see . * *************************************************************************/ +using System; +using System.Collections; +using System.Collections.Generic; using System.Net; +using System.Reflection; using Server.Network; using Server.Network.Bans; using Xunit; namespace Server.Tests.Network.LoginAllowlists; -// The list is static, so every test resets it first. Addresses come from TEST-NET-3 (203.0.113.0/24) so they -// cannot collide with the blocklist tests if xUnit runs the two classes at the same time. -public class LoginAllowlistTests +// The list is static and the login path and BlocklistFilter both read it, so every test resets it first, +// the class runs in the sequential collection, and Dispose puts the original state back. Addresses come +// from TEST-NET-3 (203.0.113.0/24). +[Collection("Sequential UOContent Tests")] +public class LoginAllowlistTests : IDisposable { + private readonly List<(FieldInfo Field, object Value, DictionaryEntry[] Entries)> _saved = []; + + public LoginAllowlistTests() + { + var fields = typeof(LoginAllowlist).GetFields(BindingFlags.Static | BindingFlags.NonPublic); + for (var i = 0; i < fields.Length; i++) + { + var field = fields[i]; + if (field.IsLiteral) + { + continue; + } + + var value = field.GetValue(null); + + // Readonly tables cannot be reassigned, so their contents are copied and refilled instead. + DictionaryEntry[] entries = null; + if (field.IsInitOnly && value is IDictionary table) + { + entries = new DictionaryEntry[table.Count]; + table.CopyTo(entries, 0); + } + else if (field.IsInitOnly) + { + continue; + } + + _saved.Add((field, value, entries)); + } + } + + public void Dispose() + { + for (var i = 0; i < _saved.Count; i++) + { + var (field, value, entries) = _saved[i]; + if (entries == null) + { + field.SetValue(null, value); + continue; + } + + var table = (IDictionary)value; + table.Clear(); + for (var j = 0; j < entries.Length; j++) + { + table.Add(entries[j].Key, entries[j].Value); + } + } + } + private const long Ttl = 90 * 24 * 60 * 60; // 90 days, the shipped default private const long Window = 3600; private const long Now = 1_800_000_000; diff --git a/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs index 1146414b5..0687c5dce 100644 --- a/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs @@ -50,12 +50,6 @@ public class ManualAllowlistConfigurationTests } // The point of the flag: a shard that never opts in must not start the reload poll. - [Fact] - public void Manual_allowlist_is_off_by_default() - { - Assert.False(new ManualAllowlistSettings().Enabled); - } - // The generator creates ip-allowlist.txt beside the blocklist; the wildcard is what picks up a // carve-out file (-RefreshCarveouts writes ip-allowlist-starlink.txt) with no config edit. [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs index 456eb174a..062fba315 100644 --- a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using Server.Accounting; using Server.Accounting.Security; @@ -22,14 +23,43 @@ public class AuthIdTests : IDisposable IncomingAccountPackets.ClearAuthIdWindow(); } + // The constructor registers each account in the process-wide Accounts table. + private readonly List _created = []; + public void Dispose() { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + IncomingAccountPackets.ClearAuthIdWindow(); AccountSecurity.CurrentAlgorithm = _originalAlgorithm; } - private static IAccount CreateAccount(string username) => - Accounts.GetAccount(username) ?? new Account(username, "hunter2"); + // Auth ids never read the password, so these accounts skip the Argon2 cost. + private IAccount CreateAccount(string username) + { + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.SHA2; + try + { + return TrackNewAccount(username); + } + finally + { + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + } + } + + // The game-login tests run the real password check, so they keep the configured algorithm. + private IAccount CreateArgon2Account(string username) => TrackNewAccount(username); + + private Account TrackNewAccount(string username) + { + var account = new Account(username, "hunter2"); + _created.Add(account); + return account; + } private static int Register(IAccount account, IPAddress address) => IncomingAccountPackets.RegisterAuthId(account, address, new ClientVersion(7, 0, 0, 0)); @@ -255,17 +285,6 @@ public class AuthIdTests : IDisposable new ClientVersion(7, 0, 0, 0) ); - [Fact] - public void IssuesAnIdWhenTheConnectionHasNone() - { - var account = CreateAccount("authid-first-select-user"); - - var authId = Ensure(0, account, AddressX); - - Assert.NotEqual(0, authId); - Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); - } - // Handing the same id back rather than minting another is what makes an orphan impossible, // instead of something to clean up afterwards. [Fact] @@ -347,7 +366,7 @@ public class AuthIdTests : IDisposable [Fact] public void PreAuthenticatedGameLogin_SkipsThePasswordCheck() { - var account = CreateAccount("authid-preauth-user"); + var account = CreateArgon2Account("authid-preauth-user"); using var ns = PacketTestUtilities.CreateTestNetState(); // A wrong password is accepted only because the auth id already vouched for the account. @@ -360,7 +379,7 @@ public class AuthIdTests : IDisposable [Fact] public void GameLoginWithoutPreAuthentication_StillChecksThePassword() { - var account = CreateAccount("authid-nopreauth-user"); + var account = CreateArgon2Account("authid-nopreauth-user"); using var ns = PacketTestUtilities.CreateTestNetState(); var wrong = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", false); @@ -371,12 +390,4 @@ public class AuthIdTests : IDisposable GameServer.GameServerLoginEvent(right); Assert.True(right.Accepted); } - - [Fact] - public void GeneratesDistinctAuthIds() - { - var account = CreateAccount("authid-distinct-user"); - - Assert.NotEqual(Register(account, AddressX), Register(account, AddressX)); - } } diff --git a/Projects/UOContent.Tests/Tests/Skills/DetectHiddenTests.cs b/Projects/UOContent.Tests/Tests/Skills/DetectHiddenTests.cs index bc416d148..e7a3f1d47 100644 --- a/Projects/UOContent.Tests/Tests/Skills/DetectHiddenTests.cs +++ b/Projects/UOContent.Tests/Tests/Skills/DetectHiddenTests.cs @@ -141,68 +141,6 @@ public class DetectHiddenTests } } - /// - /// TryDetectStealther is a no-op when the target is not actually hidden. - /// - [Fact] - public void TryDetectStealther_DoesNothing_WhenStealtherIsNotHidden() - { - using var rng = new PredictableRandom(10); - DetectHidden.ClearDebounceCache(); - var map = Map.Felucca; - var detector = CreatePlayerMobile(map, new Point3D(1800, 500, 0)); - var stealther = CreatePlayerMobile(map, new Point3D(1801, 500, 0)); - - try - { - detector.Skills.DetectHidden.BaseFixedPoint = 1000; - stealther.Hidden = false; - - var result = DetectHidden.TryDetectStealther(detector, stealther); - - Assert.False(result); - Assert.False(stealther.Hidden); - } - finally - { - detector.Delete(); - stealther.Delete(); - DetectHidden.ClearDebounceCache(); - } - } - - /// - /// Passive detection only works on Felucca. A stealther on Trammel - /// should never be revealed by passive detection. - /// - [Fact] - public void TryDetectStealther_DoesNotReveal_WhenNotOnFelucca() - { - using var rng = new PredictableRandom(10); - DetectHidden.ClearDebounceCache(); - var map = Map.Trammel; - var detector = CreatePlayerMobile(map, new Point3D(2000, 500, 0)); - var stealther = CreatePlayerMobile(map, new Point3D(2001, 500, 0)); - - try - { - detector.Skills.DetectHidden.BaseFixedPoint = 1000; // 100.0 - stealther.Skills.Hiding.BaseFixedPoint = 0; // 0.0 - stealther.Hidden = true; - - var result = DetectHidden.TryDetectStealther(detector, stealther); - - Assert.False(result); - Assert.True(stealther.Hidden); - } - finally - { - detector.Delete(); - stealther.Delete(); - DetectHidden.ClearDebounceCache(); - } - } - private static PlayerMobile CreatePlayerMobile(Map map, Point3D location) { var mobile = new PlayerMobile(World.NewMobile); diff --git a/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs b/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs index 91fba77bc..e94e16753 100644 --- a/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs +++ b/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs @@ -81,44 +81,4 @@ public class SkillEventsTests from.Delete(); } } - - [Fact] - public void CheckSkill_Direct_DoesNotRaise() - { - var from = new Mobile(); - var skill = from.Skills[SkillName.Mining]; - var recorder = new Recorder(); - - SkillEvents.SkillUsed += recorder.Handle; - try - { - SkillCheck.CheckSkill(from, skill, null, 1.0); - Assert.Equal(0, recorder.Calls); - } - finally - { - SkillEvents.SkillUsed -= recorder.Handle; - from.Delete(); - } - } - - [Fact] - public void NoSubscriber_DoesNotThrow() - { - var from = new Mobile(); - var recorder = new Recorder(); - - try - { - SkillEvents.SkillUsed += recorder.Handle; - SkillEvents.SkillUsed -= recorder.Handle; - - Assert.True(SkillCheck.Mobile_SkillCheckDirectLocation(from, SkillName.Mining, 1.0)); - Assert.Equal(0, recorder.Calls); - } - finally - { - from.Delete(); - } - } } diff --git a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs index 08658b1a1..fb2ee2f8d 100644 --- a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs +++ b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs @@ -19,24 +19,8 @@ public class BloodOathSpellTests // ModernUO previously used /80 (matching the bugged in-game tooltip), which made // Spirit Speak almost irrelevant to duration. RunUO/ServUO and the code's own // fixed-point comment both use /8. - [Theory] - [InlineData(120.0, 0.0, 23.0)] // GM Spirit Speak, no resist - [InlineData(120.0, 120.0, 8.0)] // equal skills -> baseline 8s - [InlineData(100.0, 20.0, 18.0)] // (100-20)/8 + 8 - [InlineData(20.0, 0.0, 10.5)] // minimum skill - public void GetDurationSeconds_UsesDivideByEight(double ss, double resist, double expected) - { - Assert.Equal(expected, BloodOathSpell.GetDurationSeconds(ss, resist), 3); - } - // Player caster: Publish 48 resist mitigation does NOT apply; the attacker takes the // full reflected (original, un-bonused) damage. - [Fact] - public void ComputeReflectedDamage_NoMitigation_ReturnsOriginal() - { - Assert.Equal(40, BloodOathSpell.ComputeReflectedDamage(40, 120.0, applyResistMitigation: false)); - } - // Creature caster (Publish 48 / SA+): ((Resist * 10) / 20) + 10 = % of reflected damage resisted. [Theory] [InlineData(40, 0.0, 36)] // 10% resisted -> 40 * 0.90 @@ -82,14 +66,6 @@ public class BloodOathSpellTests target.Delete(); } - [Fact] - public void RemoveCurse_NotCursed_ReturnsFalse() - { - var m = NewMobile(); - Assert.False(BloodOathSpell.RemoveCurse(m)); - m.Delete(); - } - // Issue #1690: death/delete of either party must break the oath immediately. The oath is wired // to the central PlayerMobile/BaseCreature death+delete events instead of a polling timer. [Fact] diff --git a/Projects/UOContent.Tests/Tests/Spells/Ninjitsu/AnimalFormTests.cs b/Projects/UOContent.Tests/Tests/Spells/Ninjitsu/AnimalFormTests.cs index 34a665495..133238ff1 100644 --- a/Projects/UOContent.Tests/Tests/Spells/Ninjitsu/AnimalFormTests.cs +++ b/Projects/UOContent.Tests/Tests/Spells/Ninjitsu/AnimalFormTests.cs @@ -44,17 +44,6 @@ public class AnimalFormTests } // Talisman-gated forms are never selectable without the matching talisman, regardless of skill. - [Fact] - public void CanSelectEntry_TalismanGatedForm_RequiresTalisman() - { - var m = NewMobileWithNinjitsu(120.0); - - Assert.False(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Squirrel))])); - Assert.False(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Ferret))])); - - m.Delete(); - } - // Issue #2452: attempting a form above your skill must report NoSkill (so the caller can // refrain from charging mana) and must not actually transform the caster. [Fact] @@ -70,19 +59,4 @@ public class AnimalFormTests m.Delete(); } - - [Fact] - public void Morph_SufficientSkill_TransformsCaster() - { - // >= ReqSkill + 37.5 guarantees the success-chance roll is skipped. - var m = NewMobileWithNinjitsu(60.0); - - var result = AnimalForm.Morph(m, IndexOf(typeof(Rat))); // needs 20 - - Assert.Equal(AnimalForm.MorphResult.Success, result); - Assert.NotNull(AnimalForm.GetContext(m)); - - AnimalForm.RemoveContext(m); // stop the form timer / clear state - m.Delete(); - } } diff --git a/Projects/UOContent.Tests/Tests/Terrain/TerrainScopeCacheTests.cs b/Projects/UOContent.Tests/Tests/Terrain/TerrainScopeCacheTests.cs new file mode 100644 index 000000000..d4db67c68 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Terrain/TerrainScopeCacheTests.cs @@ -0,0 +1,54 @@ +using System; +using Server.Engines.Pathing.Cache; +using Server.Tests.Terrain; +using Xunit; + +namespace Server.Tests.Pathfinding; + +[Collection("Sequential Pathfinding Tests")] +public class TerrainScopeCacheTests : IDisposable +{ + // Default ground, clear of every prefab. + private const int X = 1300; + private const int Y = 1300; + + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); + + [Fact] + public void TerrainStampedInAScope_IsNotServedStaleFromTheStepCache() + { + var cache = StepCache.Instance; + cache.MissPromotionThreshold = 1; + var map = Map.Trammel; + + var open = cache.TryGetMask(map, X, Y, sourceZ: 0); + Assert.True(open.IsHit); + Assert.True(open.IsWalkable(Direction.North)); + + using (TestTerrain.Scope()) + { + TestTerrain.AddStatic(X, Y - 1, TestTiles.Wall, 0); + + Assert.False(cache.TryGetMask(map, X, Y, sourceZ: 0).IsWalkable(Direction.North)); + } + + Assert.True(cache.TryGetMask(map, X, Y, sourceZ: 0).IsWalkable(Direction.North)); + } + + [Fact] + public void TerrainStampedAfterAQueryInsideAScope_IsNotServedStale() + { + var cache = StepCache.Instance; + cache.MissPromotionThreshold = 1; + var map = Map.Trammel; + + using var scope = TestTerrain.Scope(); + Assert.True(cache.TryGetMask(map, X, Y, sourceZ: 0).IsWalkable(Direction.North)); + + TestTerrain.AddStatic(X, Y - 1, TestTiles.Wall, 0); + + Assert.False(cache.TryGetMask(map, X, Y, sourceZ: 0).IsWalkable(Direction.North)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs b/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs index b6c693c84..4167e3d06 100644 --- a/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs +++ b/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs @@ -5,21 +5,6 @@ namespace Server.Tests.Utility; public class TryParseTests { - [Theory] - [InlineData("True", null, true)] - [InlineData("False", null, false)] - [InlineData("Alakazam", "Not a valid boolean string.", true)] - public void TestTryParseBool(string value, string returned, bool parsedAs) - { - var actualReturned = Server.Types.TryParse(typeof(bool), value, out var constructed); - Assert.Equal(returned, actualReturned); - - if (returned == null) - { - Assert.Equal(parsedAs, constructed); - } - } - [Theory] // Parsed directly into the target type (INumber.TryParse), not via ulong + Convert.ChangeType. [InlineData(typeof(int), "42", true, 42)] diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs index ede98a27f..14db8d19d 100644 --- a/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs +++ b/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Reflection; using Server.Saves; using Xunit; @@ -9,34 +10,38 @@ namespace Server.Tests; [Collection("Sequential UOContent Tests")] public class ArchiveJournalTests : IDisposable { + private static readonly FieldInfo _dataField = + typeof(ArchiveJournal).GetField("_data", BindingFlags.Static | BindingFlags.NonPublic); + + private static readonly FieldInfo _journalPathField = + typeof(ArchiveJournal).GetField("_journalPath", BindingFlags.Static | BindingFlags.NonPublic); + + private readonly object _savedData; + private readonly object _savedJournalPath; private readonly string _testDir; public ArchiveJournalTests() { + // Configure() repoints the process-wide journal at the temp dir; restore it so no later + // caller writes into a deleted directory. + _savedData = _dataField.GetValue(null); + _savedJournalPath = _journalPathField.GetValue(null); + _testDir = Path.Combine(Path.GetTempPath(), $"modernuo-journal-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(_testDir); } public void Dispose() { + _dataField.SetValue(null, _savedData); + _journalPathField.SetValue(null, _savedJournalPath); + if (Directory.Exists(_testDir)) { Directory.Delete(_testDir, true); } } - [Fact] - public void Configure_LoadsCleanState() - { - // Arrange & Act - ArchiveJournal.Configure(_testDir); - - // Assert - fresh directory should have no operations - // Note: ArchiveJournal is static, so we check the journal file doesn't exist - var journalPath = Path.Combine(_testDir, ".archive-journal.json"); - Assert.False(File.Exists(journalPath)); - } - [Fact] public void BeginOperation_CreatesStartedEntry() { diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/AutoArchiveHelperTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/AutoArchiveHelperTests.cs deleted file mode 100644 index b0d2b99d4..000000000 --- a/Projects/UOContent.Tests/Tests/WorldSaves/AutoArchiveHelperTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using Server.Saves; -using Xunit; - -namespace Server.Tests; - -[Collection("Sequential UOContent Tests")] -public class AutoArchiveHelperTests -{ - [Fact] - public void ArchiveDestinationRegistry_RegisterAndRetrieve() - { - // Arrange - var dest = new LocalArchiveDestination(24, 30, 12); - - // Act - ArchiveDestinationRegistry.Register(dest); - - // Assert - Assert.Contains(dest, ArchiveDestinationRegistry.Destinations); - - // Cleanup - ArchiveDestinationRegistry.Unregister(dest); - Assert.DoesNotContain(dest, ArchiveDestinationRegistry.Destinations); - } - - [Fact] - public void LocalArchiveDestination_SendArchive_ReturnsTrue() - { - // Arrange - var dest = new LocalArchiveDestination(24, 30, 12); - - // Act - var result = dest.SendArchive("/fake/path.tar.zst", ArchivePeriod.Hourly, DateTime.UtcNow); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData(ArchivePeriod.Hourly, 24)] - [InlineData(ArchivePeriod.Daily, 30)] - [InlineData(ArchivePeriod.Monthly, 12)] - public void LocalArchiveDestination_RetentionCounts(ArchivePeriod period, int expected) - { - // Arrange - var dest = new LocalArchiveDestination(24, 30, 12); - - // Act & Assert - Assert.Equal(expected, dest.GetRetentionCount(period)); - } - - [Fact] - public void ArchiveCompletedEventArgs_StoresValues() - { - // Arrange & Act - var args = new ArchiveCompletedEventArgs( - "/archives/hourly/test.tar.zst", - ArchivePeriod.Hourly, - new DateTime(2026, 3, 22, 14, 0, 0, DateTimeKind.Utc), - 1048576, - 2.5 - ); - - // Assert - Assert.Equal("/archives/hourly/test.tar.zst", args.ArchiveFilePath); - Assert.Equal(ArchivePeriod.Hourly, args.Period); - Assert.Equal(1048576, args.FileSizeBytes); - Assert.Equal(2.5, args.ElapsedSeconds); - } - - [Fact] - public void ArchiveFailedEventArgs_StoresException() - { - // Arrange - var ex = new InvalidOperationException("disk full"); - - // Act - var args = new ArchiveFailedEventArgs( - ArchivePeriod.Daily, - new DateTime(2026, 3, 22, 0, 0, 0, DateTimeKind.Utc), - ex - ); - - // Assert - Assert.Equal(ArchivePeriod.Daily, args.Period); - Assert.Same(ex, args.Exception); - } - - [Fact] - public void ArchiveJournalEntry_DefaultState() - { - // Arrange & Act - var entry = new ArchiveJournalEntry(); - - // Assert - Assert.NotNull(entry.SourceDirectories); - Assert.Empty(entry.SourceDirectories); - Assert.NotNull(entry.DestinationResults); - Assert.Empty(entry.DestinationResults); - Assert.Null(entry.FailureReason); - Assert.Equal(DateTime.MaxValue, entry.CompletedAt); - } -} diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs index b5a2c0dc9..11c72458d 100644 --- a/Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs +++ b/Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs @@ -46,29 +46,6 @@ public class ManagedArchiveTests : IDisposable return dir; } - [Fact] - public void CreateTarZstd_SingleDirectory_RoundTrips() - { - // Arrange - var sourceDir = CreateTestDirectory("source", - ("file1.txt", "Hello World"), - ("file2.bin", "Binary content here"), - ("subdir/nested.txt", "Nested file") - ); - - var archivePath = Path.Combine(_testDir, "test.tar.zst"); - - // Act - var entryCount = ManagedArchive.CreateTarZstd( - [sourceDir], archivePath, _testDir, compressionLevel: 1 - ); - - // Assert - Assert.True(entryCount > 0); - Assert.True(File.Exists(archivePath)); - Assert.True(new FileInfo(archivePath).Length > 0); - } - [Fact] public void CreateTarZstd_MultipleDirectories_IncludesAll() { @@ -224,30 +201,6 @@ public class ManagedArchiveTests : IDisposable Assert.Equal(-1, count); } - [Theory] - [InlineData(1)] - [InlineData(3)] - [InlineData(10)] - public void CreateTarZstd_DifferentCompressionLevels_AllWork(int level) - { - // Arrange - var sourceDir = CreateTestDirectory($"level-{level}", ("data.txt", new string('X', 10000))); - var archivePath = Path.Combine(_testDir, $"level-{level}.tar.zst"); - - // Act - var entryCount = ManagedArchive.CreateTarZstd( - [sourceDir], archivePath, _testDir, compressionLevel: level - ); - - // Assert - Assert.Equal(1, entryCount); - Assert.True(File.Exists(archivePath)); - - // Verify we can extract it back - var extractDir = Path.Combine(_testDir, $"extract-level-{level}"); - Assert.True(ManagedArchive.ExtractTarZstd(archivePath, extractDir)); - } - [Fact] public void ExtractTarGz_RoundTrips() { @@ -311,6 +264,50 @@ public class ManagedArchiveTests : IDisposable Assert.Equal("Plain tar test data", File.ReadAllText(Path.Combine(extractDir, "plain.txt"))); } + [Theory] + [InlineData("../escaped.txt")] + [InlineData("sub/../../escaped.txt")] + public void ExtractTar_EntryOutsideOutputDirectory_IsRejected(string entryName) + { + var archivePath = Path.Combine(_testDir, "traversal.tar"); + + using (var fileStream = new FileStream(archivePath, FileMode.Create)) + using (var tarWriter = new TarWriter(fileStream, TarEntryFormat.Pax, leaveOpen: true)) + { + var entry = new PaxTarEntry(TarEntryType.RegularFile, entryName) + { + DataStream = new MemoryStream("escaped"u8.ToArray()) + }; + tarWriter.WriteEntry(entry); + } + + var extractDir = Path.Combine(_testDir, "out", "extract"); + + Assert.False(ManagedArchive.ExtractTar(archivePath, extractDir)); + Assert.False(File.Exists(Path.Combine(_testDir, "out", "escaped.txt"))); + Assert.False(File.Exists(Path.Combine(_testDir, "escaped.txt"))); + } + + [Fact] + public void ExtractTar_RootedEntry_IsRejected() + { + var archivePath = Path.Combine(_testDir, "rooted.tar"); + var target = Path.Combine(_testDir, "rooted-escaped.txt"); + + using (var fileStream = new FileStream(archivePath, FileMode.Create)) + using (var tarWriter = new TarWriter(fileStream, TarEntryFormat.Pax, leaveOpen: true)) + { + var entry = new PaxTarEntry(TarEntryType.RegularFile, target) + { + DataStream = new MemoryStream("escaped"u8.ToArray()) + }; + tarWriter.WriteEntry(entry); + } + + Assert.False(ManagedArchive.ExtractTar(archivePath, Path.Combine(_testDir, "rooted-extract"))); + Assert.False(File.Exists(target)); + } + [Fact] public void ExtractTarGz_InvalidFile_ReturnsFalse() { diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 8b83f7992..1d7f20e68 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,14 +4,14 @@ Debug;Release;Analyze - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -24,4 +24,8 @@ + + + + diff --git a/Projects/UOContent/Compression/ManagedArchive.cs b/Projects/UOContent/Compression/ManagedArchive.cs index 32142d9e4..4b7e9da2b 100644 --- a/Projects/UOContent/Compression/ManagedArchive.cs +++ b/Projects/UOContent/Compression/ManagedArchive.cs @@ -224,9 +224,17 @@ public static class ManagedArchive { using var tarReader = new TarReader(stream); + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(outputDirectory)) + Path.DirectorySeparatorChar; + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + while (tarReader.GetNextEntry() is { } entry) { - var destPath = Path.Combine(outputDirectory, entry.Name); + // Rooted names and ".." segments would otherwise let the archive write anywhere the process can. + var destPath = Path.GetFullPath(Path.Combine(root, entry.Name)); + if (!destPath.StartsWith(root, comparison)) + { + throw new InvalidDataException($"Archive entry '{entry.Name}' resolves outside '{root}'"); + } var destDir = Path.GetDirectoryName(destPath); if (destDir != null) diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index 3a024576b..a5d457e2f 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -28,7 +28,7 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(11, false)] +[SerializationGenerator(12, false)] public partial class ChampionSpawn : Item { private void MigrateFrom(V10Content content) @@ -59,6 +59,35 @@ public partial class ChampionSpawn : Item _restartTime = content.RestartTime; } + private void MigrateFrom(V11Content content) + { + _level = content.Level; + _activatedByProximity = content.ActivatedByProximity; + _nextProximityTime = content.NextProximityTime; + _maxLevel = content.MaxLevel; + _activatedByValor = content.ActivatedByValor; + _damageEntries = content.DamageEntries; + _confinedRoaming = content.ConfinedRoaming; + _idol = content.Idol; + _hasBeenAdvanced = content.HasBeenAdvanced; + _spawnArea = content.SpawnArea; + _randomizeType = content.RandomizeType; + _kills = content.Kills; + _active = content.Active; + _type = content.Type; + _creatures = content.Creatures; + _redSkulls = content.RedSkulls; + _whiteSkulls = content.WhiteSkulls; + _platform = content.Platform; + _altar = content.Altar; + _expireDelay = content.ExpireDelay; + _expireTime = content.ExpireTime; + _champion = content.Champion; + _restartDelay = content.RestartDelay; + _restartTime = content.RestartTime; + _minions = []; + } + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn)); [SerializableField(1)] @@ -143,6 +172,12 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; + // Mobiles summoned by the champion or the spawn's creatures. Deleted with the creatures, never counted as kills. + [Tidy] + [SerializableField(24)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private List _minions; + private ChampionSpawnRegion m_Region; //Goes back each level, below level 0 and it goes off! @@ -173,6 +208,7 @@ public partial class ChampionSpawn : Item Visible = false; _creatures = new List(); + _minions = []; _redSkulls = new List(); _whiteSkulls = new List(); @@ -294,6 +330,21 @@ public partial class ChampionSpawn : Item public bool IsChampionSpawn(Mobile m) => _creatures.Contains(m); + /// + /// The spawn fights for: as its champion, one of its creatures, or a minion of either. + /// + public static ChampionSpawn FindSpawnOf(BaseCreature creature) + { + var spawn = Region.Find(creature.Location, creature.Map).GetRegion()?.Spawn; + + return spawn?.Deleted == false && + (spawn.Champion == creature || spawn._minions.Contains(creature) || spawn._creatures.Contains(creature)) + ? spawn + : null; + } + + public void AddMinion(Mobile minion) => AddToMinions(minion); + [CommandProperty(AccessLevel.GameMaster)] public virtual int MaxKills { @@ -1171,6 +1222,13 @@ public partial class ChampionSpawn : Item ClearCreatures(); } + + for (var i = 0; i < _minions.Count; ++i) + { + _minions[i].Delete(); + } + + ClearMinions(); } public virtual void RegisterDamageTo(Mobile m) @@ -1321,6 +1379,7 @@ public partial class ChampionSpawn : Item _active = reader.ReadBool(); _type = (ChampionSpawnType)reader.ReadInt(); _creatures = reader.ReadEntityList(); + _minions = []; _redSkulls = reader.ReadEntityList(); _whiteSkulls = reader.ReadEntityList(); _platform = reader.ReadEntity(); diff --git a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs index 5efb22784..a2ed9e045 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs @@ -663,15 +663,7 @@ namespace Server.Engines.Craft } // ****************** - for (var j = 0; types[i] == null && j < m_TypesTable.Length; ++j) - { - if (m_TypesTable[j][0] == baseType) - { - types[i] = m_TypesTable[j]; - } - } - - types[i] ??= [baseType]; + types[i] = GetEquivalentTypes(baseType) ?? [baseType]; amounts[i] = craftRes.Amount; // For stackable items that can be crafted more than one at a time @@ -1271,13 +1263,54 @@ namespace Server.Engines.Craft return true; } - private static int GetHuedAmount(Container pack, Type type, int hue) + /// + /// Returns the interchangeable resource types for (e.g. logs and boards), + /// or null when it has no equivalents. + /// + internal static Type[] GetEquivalentTypes(Type baseType) + { + for (var i = 0; i < m_TypesTable.Length; i++) + { + if (m_TypesTable[i][0] == baseType) + { + return m_TypesTable[i]; + } + } + + return null; + } + + private static bool IsHuedMatch(Item item, Type type, Type[] equivalents, int hue) + { + if (item.Hue != hue) + { + return false; + } + + if (equivalents == null) + { + return type.IsInstanceOfType(item); + } + + for (var i = 0; i < equivalents.Length; i++) + { + if (equivalents[i].IsInstanceOfType(item)) + { + return true; + } + } + + return false; + } + + internal static int GetHuedAmount(Container pack, Type type, int hue) { var total = 0; + var equivalents = GetEquivalentTypes(type); foreach (var item in pack.FindItems(true)) { - if (item.Hue == hue && type.IsInstanceOfType(item)) + if (IsHuedMatch(item, type, equivalents, hue)) { total += item.Amount; } @@ -1289,6 +1322,7 @@ namespace Server.Engines.Craft private static bool ConsumeHuedAmount(Container pack, Type type, int hue, int amount) { var remaining = amount; + var equivalents = GetEquivalentTypes(type); using var toDelete = PooledRefList.Create(); foreach (var item in pack.FindItems(true)) @@ -1298,7 +1332,7 @@ namespace Server.Engines.Craft break; } - if (item.Hue != hue || !type.IsInstanceOfType(item)) + if (!IsHuedMatch(item, type, equivalents, hue)) { continue; } diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACooking.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACooking.cs new file mode 100644 index 000000000..e8fbb05d6 --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACooking.cs @@ -0,0 +1,211 @@ +using System; +using Server.Items; +using Server.Targeting; + +namespace Server.Engines.Craft.T2A; + +/// An item that can be double-clicked under T2A to combine with, or cook over, a target. +public interface IT2ACombinable +{ + bool TryCombine(Mobile from, object target); +} + +/// +/// Shared helpers for T2A cooking, which is item-on-item and item-on-heat-source rather than a craft menu. +/// Each ingredient item decides its own results in . +/// +public sealed class T2ACooking +{ + private const int _heatSourceRange = 1; + private const int _waterRange = 2; + private const int _burnRange = 3; + + private T2ACooking() + { + } + + /// Asks the player for a target if the item is in their pack. + public static void Prompt(Mobile from, Item source) + { + if (!ValidateInPack(from, source)) + { + return; + } + + from.SendAsciiMessage("What do you wish to combine this with, or cook it over?"); + from.Target = new CookTarget(source); + } + + public static bool ValidateInPack(Mobile from, Item item) + { + if (item is { Deleted: false } && item.IsChildOf(from.Backpack)) + { + return true; + } + + from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return false; + } + + public static bool CannotCombine(Mobile from) + { + from.SendAsciiMessage("You cannot combine those."); + return false; + } + + //TODO Support static water troughs and barrels (no static water ID list exists yet) + public static IHasQuantity GetWaterSource(object o) => + o switch + { + BaseBeverage { Content: BeverageType.Water, Quantity: > 0 } bev => bev, + BaseWaterContainer { IsEmpty: false } container => container, + AddonComponent { Addon: IWaterSource { Quantity: > 0 } trough } => trough, + IWaterSource { Quantity: > 0 } source => source, + _ => null + }; + + // Ground water only, so a nearby open container (someone else's pack) cannot be drawn from. + public static bool CanReachWater(Mobile from, Item water) + { + if (water.IsChildOf(from.Backpack) || water.Parent == from || + water.RootParent == null && water.Map == from.Map && + from.InRange(water.GetWorldLocation(), _waterRange) && from.InLOS(water)) + { + return true; + } + + from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that. + return false; + } + + /// Spends one unit of each ingredient and gives the player the result. + public static bool Combine(Mobile from, object source, object target, Item result, string message) + { + ConsumeOne(source); + ConsumeOne(target); + + if (!GiveResult(from, result)) + { + return false; + } + + from.SendAsciiMessage(message); + return true; + } + + // AddToBackpack drops a result that does not fit at the mobile's feet (or logout location), + // but with no valid map either it would be left on the internal map forever. + private static bool GiveResult(Mobile from, Item result) + { + if (from.AddToBackpack(result) || result.Map != null && result.Map != Map.Internal) + { + return true; + } + + result.Delete(); + return false; + } + + //TODO Confirm whether an empty bowl is returned when a BowlFlour is used up + private static void ConsumeOne(object o) + { + if (o is IHasQuantity container) + { + container.Quantity--; + } + else if (o is Item item) + { + item.Consume(); + } + } + + /// Cooks the source over a heat source if it is close enough and the player is not already cooking. + public static bool BeginHeat(Mobile from, Item source, object target) + { + var point = target is IPoint3D p ? new Point3D(p) : from.Location; + if (from.Map == null || !from.InRange(point, _heatSourceRange)) + { + from.SendLocalizedMessage(500446); // That is too far away. + return false; + } + + if (!from.BeginAction()) + { + from.SendLocalizedMessage(500119); // You must wait to perform another action + return false; + } + + from.PlaySound(0x225); + source.Consume(); + + Timer.DelayCall(TimeSpan.FromSeconds(5.0), FinishHeat, from, source, from.Map, point); + return true; + } + + // The source is already consumed here and may be deleted, so only its type and CookingLevel are read. + private static void FinishHeat(Mobile from, Item source, Map map, Point3D point) + { + from.EndAction(); + + if (from.Deleted) + { + return; + } + + if (from.Map != map || !from.InRange(point, _burnRange)) + { + from.SendLocalizedMessage(500686); // You burn the food to a crisp! It's ruined. + return; + } + + var (min, max) = source switch + { + CookableFood food => (food.CookingLevel, 100.0), + Dough => CraftRange(typeof(BreadLoaf)), + SweetDough => CraftRange(typeof(Muffins)), + _ => (0.0, 100.0) + }; + + if (!from.CheckSkill(SkillName.Cooking, min, max)) + { + from.SendLocalizedMessage(500686); // You burn the food to a crisp! It's ruined. + return; + } + + Item result = source switch + { + CookableFood food => food.Cook(), + Dough => new BreadLoaf(), + SweetDough => new Muffins(), + _ => null + }; + + if (result != null && GiveResult(from, result)) + { + from.PlaySound(0x57); + } + } + + // Falls back to the full range when the cooking craft system has no entry or is not initialized. + private static (double Min, double Max) CraftRange(Type type) + { + var skills = DefCooking.CraftSystem?.CraftItems.SearchFor(type)?.Skills; + + return skills is { Count: > 0 } ? (skills[0].MinSkill, skills[0].MaxSkill) : (0.0, 100.0); + } + + private sealed class CookTarget : Target + { + private readonly Item _source; + + public CookTarget(Item source) : base(_waterRange, false, TargetFlags.None) => _source = source; + + protected override void OnTarget(Mobile from, object targeted) + { + if (ValidateInPack(from, _source) && _source is IT2ACombinable combinable) + { + combinable.TryCombine(from, targeted); + } + } + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs index 785a319ad..4e50b9ce6 100644 --- a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs @@ -15,6 +15,12 @@ public static class T2ACraftSystem /// public static bool Enabled { get; set; } + public static bool HasMenu(CraftSystem system) => + system == DefBlacksmithy.CraftSystem || system == DefAlchemy.CraftSystem || + system == DefBowFletching.CraftSystem || system == DefCarpentry.CraftSystem || + system == DefCartography.CraftSystem || system == DefInscription.CraftSystem || + system == DefTailoring.CraftSystem || system == DefTinkering.CraftSystem; + public static void ShowMenu(Mobile from, CraftSystem craftSystem, BaseTool tool, Item preTarget = null) { if (!Enabled) @@ -190,9 +196,11 @@ public static class T2ACraftSystem /// Checks if a player can craft a specific item, accounting for sub-resource types. /// When is non-null, checks against that specific sub-resource. /// When null, checks against ANY available sub-resource the player has sufficient skill and materials for. + /// A non-negative counts only same-hue stock for the primary resource, exactly as + /// the hue-aware craft path consumes it. /// public static bool CanCraftItem( - Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null + Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null, int hue = -1 ) { var pack = from.Backpack; @@ -214,6 +222,27 @@ public static class T2ACraftSystem var res = itemDef.Resources[i]; var resType = res.ItemType; + if (hue >= 0 && i == 0) + { + if (resCol.Init && resType == resCol.ResType && selectedResourceType != null) + { + var subRes = resCol.SearchFor(selectedResourceType); + if (subRes != null && from.Skills[system.MainSkill].Value < subRes.RequiredSkill) + { + return false; + } + + resType = selectedResourceType; + } + + if (CraftItem.GetHuedAmount(pack, resType, hue) < res.Amount) + { + return false; + } + + continue; + } + // If this resource is the base sub-resource type (e.g. IronIngot for blacksmithing), // handle sub-resource substitution if (resCol.Init && resType == resCol.ResType) @@ -250,17 +279,20 @@ public static class T2ACraftSystem /// /// Convenience overload that resolves a Type to a CraftItem first. /// - public static bool CanCraftItem(Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null) + public static bool CanCraftItem( + Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null, int hue = -1 + ) { var itemDef = system.CraftItems.SearchFor(itemType); - return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType); + return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType, hue); } /// /// Filters static template entries to only those the player can craft. /// public static ItemListEntry[] FilterEntries( - Mobile from, ItemListEntry[] staticEntries, Type[] types, CraftSystem system, Type selectedResourceType = null + Mobile from, ItemListEntry[] staticEntries, Type[] types, CraftSystem system, + Type selectedResourceType = null, int hue = -1 ) { var filtered = new ItemListEntry[staticEntries.Length]; @@ -271,7 +303,7 @@ public static class T2ACraftSystem var entry = staticEntries[i]; var typeIndex = entry.CraftIndex; if (typeIndex >= 0 && typeIndex < types.Length && - CanCraftItem(from, types[typeIndex], system, selectedResourceType)) + CanCraftItem(from, types[typeIndex], system, selectedResourceType, hue)) { filtered[count++] = entry; } @@ -294,12 +326,12 @@ public static class T2ACraftSystem /// Returns true if at least one item in the type array is craftable. /// public static bool AnyCraftableInCategory( - Mobile from, Type[] types, CraftSystem system, Type selectedResourceType = null + Mobile from, Type[] types, CraftSystem system, Type selectedResourceType = null, int hue = -1 ) { for (var i = 0; i < types.Length; i++) { - if (CanCraftItem(from, types[i], system, selectedResourceType)) + if (CanCraftItem(from, types[i], system, selectedResourceType, hue)) { return true; } @@ -308,28 +340,11 @@ public static class T2ACraftSystem return false; } - /// - /// Equivalent type pairs mirroring CraftItem.m_TypesTable — used so that menu filtering - /// counts boards when checking for logs, hides when checking for leather, etc. - /// - private static readonly Type[][] _equivalentTypes = - [ - [typeof(Log), typeof(Board)], - [typeof(Cloth), typeof(UncutCloth)], - [typeof(Items.Leather), typeof(Hides)] - ]; - + // Shares CraftItem's equivalence table so the menu counts exactly what the craft will consume private static int GetResourceAmount(Container pack, Type type) { - for (var i = 0; i < _equivalentTypes.Length; i++) - { - if (_equivalentTypes[i][0] == type) - { - return pack.GetAmount(_equivalentTypes[i]); - } - } - - return pack.GetAmount(type); + var equivalents = CraftItem.GetEquivalentTypes(type); + return equivalents != null ? pack.GetAmount(equivalents) : pack.GetAmount(type); } private static bool HasAnySufficientSubResource( diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs index d7398f000..80978103c 100644 --- a/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs @@ -36,6 +36,13 @@ public class T2ACraftToolTarget : Target var resIndex = lastMade.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; var type = resIndex > -1 ? res[resIndex].ItemType : null; + // T2A tinkering has no colored ingots; a stale index from the gump flow must not leak in + if (_system == DefTinkering.CraftSystem) + { + type = typeof(IronIngot); + T2ACraftSystem.SetLastResourceIndex(from, _system, type); + } + // Jewelry requires gem targeting — re-prompt instead of crafting directly if (typeof(BaseJewel).IsAssignableFrom(lastMade.ItemType)) { diff --git a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs index a7a5feccc..704845c39 100644 --- a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs +++ b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs @@ -92,7 +92,7 @@ public class TailoringMenu : ItemListMenu }; private TailoringMenu(Mobile from, BaseTool tool, Category category, int hue = -1) - : base(GetQuestion(category), BuildFilteredEntries(from, category)) + : base(GetQuestion(category), BuildFilteredEntries(from, category, hue)) { _tool = tool; _category = category; @@ -198,11 +198,11 @@ public class TailoringMenu : ItemListMenu public static ItemListEntry[] Studded() => _studdedEntries ??= BuildStaticEntries(StuddedArmorTypes, "leather"); public static ItemListEntry[] Female() => _femaleEntries ??= BuildStaticEntries(FemaleArmorTypes, "leather"); - private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category, int hue) { if (category is Category.Main or Category.LeatherMain) { - return BuildFilteredMainEntries(from, category); + return BuildFilteredMainEntries(from, category, hue); } var types = GetTypes(category); @@ -212,10 +212,10 @@ public class TailoringMenu : ItemListMenu return []; } - return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem); + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem, null, hue); } - private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory) + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory, int hue) { var system = DefTailoring.CraftSystem; var mainStatic = GetStaticEntries(mainCategory); @@ -231,7 +231,7 @@ public class TailoringMenu : ItemListMenu { var entry = mainStatic[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, null, hue)) { filtered[count++] = entry; } diff --git a/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs index e823d68ee..766b6a545 100644 --- a/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs +++ b/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs @@ -117,7 +117,7 @@ public class TinkeringMenu : ItemListMenu }; private TinkeringMenu(Mobile from, BaseTool tool, Category category, Type selectedResourceType) - : base(GetQuestion(category), BuildFilteredEntries(from, category)) + : base(GetQuestion(category), BuildFilteredEntries(from, category, selectedResourceType)) { _tool = tool; _category = category; @@ -224,16 +224,16 @@ public class TinkeringMenu : ItemListMenu public static ItemListEntry[] Rings() => _ringEntries ??= BuildStaticEntries(RingTypes, "ingots"); public static ItemListEntry[] KegItems() => _kegEntries ??= BuildStaticEntries(KegItemTypes, "kegs"); - private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category, Type selectedResourceType) { if (category == Category.Main) { - return BuildFilteredMainEntries(from); + return BuildFilteredMainEntries(from, selectedResourceType); } if (category == Category.Jewelry) { - return BuildFilteredJewelryEntries(from); + return BuildFilteredJewelryEntries(from, selectedResourceType); } var types = GetTypes(category); @@ -243,7 +243,7 @@ public class TinkeringMenu : ItemListMenu return []; } - return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem); + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem, selectedResourceType); } private static readonly ItemListEntry[] JewelrySubcategoryEntries = @@ -253,7 +253,7 @@ public class TinkeringMenu : ItemListMenu new("Rings", 0x108a, 0, (int)Category.Rings) ]; - private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from) + private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from, Type selectedResourceType) { var system = DefTinkering.CraftSystem; var filtered = new ItemListEntry[JewelrySubcategoryEntries.Length]; @@ -263,7 +263,7 @@ public class TinkeringMenu : ItemListMenu { var entry = JewelrySubcategoryEntries[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResourceType)) { filtered[count++] = entry; } @@ -282,7 +282,7 @@ public class TinkeringMenu : ItemListMenu return filtered; } - private static ItemListEntry[] BuildFilteredMainEntries(Mobile from) + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Type selectedResourceType) { var system = DefTinkering.CraftSystem; var mainStatic = Main(); @@ -293,7 +293,7 @@ public class TinkeringMenu : ItemListMenu { var entry = mainStatic[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResourceType)) { filtered[count++] = entry; } @@ -407,9 +407,17 @@ public class TinkeringMenu : ItemListMenu return true; } - if (targeted is BaseIngot) + if (targeted is BaseIngot and not IronIngot) { - var menu = new TinkeringMenu(from, tool, Category.Main, targeted.GetType()); + // T2A tinkering predates colored ingots; metal items are always made from iron. + from.SendAsciiMessage("Tinkering can only use iron ingots. Please select iron ingots or wood."); + from.Target = new ResourceSelectTarget(from, tool); + return true; + } + + if (targeted is IronIngot) + { + var menu = new TinkeringMenu(from, tool, Category.Main, typeof(IronIngot)); if (menu.Entries.Length == 0) { from.SendAsciiMessage("You lack the skill and materials to craft anything."); diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs index b45c44b72..2eb04702d 100644 --- a/Projects/UOContent/Engines/Harvest/Fishing.cs +++ b/Projects/UOContent/Engines/Harvest/Fishing.cs @@ -10,61 +10,77 @@ namespace Server.Engines.Harvest { private static Fishing _system; + private static readonly Type[] _magicFish = + [ + typeof(PrizedFish), typeof(WondrousFish), typeof(TrulyRareFish), typeof(PeculiarFish) + ]; + + private static readonly Type[] _footwear = [typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)]; + private static readonly MutateEntry[] _mutateTable = - { + [ new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(SpecialFishingNet)), new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(BigFish)), - new(90.0, 80.0, 4080.0, true, Expansion.T2A, typeof(TreasureMap)), - new(100.0, 80.0, 4080.0, true, Expansion.T2A, typeof(MessageInABottle)), - new( - 0.0, - 125.0, - -2375.0, - false, - Expansion.T2A, - typeof(PrizedFish), - typeof(WondrousFish), - typeof(TrulyRareFish), - typeof(PeculiarFish) - ), - new(0.0, 105.0, -420.0, false, Expansion.T2A, typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)), - new(0.0, 200.0, -200.0, false, Expansion.None, new Type[] { null }) - }; + // T2A-UOTD: needs 90; 0.25% at 90, 0.5% at 100. + // LBR onward: needs 90; 0.42% at 90, 0.83% at 100. + new(90.0, 80.0, 4080.0, true, Expansion.T2A, Expansion.UOTD, typeof(TreasureMap)), + new(90.0, 80.0, 2480.0, true, Expansion.LBR, typeof(TreasureMap)), + // T2A-UOTD: needs 100; 0.5% at 100. + // LBR onward: needs 100; 1.33% at 100. + new(100.0, 80.0, 4080.0, true, Expansion.T2A, Expansion.UOTD, typeof(MessageInABottle)), + new(100.0, 80.0, 1580.0, true, Expansion.LBR, typeof(MessageInABottle)), + // T2A-UOTD: needs 80; 2.0% at 80, 2.5% at 100. + // LBR-SA: no minimum; 0% at 0, 5% at 100. + // HS onward: needs 80; 4.0% at 80, 5.0% at 100. + new(80.0, 0.0, 4000.0, false, Expansion.T2A, Expansion.UOTD, _magicFish), + new(0.0, 0.0, 2000.0, false, Expansion.LBR, Expansion.SA, _magicFish), + new(80.0, 0.0, 2000.0, false, Expansion.HS, _magicFish), + new(0.0, 103.5, -34.5, false, Expansion.T2A, Expansion.UOTD, _footwear), + // From LBR the two footwear rows make one curve: 75% at 0 skill, 10% at 100 and 5% at 120. + new(0.0, 100.0, -62.5, false, Expansion.LBR, _footwear), + new(0.0, 140.0, -260.0, false, Expansion.LBR, _footwear) + ]; + + // From UO:R deep water needs 65 skill to catch anything, and a GM succeeds 70% of the time. + private static readonly HarvestResource _deepWaterFish = new(0.0, 65.0, 115.0, 1043297, typeof(Fish)); private static readonly int[] _waterLandTiles = - { + [ 0x00A8, 0x00AB, 0x0136, 0x0137 - }; + ]; private static readonly int[] waterStaticTiles = - { + [ 0x1797, 0x179C, 0x346E, 0x3485, 0x3490, 0x34AB, 0x34B5, 0x35D5 - }; + ]; - private static readonly int[] bodyParts = { + private static readonly int[] bodyParts = + [ 0x1CDD, 0x1CE5, // arm 0x1CE0, 0x1CE8, // torso 0x1CE1, 0x1CE9, // head 0x1CE2, 0x1CEC // leg - }; + ]; - private static readonly int[] boneParts = { + private static readonly int[] boneParts = + [ 0x1AE0, 0x1AE1, 0x1AE2, 0x1AE3, 0x1AE4, // skulls 0x1B09, 0x1B0A, 0x1B0B, 0x1B0C, 0x1B0D, 0x1B0E, 0x1B0F, 0x1B10, // bone piles 0x1B15, 0x1B16 // pelvis bones - }; + ]; - private static readonly int[] miscItems = { + private static readonly int[] miscItems = + [ 0x1EB5, // unfinished barrel 0xA2A, // stool 0xC1F, // broken clock 0x1047, 0x1048, // globe 0x1EB1, 0x1EB2, 0x1EB3, 0x1EB4 // barrel staves - }; + ]; private Fishing() { @@ -83,9 +99,9 @@ namespace Server.Engines.Harvest MaxRange = 4, ConsumedPerHarvest = 1, ConsumedPerFeluccaHarvest = 1, - EffectActions = new[] { 12 }, - EffectSounds = Array.Empty(), - EffectCounts = new[] { 1 }, + EffectActions = [12], + EffectSounds = [], + EffectCounts = [1], EffectDelay = TimeSpan.Zero, EffectSoundDelay = TimeSpan.FromSeconds(8.0), NoResourcesMessage = 503172, // The fish don't seem to be biting here. @@ -96,29 +112,23 @@ namespace Server.Engines.Harvest ToolBrokeMessage = 503174 // You broke your fishing pole. }; - HarvestResource[] res = - { - new(00.0, 00.0, 100.0, 1043297, typeof(Fish)) - }; + HarvestResource[] res = [new(00.0, 00.0, 100.0, 1043297, typeof(Fish))]; - HarvestVein[] veins = - { - new(1000, 0.0, res[0], null) - }; + HarvestVein[] veins = [new(1000, 0.0, res[0], null)]; fish.Resources = res; fish.Veins = veins; if (Core.ML) { - fish.BonusResources = new[] - { + fish.BonusResources = + [ new BonusHarvestResource(0, 99.4, null, null), // set to same chance as mining ml gems new BonusHarvestResource(80.0, .6, 1072597, typeof(WhitePearl)) - }; + ]; } - Definitions = new[] { fish }; + Definitions = [fish]; } public static Fishing System => _system ??= new Fishing(); @@ -160,6 +170,13 @@ namespace Server.Engines.Harvest return false; } + public override HarvestResource MutateResource( + Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, + HarvestVein vein, HarvestResource primary, HarvestResource fallback + ) => Core.UOR && SpecialFishingNet.FullValidation(map, loc.X, loc.Y) + ? _deepWaterFish + : base.MutateResource(from, tool, def, map, loc, vein, primary, fallback); + public override Type MutateType( Type type, Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, HarvestResource resource @@ -174,7 +191,7 @@ namespace Server.Engines.Harvest { var entry = _mutateTable[i]; - if (Core.Expansion < entry.m_Expansion) + if (Core.Expansion < entry.m_Expansion || Core.Expansion > entry.m_MaxExpansion) { continue; } @@ -198,11 +215,14 @@ namespace Server.Engines.Harvest return type; } + // From UO:R the chest needs 65 skill, and below that an SOS changes nothing. + private static bool CanFishUpShipwreck(Mobile from) => !Core.UOR || from.Skills.Fishing.Base >= 65.0; + public override bool CheckResources(Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, bool timed) { var pack = from.Backpack; - if (pack != null) + if (pack != null && CanFishUpShipwreck(from)) { foreach (var sos in pack.FindItemsByType()) { @@ -239,91 +259,67 @@ namespace Server.Engines.Harvest return new MessageInABottle(from.Map == Map.Felucca ? Map.Felucca : Map.Trammel); } + // The pearl is a bonus on top of the catch, so an SOS never takes its place. + if (type == typeof(WhitePearl)) + { + return base.Construct(type, from); + } + var pack = from.Backpack; - if (pack != null) + if (pack != null && CanFishUpShipwreck(from)) { // We don't have to queue since we are returning on the first SOS. foreach (var sos in pack.FindItemsByType()) { - if (from.Map == sos.TargetMap && from.InRange(sos.TargetLocation, 60)) + if (from.Map != sos.TargetMap || !from.InRange(sos.TargetLocation, 60)) { - Item preLoot = null; - - switch (Utility.Random(8)) - { - case 0: // Body parts - { - preLoot = new ShipwreckedItem(bodyParts.RandomElement()); - break; - } - case 1: // Bone parts - { - preLoot = new ShipwreckedItem(boneParts.RandomElement()); - break; - } - case 2: // Paintings and portraits - { - preLoot = new ShipwreckedItem(Utility.Random(0xE9F, 10)); - break; - } - case 3: // Pillows - { - preLoot = new ShipwreckedItem(Utility.Random(0x13A4, 11)); - break; - } - case 4: // Shells - { - preLoot = new ShipwreckedItem(Utility.Random(0xFC4, 9)); - break; - } - case 5: // Hats - { - preLoot = Utility.RandomBool() ? new SkullCap() : new TricorneHat(); - break; - } - case 6: // Misc - { - var rand = Utility.Random(miscItems.Length + 1); - - preLoot = rand == miscItems.Length - ? new Candelabra() - : new ShipwreckedItem(miscItems[rand]); - - break; - } - } - - if (preLoot != null) - { - ((IShipwreckedItem)preLoot).IsShipwreckedItem = true; - return preLoot; - } - - LockableContainer chest = Utility.RandomBool() ? new MetalGoldenChest() : new WoodenChest(); - - if (sos.IsAncient && Core.SE) - { - chest.Hue = 0x481; - } - - TreasureMapChest.Fill(chest, Math.Max(1, Math.Min(4, sos.Level))); - - if (Core.UOR) - { - chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet()); - } - - chest.Movable = true; - chest.Locked = false; - chest.TrapType = TrapType.None; - chest.TrapPower = 0; - chest.TrapLevel = 0; - - sos.Delete(); - - return chest; + continue; } + + if (Utility.Random(4) != 0) + { + return Utility.Random(7) switch + { + 0 => new ShipwreckedItem(bodyParts.RandomElement()), // Body Parts + 1 => new ShipwreckedItem(boneParts.RandomElement()), // Bone Parts + 2 => new ShipwreckedItem(Utility.Random(0xE9F, 10)), // Paintings and portraits + 3 => new ShipwreckedItem(Utility.Random(0x13A4, 11)), // Pillows + 4 => new ShipwreckedItem(Utility.Random(0xFC4, 9)), // Shells + 5 => Utility.RandomBool() // Hats + ? new SkullCap { IsShipwreckedItem = true } + : new TricorneHat { IsShipwreckedItem = true }, + 6 => Utility.Random(miscItems.Length + 1) switch // Misc + { + var r when r < miscItems.Length => new ShipwreckedItem(miscItems[r]), + _ => new Candelabra { IsShipwreckedItem = true }, + } + }; + } + + LockableContainer chest = Utility.RandomBool() ? new MetalGoldenChest() : new WoodenChest(); + + if (sos.IsAncient && Core.SE) + { + chest.Hue = 0x481; + } + + TreasureMapChest.Fill(chest, Math.Max(1, Math.Min(4, sos.Level))); + + if (Core.UOR) + { + chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet()); + } + + chest.Movable = true; + chest.Locked = false; + chest.TrapType = TrapType.None; + chest.TrapPower = 0; + chest.TrapLevel = 0; + + sos.Delete(); + + return chest; } } @@ -334,45 +330,7 @@ namespace Server.Engines.Harvest { if (item is TreasureMap or MessageInABottle or SpecialFishingNet) { - BaseCreature serp; - - if (Utility.RandomDouble() < 0.25) - { - serp = new DeepSeaSerpent(); - } - else - { - serp = new SeaSerpent(); - } - - int x = m.X, y = m.Y; - - var map = m.Map; - - for (var i = 0; map != null && i < 20; ++i) - { - var tx = m.X - 10 + Utility.Random(21); - var ty = m.Y - 10 + Utility.Random(21); - - var t = map.Tiles.GetLandTile(tx, ty); - - if (t.Z == -5 && (t.ID >= 0xA8 && t.ID <= 0xAB || t.ID >= 0x136 && t.ID <= 0x137) && - !SpellHelper.CheckMulti(new Point3D(tx, ty, -5), map)) - { - x = tx; - y = ty; - break; - } - } - - serp.MoveToWorld(new Point3D(x, y, -5), map); - - serp.Home = serp.Location; - serp.RangeHome = 10; - - serp.PackItem(item); - - m.SendLocalizedMessage(503170); // Uh oh! That doesn't look like a fish! + SurfaceSerpent(m).PackItem(item); return true; // we don't want to give the item to the player, it's on the serpent } @@ -380,6 +338,52 @@ namespace Server.Engines.Harvest return base.Give(m, item, placeAtFeet || item is BigFish or WoodenChest or MetalGoldenChest); } + private static BaseCreature SurfaceSerpent(Mobile m) + { + BaseCreature serp = Utility.RandomDouble() < 0.1 ? new DeepSeaSerpent() : new SeaSerpent(); + + int x = m.X, y = m.Y; + + var map = m.Map; + + for (var i = 0; map != null && i < 20; ++i) + { + var tx = m.X - 10 + Utility.Random(21); + var ty = m.Y - 10 + Utility.Random(21); + + var t = map.Tiles.GetLandTile(tx, ty); + + if (t.Z == -5 && (t.ID >= 0xA8 && t.ID <= 0xAB || t.ID >= 0x136 && t.ID <= 0x137) && + !SpellHelper.CheckMulti(new Point3D(tx, ty, -5), map)) + { + x = tx; + y = ty; + break; + } + } + + serp.MoveToWorld(new Point3D(x, y, -5), map); + + serp.Home = serp.Location; + serp.RangeHome = 10; + serp.Combatant = m; + + Timer.StartTimer(TimeSpan.FromMinutes(5.0), () => ReturnToDeepWater(serp)); + + m.SendLocalizedMessage(503170); // Uh oh! That doesn't look like a fish! + + return serp; + } + + // Damage entries last 2 minutes and clear at full health, so a serpent nobody is fighting goes back to deep water. + private static void ReturnToDeepWater(BaseCreature serpent) + { + if (!serpent.Deleted && serpent.FindMostRecentDamager(false) == null) + { + serpent.Delete(); + } + } + public override void SendSuccessTo(Mobile from, Item item, HarvestResource resource) { if (item is BigFish fish) @@ -401,12 +405,7 @@ namespace Server.Engines.Harvest number = 1008124; name = "a mess of small fish"; } - else if (item is Fish) - { - number = 1008124; - name = item.ItemData.Name; - } - else if (item is BaseShoes) + else if (item is Fish or BaseShoes) { number = 1008124; name = item.ItemData.Name; @@ -467,7 +466,7 @@ namespace Server.Engines.Harvest TimeSpan.FromSeconds(1.5), () => { - if (Core.ML) + if (Core.T2A) { from.RevealingAction(); } @@ -486,10 +485,18 @@ namespace Server.Engines.Harvest { base.OnHarvestFinished(from, tool, def, vein, bank, resource, harvested); - if (Core.ML) + if (Core.T2A) { from.RevealingAction(); } + + // From T2A, 1% of casts in deep water at 80 skill or more bring up an empty sea serpent, whatever the catch. + if (Core.T2A && from.Skills.Fishing.Base >= 80.0 && Utility.RandomDouble() < 0.01 && + GetHarvestDetails(from, tool, harvested, out _, out var map, out var loc, out _) && + SpecialFishingNet.FullValidation(map, loc.X, loc.Y)) + { + SurfaceSerpent(from); + } } public override object GetLock(Mobile from, Item tool, HarvestDefinition def, object toHarvest) => this; @@ -541,6 +548,7 @@ namespace Server.Engines.Harvest { public readonly bool m_DeepWater; public readonly Expansion m_Expansion; + public readonly Expansion? m_MaxExpansion; public readonly double m_MaxSkill; public readonly double m_MinSkill; public readonly double m_ReqSkill; @@ -548,6 +556,13 @@ namespace Server.Engines.Harvest public MutateEntry( double reqSkill, double minSkill, double maxSkill, bool deepWater, Expansion expansion, params Type[] types + ) : this(reqSkill, minSkill, maxSkill, deepWater, expansion, null, types) + { + } + + public MutateEntry( + double reqSkill, double minSkill, double maxSkill, bool deepWater, Expansion expansion, + Expansion? maxExpansion, params Type[] types ) { m_ReqSkill = reqSkill; @@ -555,6 +570,7 @@ namespace Server.Engines.Harvest m_MaxSkill = maxSkill; m_DeepWater = deepWater; m_Expansion = expansion; + m_MaxExpansion = maxExpansion; m_Types = types; } } diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs index 538807448..faa3a982c 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -216,16 +216,22 @@ internal static class StepCacheFile var path = Core.FindDataFile("tiledata.mul", false); if (path != null) { - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var hasher = HashUtility.CreateXxHash3(); - hasher.Append(fs); - _tileDataFileFingerprint = hasher.GetCurrentHashAsUInt64(); + _tileDataFileFingerprint = HashTileDataFile(path); } _tileDataFileFingerprintComputed = true; return _tileDataFileFingerprint; } + /// XxHash3 of the bytes of the tiledata file at . + internal static ulong HashTileDataFile(string path) + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var hasher = HashUtility.CreateXxHash3(); + hasher.Append(fs); + return hasher.GetCurrentHashAsUInt64(); + } + /// /// Writes the map's chunks to : header, then one record per chunk, then /// the index trailer. IndexOffset isn't known until the records are down, so it goes in as a diff --git a/Projects/UOContent/Items/Food/CookableFood.cs b/Projects/UOContent/Items/Food/CookableFood.cs index e9597fd03..1c426de88 100644 --- a/Projects/UOContent/Items/Food/CookableFood.cs +++ b/Projects/UOContent/Items/Food/CookableFood.cs @@ -1,10 +1,11 @@ using ModernUO.Serialization; +using Server.Engines.Craft.T2A; using Server.Targeting; namespace Server.Items; [SerializationGenerator(0, false)] -public abstract partial class CookableFood : Item +public abstract partial class CookableFood : Item, IT2ACombinable { [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] @@ -14,6 +15,21 @@ public abstract partial class CookableFood : Item public abstract Food Cook(); + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) => + IsHeatSource(target) ? T2ACooking.BeginHeat(from, this, target) : T2ACooking.CannotCombine(from); + public static bool IsHeatSource(object targeted) { int itemID; diff --git a/Projects/UOContent/Items/Food/Cooking.cs b/Projects/UOContent/Items/Food/Cooking.cs index cd010432d..22ce41837 100644 --- a/Projects/UOContent/Items/Food/Cooking.cs +++ b/Projects/UOContent/Items/Food/Cooking.cs @@ -1,11 +1,12 @@ using System; using ModernUO.Serialization; +using Server.Engines.Craft.T2A; using Server.Targeting; namespace Server.Items; [SerializationGenerator(0, false)] -public partial class Dough : Item +public partial class Dough : Item, IT2ACombinable { [Constructible] public Dough() : base(0x103d) @@ -14,10 +15,68 @@ public partial class Dough : Item } public override double DefaultWeight => 1.0; + + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) + { + if (CookableFood.IsHeatSource(target)) + { + return T2ACooking.BeginHeat(from, this, target); + } + + if (target is not Item item) + { + return T2ACooking.CannotCombine(from); + } + + if (!T2ACooking.ValidateInPack(from, item)) + { + return false; + } + + Item result = item switch + { + JarHoney => new SweetDough(), + Pear => new UnbakedFruitPie(), + Peach => new UnbakedPeachCobbler(), + Apple => new UnbakedApplePie(), + Pumpkin or SmallPumpkin => new UnbakedPumpkinPie(), + Eggs => new UnbakedQuiche(), + Ham or Spam or CookedBird or FishSteak => new UnbakedMeatPie(), + Sausage => new UncookedSausagePizza(), + _ => null + }; + + if (result == null) + { + return T2ACooking.CannotCombine(from); + } + + return T2ACooking.Combine( + from, + this, + item, + result, + item is JarHoney + ? "You mix the dough with honey to make sweet dough." + : "You combine the dough with the ingredient." + ); + } } [SerializationGenerator(0, false)] -public partial class SweetDough : Item +public partial class SweetDough : Item, IT2ACombinable { [Constructible] public SweetDough() : base(0x103d) @@ -28,10 +87,79 @@ public partial class SweetDough : Item public override double DefaultWeight => 1.0; public override int LabelNumber => 1041340; // sweet dough + + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) + { + if (CookableFood.IsHeatSource(target)) + { + return T2ACooking.BeginHeat(from, this, target); + } + + switch (target) + { + case JarHoney honey: + { + return T2ACooking.ValidateInPack(from, honey) && T2ACooking.Combine( + from, + this, + honey, + new CookieMix(), + "You mix the sweet dough with honey to make cookie mix." + ); + } + case SackFlour sack: + { + if (!T2ACooking.ValidateInPack(from, sack)) + { + return false; + } + + if (!sack.IsOpen) + { + from.SendAsciiMessage("You must open the sack of flour first."); + return false; + } + + return T2ACooking.Combine( + from, + this, + sack, + new CakeMix(), + "You mix the flour with sweet dough to make cake mix." + ); + } + case BowlFlour bowl: + { + return T2ACooking.ValidateInPack(from, bowl) && T2ACooking.Combine( + from, + this, + bowl, + new CakeMix(), + "You mix the flour with sweet dough to make cake mix." + ); + } + default: + { + return T2ACooking.CannotCombine(from); + } + } + } } [SerializationGenerator(0, false)] -public partial class JarHoney : Item +public partial class JarHoney : Item, IT2ACombinable { [Constructible] public JarHoney() : base(0x9ec) @@ -40,10 +168,44 @@ public partial class JarHoney : Item } public override double DefaultWeight => 1.0; + + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) + { + return target switch + { + Dough dough => T2ACooking.ValidateInPack(from, dough) && T2ACooking.Combine( + from, + this, + dough, + new SweetDough(), + "You mix the dough with honey to make sweet dough." + ), + SweetDough sweetDough => T2ACooking.ValidateInPack(from, sweetDough) && T2ACooking.Combine( + from, + this, + sweetDough, + new CookieMix(), + "You mix the sweet dough with honey to make cookie mix." + ), + _ => T2ACooking.CannotCombine(from) + }; + } } [SerializationGenerator(0, false)] -public partial class BowlFlour : Item +public partial class BowlFlour : Item, IT2ACombinable { [Constructible] public BowlFlour() : base(0xa1e) @@ -51,6 +213,41 @@ public partial class BowlFlour : Item } public override double DefaultWeight => 1.0; + + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) + { + return target switch + { + SweetDough sweetDough => T2ACooking.ValidateInPack(from, sweetDough) && T2ACooking.Combine( + from, + this, + sweetDough, + new CakeMix(), + "You mix the flour with sweet dough to make cake mix." + ), + Item item when T2ACooking.GetWaterSource(item) is { } water => + T2ACooking.CanReachWater(from, item) && T2ACooking.Combine( + from, + this, + water, + new Dough(), + "You mix the flour with water to make dough." + ), + _ => T2ACooking.CannotCombine(from) + }; + } } [SerializationGenerator(0, false)] @@ -66,7 +263,7 @@ public partial class WoodenBowl : Item [TypeAlias("Server.Items.SackFlourOpen")] [SerializationGenerator(0, false)] -public partial class SackFlour : Item, IHasQuantity +public partial class SackFlour : Item, IHasQuantity, IT2ACombinable { [Constructible] public SackFlour() : base(0x1039) @@ -104,7 +301,52 @@ public partial class SackFlour : Item, IHasQuantity { ++ItemID; } + + if (!T2ACraftSystem.Enabled || !T2ACooking.ValidateInPack(from, this)) + { + return; + } + + if (IsOpen) + { + T2ACooking.Prompt(from, this); + } + else + { + from.SendAsciiMessage("You must open the sack of flour first."); + } } + + public bool TryCombine(Mobile from, object target) + { + if (!IsOpen) + { + from.SendAsciiMessage("You must open the sack of flour first."); + return false; + } + + return target switch + { + SweetDough sweetDough => T2ACooking.ValidateInPack(from, sweetDough) && T2ACooking.Combine( + from, + this, + sweetDough, + new CakeMix(), + "You mix the flour with sweet dough to make cake mix." + ), + Item item when T2ACooking.GetWaterSource(item) is { } water => + T2ACooking.CanReachWater(from, item) && T2ACooking.Combine( + from, + this, + water, + new Dough(), + "You mix the flour with water to make dough." + ), + _ => T2ACooking.CannotCombine(from) + }; + } + + public bool IsOpen => ItemID is not (0x1039 or 0x1045); } [SerializationGenerator(0, false)] diff --git a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs index b19e17290..3e58f67f8 100644 --- a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs +++ b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs @@ -15,7 +15,7 @@ public partial class BigFish : Item, ICarvable public BigFish() : base(0x09CC) { // TODO: Find correct formula. max on OSI currently 200, OSI dev says it's not 200 as max, and ~ 1/1,000,000 chance to get highest - Weight = Utility.RandomMinMax(3, 200); + Weight = Core.SE ? Utility.RandomMinMax(3, 200) : 100; Hue = Utility.RandomBool() ? 0x847 : 0x58C; } @@ -23,7 +23,8 @@ public partial class BigFish : Item, ICarvable public void Carve(Mobile from, Item item) { - ScissorHelper(from, new RawFishSteak(), Math.Max(16, (int)Weight) / 4, false); + var steaks = Core.SE ? Math.Max(4, (int)Weight / 5) : Math.Max(16, (int)Weight) / 4; + ScissorHelper(from, new RawFishSteak(), steaks, false); } public override void GetProperties(IPropertyList list) diff --git a/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs b/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs index e4dea06dc..184473e50 100644 --- a/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs +++ b/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs @@ -16,7 +16,8 @@ public abstract partial class BaseMagicFish : Item public virtual bool Apply(Mobile from) { - var applied = SpellHelper.AddStatOffset(from, Type, Bonus, TimeSpan.FromMinutes(1.0)); + var duration = Core.SE ? TimeSpan.FromSeconds(12.0) : TimeSpan.FromMinutes(1.0); + var applied = SpellHelper.AddStatOffset(from, Type, Bonus, duration); if (!applied) { @@ -50,7 +51,7 @@ public partial class PrizedFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Int; public override int LabelNumber => 1041073; // prized fish @@ -64,7 +65,7 @@ public partial class WondrousFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Dex; public override int LabelNumber => 1041074; // wondrous fish @@ -78,7 +79,7 @@ public partial class TrulyRareFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Str; public override int LabelNumber => 1041075; // truly rare fish diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs index 5374f8716..374fb21cd 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -12,12 +12,6 @@ public partial class SOS : Item // By default let's use the old gump, because we can. private static bool UseNewMessages = false; - private static int[] WaterTiles = - { - 0x00A8, 0x00AB, - 0x0136, 0x0137 - }; - private static Rectangle2D[] BritRegions = { new(0, 0, 5120, 4096) }; private static Rectangle2D[] IlshRegions = @@ -179,34 +173,7 @@ public partial class SOS : Item var x = Utility.Random(reg.X, reg.Width); var y = Utility.Random(reg.Y, reg.Height); - if (!ValidateDeepWater(map, x, y)) - { - continue; - } - - var valid = true; - - for (int j = 1, offset = 5; valid && j <= 5; ++j, offset += 5) - { - if (!ValidateDeepWater(map, x + offset, y + offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x + offset, y - offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x - offset, y + offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x - offset, y - offset)) - { - valid = false; - } - } - - if (valid) + if (SpecialFishingNet.FullValidation(map, x, y)) { return new Point3D(x, y, 0); } @@ -215,19 +182,6 @@ public partial class SOS : Item return Point3D.Zero; } - private static bool ValidateDeepWater(Map map, int x, int y) - { - var tileID = map.Tiles.GetLandTile(x, y).ID; - var water = false; - - for (var i = 0; !water && i < WaterTiles.Length; i += 2) - { - water = tileID >= WaterTiles[i] && tileID <= WaterTiles[i + 1]; - } - - return water; - } - private class MessageGump : StaticGump { private readonly string _fmt; diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs index 7017af81f..dc7eef960 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs @@ -10,8 +10,8 @@ namespace Server.Items; [SerializationGenerator(0, false)] public partial class SpecialFishingNet : Item { - private static int[] _hues = - { + private static readonly int[] _hues = + [ 0x09B, 0x0CD, 0x0D3, @@ -26,25 +26,19 @@ public partial class SpecialFishingNet : Item 0x492, 0x4B5, 0x8AA - }; + ]; - private static int[] _waterTiles = - { - 0x00A8, 0x00AB, - 0x0136, 0x0137 - }; + private static readonly int[] _waterTiles = [0x00A8, 0x00AB, 0x0136, 0x0137 ]; - private static int[] _shallowWaterTiles = - { - 0x1797, 0x179C - }; + private static readonly int[] _shallowWaterTiles = [0x1797, 0x179C]; [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _inUse; [Constructible] - public SpecialFishingNet() : base(0x0DCA) => Hue = Utility.RandomDouble() < 0.01 ? _hues.RandomElement() : 0x8A0; + public SpecialFishingNet() : base(0x0DCA) => + Hue = Core.LBR && Utility.RandomDouble() < 0.1 ? _hues.RandomElement() : 0x8A0; public override double DefaultWeight => 1.0; @@ -322,31 +316,15 @@ public partial class SpecialFishingNet : Item public static bool FullValidation(Map map, int x, int y) { - if (!ValidateDeepWater(map, x, y)) + // Every tile closer than 14 must be water, so land and the edge of the map are at least 14 tiles away. + for (var ty = y - 13; ty <= y + 13; ++ty) { - return false; - } - - for (int j = 1, offset = 5; j <= 5; ++j, offset += 5) - { - if (!ValidateDeepWater(map, x + offset, y + offset)) + for (var tx = x - 13; tx <= x + 13; ++tx) { - return false; - } - - if (!ValidateDeepWater(map, x + offset, y - offset)) - { - return false; - } - - if (!ValidateDeepWater(map, x - offset, y + offset)) - { - return false; - } - - if (!ValidateDeepWater(map, x - offset, y - offset)) - { - return false; + if (!ValidateDeepWater(map, tx, ty)) + { + return false; + } } } diff --git a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs index 9447fddfa..34f1bc419 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -108,6 +108,7 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; + [InvalidateProperties] [SerializableField(8)] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _spellCount; @@ -135,7 +136,6 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem [SerializableField(7, fieldChanged: nameof(OnContentChanged))] [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] private ulong _content; private void OnContentChanged(ulong oldValue, ulong newValue) diff --git a/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs b/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs index 73482ccd0..074c29512 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs @@ -159,7 +159,7 @@ public abstract partial class BaseTool : Item, IUsesRemaining, ICraftable { from.SendLocalizedMessage(num); } - else if (T2ACraftSystem.Enabled) + else if (T2ACraftSystem.Enabled && T2ACraftSystem.HasMenu(system)) { from.Target = new T2ACraftToolTarget(this, system); from.SendAsciiMessage("Target this tool to make last item, or any other target to begin crafting."); diff --git a/Projects/UOContent/Items/Weapons/Abilities/MovingShot.cs b/Projects/UOContent/Items/Weapons/Abilities/MovingShot.cs index e0b543cfd..955023c8f 100644 --- a/Projects/UOContent/Items/Weapons/Abilities/MovingShot.cs +++ b/Projects/UOContent/Items/Weapons/Abilities/MovingShot.cs @@ -11,8 +11,9 @@ namespace Server.Items public override bool ValidatesDuringHit => false; + // Mana is only checked here; OnHit/OnMiss charge it, since they run only for a shot that actually fired. public override bool OnBeforeSwing(Mobile attacker, Mobile defender) => - Validate(attacker) && CheckMana(attacker, true); + Validate(attacker) && CheckMana(attacker, false); public override void OnMiss(Mobile attacker, Mobile defender) { @@ -20,7 +21,10 @@ namespace Server.Items ClearCurrentAbility(attacker); - attacker.SendLocalizedMessage(1060089); // You fail to execute your special move + if (CheckMana(attacker, true)) + { + attacker.SendLocalizedMessage(1060089); // You fail to execute your special move + } } public override void OnHit(Mobile attacker, Mobile defender, int damage, WorldLocation worldLocation) @@ -29,7 +33,10 @@ namespace Server.Items ClearCurrentAbility(attacker); - attacker.SendLocalizedMessage(1060216); // Your shot was successful + if (CheckMana(attacker, true)) + { + attacker.SendLocalizedMessage(1060216); // Your shot was successful + } } } } diff --git a/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs b/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs index 4fd786809..a47a43543 100644 --- a/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs +++ b/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs @@ -206,7 +206,7 @@ namespace Server.Items var result = base.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, craftItem, resHue); if (Core.ML) { - ApplyWoodBonuses(typeRes); + ApplyWoodBonuses(typeRes ?? craftItem.Resources[0].ItemType); } return result; diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json new file mode 100644 index 000000000..55d3c3ec8 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json @@ -0,0 +1,199 @@ +{ + "version": 12, + "type": "Server.Engines.CannedEvil.ChampionSpawn", + "properties": [ + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByProximity", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextProximityTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "MaxLevel", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByValor", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DamageEntries", + "type": "System.Collections.Generic.Dictionary\u003CServer.Mobile, int\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule", + "0", + "int", + "PrimitiveTypeMigrationRule", + "1", + "" + ] + }, + { + "name": "ConfinedRoaming", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Idol", + "type": "Server.Engines.CannedEvil.IdolOfTheChampion", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HasBeenAdvanced", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnArea", + "type": "Server.Rectangle2D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect2D" + ] + }, + { + "name": "RandomizeType", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Kills", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Active", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Type", + "type": "Server.Engines.CannedEvil.ChampionSpawnType", + "rule": "EnumMigrationRule" + }, + { + "name": "Creatures", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RedSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "WhiteSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Platform", + "type": "Server.Engines.CannedEvil.ChampionPlatform", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Altar", + "type": "Server.Engines.CannedEvil.ChampionAltar", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ExpireDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Champion", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "RestartDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "RestartTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Minions", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Misc/CrashGuard.cs b/Projects/UOContent/Misc/CrashGuard.cs index 1a6e12cc1..522fd755e 100644 --- a/Projects/UOContent/Misc/CrashGuard.cs +++ b/Projects/UOContent/Misc/CrashGuard.cs @@ -13,7 +13,7 @@ namespace Server.Misc private static bool Enabled; private static bool SaveBackup; - private static bool RestartServer; // Disable this if using a daemon/service + private static bool RestartServer; // Under a daemon/service, set server.restartExitCode rather than disabling this private static bool GenerateReport; public static void Configure() diff --git a/Projects/UOContent/Misc/ResourceInfo.cs b/Projects/UOContent/Misc/ResourceInfo.cs index 521216d58..dae8b4836 100644 --- a/Projects/UOContent/Misc/ResourceInfo.cs +++ b/Projects/UOContent/Misc/ResourceInfo.cs @@ -736,7 +736,7 @@ namespace Server.Items /// public static CraftResource GetFromType(Type resourceType) { - if (m_TypeTable == null) + if (resourceType == null || m_TypeTable == null) { return CraftResource.None; } diff --git a/Projects/UOContent/Misc/Titles.cs b/Projects/UOContent/Misc/Titles.cs index f26baf64a..29eac3a4a 100644 --- a/Projects/UOContent/Misc/Titles.cs +++ b/Projects/UOContent/Misc/Titles.cs @@ -262,6 +262,11 @@ namespace Server.Misc private static void SendKarmaMessage(Mobile m, int offset) { + if (offset == 0) + { + return; + } + var message = offset switch { > 40 => 1019062, // You have gained a lot of karma. diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index 1ed8cbdda..11f02f1aa 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -616,8 +616,8 @@ public class MageAI : BaseAI } var del = ScaleBySkill(3.0, SkillName.Magery); - var min = 6.0 - del * 0.75; - var max = 6.0 - del * 1.25; + var min = 6.0 - del * 1.25; + var max = 6.0 - del * 0.75; return TimeSpan.FromSeconds(min + (max - min) * Utility.RandomDouble()); } diff --git a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs index 75c6fc3dc..0fcbae30c 100644 --- a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs +++ b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using ModernUO.CodeGeneratedEvents; namespace Server.Mobiles; @@ -9,8 +8,6 @@ namespace Server.Mobiles; /// public abstract class MonsterAbility { - private Dictionary _nextTriggerTicks; - public virtual MonsterAbilityType AbilityType => MonsterAbilityType.Generic; public virtual MonsterAbilityTrigger AbilityTrigger => MonsterAbilityTrigger.None; @@ -30,18 +27,9 @@ public abstract class MonsterAbility return false; } - if (_nextTriggerTicks?.TryGetValue(source, out var nextTrigger) == true) + if (source.IsAbilityOnCooldown(this)) { - if (nextTrigger - Core.TickCount > 0) - { - return false; - } - - _nextTriggerTicks.Remove(source); - if (_nextTriggerTicks.Count == 0) - { - _nextTriggerTicks = null; - } + return false; } var c = ChanceToTrigger; @@ -65,10 +53,7 @@ public abstract class MonsterAbility ? MinTriggerCooldown : Utility.RandomMinMax(MinTriggerCooldown, MaxTriggerCooldown); - var nextTrigger = Core.TickCount + (long)randomAmount.TotalMilliseconds; - - _nextTriggerTicks ??= new Dictionary(); - _nextTriggerTicks[source] = nextTrigger; + source.SetAbilityCooldown(this, randomAmount); } public virtual void AlterMeleeDamageFrom(BaseCreature source, Mobile target, ref int damage) @@ -100,18 +85,5 @@ public abstract class MonsterAbility } [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] - [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] - public static void InvalidateNextAbilityTriggers(BaseCreature source) - { - var abilities = source.GetMonsterAbilities(); - if (abilities == null || abilities.Length == 0) - { - return; - } - - for (var i = 0; i < abilities.Length; i++) - { - abilities[i]._nextTriggerTicks?.Remove(source); - } - } + public static void ResetCooldownsOnDeath(BaseCreature source) => source.ClearAbilityCooldowns(); } diff --git a/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs b/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs index 3fe6ba737..41b2f9185 100644 --- a/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs +++ b/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs @@ -11,9 +11,10 @@ public class SummonPixiesCounter : MonsterAbility var amount = Utility.RandomMinMax(3, 6); for (var i = 0; i < amount; i++) { - var pixie = new Pixie { Team = source.Team, FightMode = FightMode.Closest }; - pixie.MoveToWorld(source.Map.GetRandomNearbyLocation(source.Location), source.Map); - pixie.Combatant = target; + if (!source.SpawnMinion(new Pixie { FightMode = FightMode.Closest }, target)) + { + break; + } } base.Trigger(trigger, source, target); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 5795c72a8..1b8c1589f 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; +using Server.Engines.CannedEvil; using Server.Engines.ConPVP; using Server.Engines.MLQuests; using Server.Engines.Quests.Doom; @@ -1315,6 +1316,8 @@ namespace Server.Mobiles public virtual int TreasureMapLevel => -1; + public virtual double TreasureMapChance => TreasureMap.LootChance; + public virtual bool IgnoreYoungProtection => false; public bool NoKillAwards { get; set; } @@ -1503,10 +1506,47 @@ namespace Server.Mobiles public List MLQuests => (m_MLQuests ??= StaticMLQuester ? MLQuestSystem.FindQuestList(GetType()) : ConstructQuestList()) ?? MLQuestSystem.EmptyList; + /// + /// The abilities this creature has. Must be a pure, stable query: it is called from lookups, damage + /// calculations and cleanup, so it must not spawn, mutate state, or change with the creature's state. + /// Gate abilities that should only work some of the time with . + /// public virtual MonsterAbility[] GetMonsterAbilities() => null; + /// + /// Whether may trigger or be looked up right now. Must be a pure read. + /// + public virtual bool CanUseAbility(MonsterAbility ability) => true; + private MonsterAbilityTrigger _activeTriggers; + // Deadlines in Core.TickCount, keyed by ability. Lives on the creature so it is collected with it. + private Dictionary _abilityCooldowns; + + public bool IsAbilityOnCooldown(MonsterAbility ability) + { + if (_abilityCooldowns?.TryGetValue(ability, out var nextTrigger) != true) + { + return false; + } + + if (nextTrigger - Core.TickCount > 0) + { + return true; + } + + _abilityCooldowns.Remove(ability); + return false; + } + + public void SetAbilityCooldown(MonsterAbility ability, TimeSpan cooldown) + { + _abilityCooldowns ??= new Dictionary(); + _abilityCooldowns[ability] = Core.TickCount + (long)cooldown.TotalMilliseconds; + } + + public void ClearAbilityCooldowns() => _abilityCooldowns = null; + public virtual MonsterAbility GetAbility(MonsterAbilityType type) { var abilities = GetMonsterAbilities(); @@ -1519,6 +1559,11 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; + if (!CanUseAbility(ability)) + { + continue; + } + if (ability is MonsterAbilityGroup group) { ability = group.GetAbilityWithType(type); @@ -1582,7 +1627,7 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; - if (ability.CanTrigger(this, trigger)) + if (CanUseAbility(ability) && ability.CanTrigger(this, trigger)) { ability.Trigger(trigger, this, defender); triggered = true; @@ -1609,7 +1654,7 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; - if (ability.CanTrigger(this, trigger)) + if (CanUseAbility(ability) && ability.CanTrigger(this, trigger)) { ability.Move(this, d); } @@ -1628,7 +1673,7 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; - if (ability.CanTrigger(this, trigger)) + if (CanUseAbility(ability) && ability.CanTrigger(this, trigger)) { if ((trigger & MonsterAbilityTrigger.GiveMeleeDamage) != 0) { @@ -1669,7 +1714,7 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; - if (ability.CanTrigger(this, trigger)) + if (CanUseAbility(ability) && ability.CanTrigger(this, trigger)) { if ((trigger & MonsterAbilityTrigger.GiveSpellDamage) != 0) { @@ -3242,7 +3287,7 @@ namespace Server.Mobiles { PackItem(new ParagonChest(Name, treasureLevel)); } - else if ((Map == Map.Felucca || Map == Map.Trammel) && Utility.RandomDouble() < TreasureMap.LootChance) + else if ((Map == Map.Felucca || Map == Map.Trammel) && Utility.RandomDouble() < TreasureMapChance) { PackItem(new TreasureMap(treasureLevel, Map)); } @@ -5834,6 +5879,47 @@ namespace Server.Mobiles } } + /// + /// Equips in place of whatever an inherited outfit put on its layer. + /// AddItem would stack both on one layer, and EquipItem would refuse and orphan the item. + /// + public void ReplaceEquipment(Item item) + { + FindItemOnLayer(item.Layer)?.Delete(); + + if (!EquipItem(item)) + { + item.Delete(); + } + } + + /// + /// Brings into the world on this creature's side. A minion of a champion spawn's + /// champion or creatures belongs to that spawn, which deletes it when it clears its creatures. + /// + /// False, and the minion is deleted, if this creature is not in the world. + public bool SpawnMinion(BaseCreature minion, Mobile target = null, Point3D? location = null) + { + var map = Map; + + if (Deleted || map == null || map == Map.Internal) + { + minion.Delete(); + return false; + } + + minion.Team = Team; + ChampionSpawn.FindSpawnOf(this)?.AddMinion(minion); + minion.MoveToWorld(location ?? map.GetRandomNearbyLocation(Location), map); + + if (target != null) + { + minion.Combatant = target; + } + + return true; + } + public virtual void HealStart(Mobile patient) { StopHeal(); diff --git a/Projects/UOContent/Mobiles/Hireables/HireRanger.cs b/Projects/UOContent/Mobiles/Hireables/HireRanger.cs index d7349c1b8..ed228ba49 100644 --- a/Projects/UOContent/Mobiles/Hireables/HireRanger.cs +++ b/Projects/UOContent/Mobiles/Hireables/HireRanger.cs @@ -43,7 +43,6 @@ public partial class HireRanger : BaseHire { Body = 0x190; Name = NameList.RandomName("male"); - EquipItem(new ShortPants(Utility.RandomNeutralHue())); } EquipItem(new Shoes(Utility.RandomNeutralHue())); diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerCaptain.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerCaptain.cs index d4b8eaf82..c7e7ab5d4 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerCaptain.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerCaptain.cs @@ -47,7 +47,8 @@ namespace Server.Mobiles pack.DropItem(new Bolt(Utility.RandomMinMax(10, 20))); pack.DropItem(new Bolt(Utility.RandomMinMax(10, 20))); - AddItem( + // The crossbow is wielded; the sword would share its hands, so it is carried as loot. + pack.DropItem( Utility.Random(6) switch { 0 => new Longsword(), diff --git a/Projects/UOContent/Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs b/Projects/UOContent/Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs index fd3747095..d326916de 100644 --- a/Projects/UOContent/Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs +++ b/Projects/UOContent/Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs @@ -42,8 +42,8 @@ namespace Server.Mobiles Fame = 18000; Karma = -18000; - AddItem(new Shoes(0x537)); - AddItem(new Robe(0x452)); + ReplaceEquipment(new Shoes(0x537)); + ReplaceEquipment(new Robe(0x452)); for (var i = 0; i < 2; ++i) { diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs index e4ffe5ba3..b0cfaf697 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs @@ -62,6 +62,8 @@ namespace Server.Mobiles public override int TreasureMapLevel => 5; + public override double TreasureMapChance => 0.25; + public override bool OnBeforeDeath() { PackItem(new MessageInABottle(Map == Map.Felucca ? Map.Felucca : Map.Trammel)); @@ -147,7 +149,7 @@ namespace Server.Mobiles { base.OnDeath(c); - if (Fisher != null && Utility.Random(100) < 25) + if (Core.SE && Fisher?.Skills.Fishing.Base >= 100.0 && Utility.Random(100) < 25) { GiveArtifactTo(Fisher); } diff --git a/Projects/UOContent/Mobiles/Monsters/SE/BakeKitsune.cs b/Projects/UOContent/Mobiles/Monsters/SE/BakeKitsune.cs index 96a8954d6..1e00abeb9 100644 --- a/Projects/UOContent/Mobiles/Monsters/SE/BakeKitsune.cs +++ b/Projects/UOContent/Mobiles/Monsters/SE/BakeKitsune.cs @@ -176,11 +176,8 @@ namespace Server.Mobiles { _disguiseTimerToken.Cancel(); - if (!IsBodyMod) - { - return; - } - + // BodyMod is not saved, so after a restart a disguised kitsune comes back without it but still + // wearing the disguise; strip it regardless. Name = null; Title = null; BodyMod = 0; diff --git a/Projects/UOContent/Mobiles/Special/Barracoon.cs b/Projects/UOContent/Mobiles/Special/Barracoon.cs index 50e941515..0bc542746 100644 --- a/Projects/UOContent/Mobiles/Special/Barracoon.cs +++ b/Projects/UOContent/Mobiles/Special/Barracoon.cs @@ -131,9 +131,7 @@ public partial class Barracoon : BaseChampion public void SpawnRatmen(Mobile target) { - var map = Map; - - if (map == null) + if (Map == null || Map == Map.Internal) { return; } @@ -162,9 +160,7 @@ public partial class Barracoon : BaseChampion _ => new Ratman() }; - rat.Team = Team; - rat.MoveToWorld(map.GetRandomNearbyLocation(Location), map); - rat.Combatant = target; + SpawnMinion(rat, target); } } diff --git a/Projects/UOContent/Mobiles/Special/LordOaks.cs b/Projects/UOContent/Mobiles/Special/LordOaks.cs index f8fb6d444..40a0d2d68 100644 --- a/Projects/UOContent/Mobiles/Special/LordOaks.cs +++ b/Projects/UOContent/Mobiles/Special/LordOaks.cs @@ -86,7 +86,11 @@ public partial class LordOaks : BaseChampion public override Poison PoisonImmune => Poison.Deadly; private static MonsterAbility[] _abilities = { MonsterAbilities.SummonPixiesCounter }; - public override MonsterAbility[] GetMonsterAbilities() => CheckQueen() ? _abilities : MonsterAbilities.Empty; + public override MonsterAbility[] GetMonsterAbilities() => _abilities; + + public override bool CanUseAbility(MonsterAbility ability) => IsQueenAlive; + + private bool IsQueenAlive => _queen?.Deleted == false; public override void GenerateLoot() { @@ -103,36 +107,42 @@ public partial class LordOaks : BaseChampion public override int GetDeathSound() => 0x2F7; - public bool CheckQueen() + // Aggression lands before the damage it causes, so the queen is up before the first hit is scaled. + public override void AggressiveAction(Mobile aggressor, bool criminal) { - if (Map == null) + base.AggressiveAction(aggressor, criminal); + SpawnQueen(); + } + + public override void OnCombatantChange() + { + base.OnCombatantChange(); + + if (Combatant != null) { - return false; + SpawnQueen(); + } + } + + private void SpawnQueen() + { + if (_spawnedQueen || Deleted || Map == null || Map == Map.Internal) + { + return; } - if (!_spawnedQueen) - { - Say(1042153); // Come forth my queen! + Say(1042153); // Come forth my queen! - _queen = new Silvani { Team = Team }; - _queen.MoveToWorld(Location, Map); + var queen = new Silvani(); + SpawnMinion(queen, location: Location); - _spawnedQueen = true; - return true; - } - - if (_queen?.Deleted != false) - { - _queen = null; - return false; - } - - return true; + Queen = queen; + SpawnedQueen = true; } public override void AlterDamageScalarFrom(Mobile caster, ref double scalar) { - if (CheckQueen()) + if (IsQueenAlive) { scalar *= 0.1; } diff --git a/Projects/UOContent/Mobiles/Special/Neira.cs b/Projects/UOContent/Mobiles/Special/Neira.cs index 4cbcbb5c9..b55e20d25 100644 --- a/Projects/UOContent/Mobiles/Special/Neira.cs +++ b/Projects/UOContent/Mobiles/Special/Neira.cs @@ -163,13 +163,13 @@ public partial class Neira : BaseChampion } } - public override void AlterDamageScalarFrom(Mobile caster, ref double scalar) + public override void OnDamagedBySpell(Mobile from, int damage) { - base.AlterDamageScalarFrom(caster, ref scalar); + base.OnDamagedBySpell(from, damage); if (Utility.RandomDouble() < 0.1) // 10% chance to throw an unholy bone { - AddUnholyBone(caster, 1.0); + AddUnholyBone(from, 1.0); } } diff --git a/Projects/UOContent/Mobiles/Townfolk/Merchant.cs b/Projects/UOContent/Mobiles/Townfolk/Merchant.cs index e5867c645..f9e5425df 100644 --- a/Projects/UOContent/Mobiles/Townfolk/Merchant.cs +++ b/Projects/UOContent/Mobiles/Townfolk/Merchant.cs @@ -33,15 +33,6 @@ public partial class Merchant : BaseEscortable public override void InitOutfit() { - if (Female) - { - AddItem(new PlainDress()); - } - else - { - AddItem(new Shirt(GetRandomHue())); - } - var lowHue = GetRandomHue(); AddItem(new ThighBoots()); diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/IronWorker.cs b/Projects/UOContent/Mobiles/Vendors/NPC/IronWorker.cs index e3ef3b2a5..a75e3efad 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/IronWorker.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/IronWorker.cs @@ -58,14 +58,6 @@ namespace Server.Mobiles AddItem(new FullApron(Utility.RandomBrightHue())); } - AddItem( - Utility.Random(3) switch - { - 0 => new Bandana(Utility.RandomBrightHue()), - _ => new JesterHat(Utility.RandomBrightHue()) // 1-2 - } - ); - AddItem(new Bascinet()); AddItem(new SmithHammer()); diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs index 3c8c33c77..9d8a439df 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs @@ -26,10 +26,10 @@ namespace Server.Mobiles { base.InitOutfit(); - AddItem(new FancyShirt(0x3E4)); - AddItem(new LongPants(0x192)); + ReplaceEquipment(new FancyShirt(0x3E4)); + ReplaceEquipment(new LongPants(0x192)); AddItem(new Pickaxe()); - AddItem(new ThighBoots(0x283)); + ReplaceEquipment(new ThighBoots(0x283)); } } } diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs index 7a4f499ba..4e8a9d462 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs @@ -31,10 +31,10 @@ namespace Server.Mobiles { base.InitOutfit(); - AddItem(new Shirt(Utility.RandomNeutralHue())); - AddItem(new LongPants(Utility.RandomNeutralHue())); + ReplaceEquipment(new Shirt(Utility.RandomNeutralHue())); + ReplaceEquipment(new LongPants(Utility.RandomNeutralHue())); AddItem(new Bow()); - AddItem(new ThighBoots(Utility.RandomNeutralHue())); + ReplaceEquipment(new ThighBoots(Utility.RandomNeutralHue())); } } } diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs index f9c802295..031bc4c97 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs @@ -31,10 +31,10 @@ namespace Server.Mobiles { base.InitOutfit(); - AddItem(new Shirt(Utility.RandomNeutralHue())); - AddItem(new LongPants(Utility.RandomNeutralHue())); + ReplaceEquipment(new Shirt(Utility.RandomNeutralHue())); + ReplaceEquipment(new LongPants(Utility.RandomNeutralHue())); AddItem(new Dagger()); - AddItem(new ThighBoots(Utility.RandomNeutralHue())); + ReplaceEquipment(new ThighBoots(Utility.RandomNeutralHue())); } } } diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 5789eabc6..d6acad5bd 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -40,18 +40,20 @@ false + + - - - + + + - - + + diff --git a/dev-docs/serialization.md b/dev-docs/serialization.md index 059fbff69..c1b86ab2a 100644 --- a/dev-docs/serialization.md +++ b/dev-docs/serialization.md @@ -261,14 +261,17 @@ Best for fields that are usually small values (counts, IDs, indexes). ### [AnchoredDateTime] **Target**: `DateTime` field -**Effect**: Stores the absolute UTC instant; at load it is shifted forward by the downtime -between the save and the load (using the save-start anchor in the save's index file), so -server downtime does not consume the remaining time. `DateTime.MinValue`/`MaxValue` +**Effect**: Stores the absolute UTC instant. At load, a value still in the future when the save +started is shifted forward by the downtime between the save and the load (using the save-start +anchor in the save's index file), so server downtime does not consume the remaining time. A +value at or before the save start is loaded as saved: an expired deadline stays expired, and a +"last happened at" stamp lets downtime count toward its window. `DateTime.MinValue`/`MaxValue` sentinels pass through unshifted. -Prefer this for deadlines and "elapsed while running" values. Unlike `[DeltaDateTime]`, the -stored bytes do not change on every save when the value is unchanged, keeping idle saves -byte-stable. +Use this for deadlines. A field that only records when something happened (a last-use stamp) +never shifts, so it may equally be a plain `DateTime`. Unlike `[DeltaDateTime]`, the stored +bytes do not change on every save when the value is unchanged, and only deadlines still running +at save time change across a restart. ```csharp [AnchoredDateTime] diff --git a/dev-docs/server-requirements.md b/dev-docs/server-requirements.md index 1447ebf67..2bbfc2f25 100644 --- a/dev-docs/server-requirements.md +++ b/dev-docs/server-requirements.md @@ -126,6 +126,36 @@ See the README for the full supported list. Two things are worth calling out: - **Linux kernel 6.1** or newer (Debian 12 and equivalents). io_uring is used where available, with automatic epoll fallback. +## Restarting under a supervisor + +By default a restart (`restart` console command, `AutoRestart`, `CrashGuard` crash recovery) +launches a fresh copy of the server and exits. That copy is detached from the console you started +the server in, and a systemd unit or container kills it as soon as the original process exits. + +If something else keeps the server running, let it do the relaunch instead. Set a restart exit code +in `Distribution/Configuration/modernuo.json`: + +```json +"settings": { + "server.restartExitCode": "75" +} +``` + +A restart then shuts down and saves as usual and exits with that code (1–255) instead of launching +a copy. A normal `shutdown` still exits with `0`. `75` is a suggestion; any code your supervisor +does not use for something else works. Then restart on that code: + +- **systemd**: `Restart=on-failure` in the `[Service]` section. +- **Docker**: `restart: on-failure` in Compose, or `--restart on-failure`. +- **Shell loop** (Linux/macOS), keeps the server on the same terminal: + ```sh + while :; do dotnet ModernUO.dll; [ $? -eq 75 ] || break; done + ``` +- **PowerShell** (Windows): + ```powershell + do { dotnet ModernUO.dll } while ($LASTEXITCODE -eq 75) + ``` + ## Tuning for a small host | Setting | Default | Why change it |