", "<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 = "
";
- 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
[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 |