", "<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/Container.cs b/Projects/Server/Items/Container.cs
index 18aa27be5..d2fb7b590 100644
--- a/Projects/Server/Items/Container.cs
+++ b/Projects/Server/Items/Container.cs
@@ -390,6 +390,21 @@ public partial class Container : Item
}
}
+ ///
+ /// Direct children are left out of world saves; the container itself is still saved and
+ /// loads empty.
+ ///
+ public virtual bool SkipsChildSerialization => false;
+
+ ///
+ /// Whether was sent to every client in range rather than only to the root
+ /// mobile, the secure-trade partner and openers. Removal of a public child is broadcast; removal of any
+ /// other child reaches only the clients that were sent it. Overrides may only widen this beyond
+ /// , never narrow it: a public container's children always broadcast
+ /// their removal regardless of what an override returns here.
+ ///
+ public virtual bool IsChildPublic(Item child) => IsPublicContainer;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override void OnItemAdded(Item item)
{
diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs
index 03ff9d000..c67acdc85 100644
--- a/Projects/Server/Items/Item.cs
+++ b/Projects/Server/Items/Item.cs
@@ -922,7 +922,10 @@ public partial class Item : IHued, IComparable- , ISpawnable, IObjectPropert
}
var info = LookupCompactInfo();
- var items = LookupItems();
+ // Skipping containers never write their child serials: those children are not saved as
+ // top-level records (see SkipSerialization), so a written serial can be handed to an
+ // unrelated new item during the next load's synchronous deserialize loop.
+ var items = this is Container { SkipsChildSerialization: true } ? EmptyItems : LookupItems();
if (m_Direction != Direction.North)
{
@@ -1039,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))
@@ -1185,6 +1189,11 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
// are final yet.
LastMoved = Core.Now;
+ // A parented item's removal already reached every client that was sent it (root, trade
+ // parties, openers) via RemoveItem below; sending another remove at oldLocation would
+ // broadcast it to bystanders who never knew about it.
+ var wasContained = Parent != null;
+
if (Parent is Mobile mobile)
{
mobile.RemoveItem(this);
@@ -1202,7 +1211,7 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
{
m_Map.OnLeave(this);
- if (oldLocation.m_X != 0)
+ if (!wasContained && oldLocation.m_X != 0)
{
SendRemovePacket(oldLocation);
}
@@ -1278,7 +1287,7 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
}
else if (m_Map != null)
{
- if (oldLocation.m_X != 0)
+ if (!wasContained && oldLocation.m_X != 0)
{
var removeEntity = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength].InitializePacket();
@@ -1344,7 +1353,12 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
m_Map.OnLeave(this);
}
- SendRemovePacket();
+ if (m_Parent is not Container)
+ {
+ // A contained item only changes map along with its parent or root; their own
+ // removal already clears this subtree for every client that knew about it.
+ SendRemovePacket();
+ }
}
var items = LookupItems();
@@ -1479,6 +1493,43 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
return;
}
+ // A properties-only invalidation (no Update) on a private child must stay as scoped as every
+ // other private-child send, otherwise a bystander who was never sent the item learns its OPL hash.
+ if (!update && ObjectPropertyList.Enabled && (flags & ItemDelta.Properties) != 0 &&
+ TryGetPrivateParent(out var cont))
+ {
+ GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo);
+
+ SendPrivateOplTo(root, worldLoc);
+
+ if (tradeFrom != root)
+ {
+ SendPrivateOplTo(tradeFrom, worldLoc);
+ }
+
+ if (tradeTo != root && tradeTo != tradeFrom)
+ {
+ SendPrivateOplTo(tradeTo, worldLoc);
+ }
+
+ var privateOpeners = cont.Openers;
+
+ if (privateOpeners != null)
+ {
+ for (var i = 0; i < privateOpeners.Count; ++i)
+ {
+ var mob = privateOpeners[i];
+
+ if (mob != root && mob != tradeFrom && mob != tradeTo)
+ {
+ SendPrivateOplTo(mob, worldLoc);
+ }
+ }
+ }
+
+ return;
+ }
+
// A second item sharing an equipment layer is omitted by SendMobileIncoming (0x78);
// sending it on its own via EquipUpdate/OPL would leave the client with two items on
// one slot. Skip the per-client sends entirely for the dupe.
@@ -1581,7 +1632,7 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
ClearProperties();
}
- public virtual bool SkipSerialization => false;
+ public virtual bool SkipSerialization => m_Parent is Container { SkipsChildSerialization: true };
[IgnoreDupe]
public ISpawner Spawner
@@ -2444,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);
@@ -2806,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)
{
@@ -2828,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.
@@ -2926,6 +3003,13 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
m_Parent = null;
}
+ // A skipping container never wrote its children's serials (see Serialize), so this
+ // item's frozen parent reference is stale/unreachable: treat it as a missing parent.
+ if (m_Parent is Container { SkipsChildSerialization: true })
+ {
+ m_Parent = null;
+ }
+
if (m_Parent == null && (parent.IsMobile || parent.IsItem))
{
Timer.DelayCall(Delete);
@@ -3089,6 +3173,13 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
m_Parent = null;
}
+ // A skipping container never wrote its children's serials (see Serialize), so this
+ // item's frozen parent reference is stale/unreachable: treat it as a missing parent.
+ if (m_Parent is Container { SkipsChildSerialization: true })
+ {
+ m_Parent = null;
+ }
+
if (m_Parent == null && (parent.IsMobile || parent.IsItem))
{
Timer.DelayCall(Delete);
@@ -3552,8 +3643,19 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
{
var items = LookupItems();
- if (items.Remove(item))
+ // Bulk removal (Delete, purges) walks children back to front; finding the tail in O(1) keeps
+ // deleting a large container linear instead of quadratic.
+ var index = items.Count - 1;
+
+ if (index < 0 || !ReferenceEquals(items[index], item))
{
+ index = items.IndexOf(item);
+ }
+
+ if (index >= 0)
+ {
+ items.RemoveAt(index);
+
if (this is not Container)
{
AcquireCompactInfo().Version++;
@@ -4018,6 +4120,43 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
var removeEntity = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength].InitializePacket();
+ if (TryGetPrivateParent(out var cont))
+ {
+ // Private children are only ever sent to these recipients (see ProcessDelta); nobody else knows them.
+ OutgoingEntityPackets.CreateRemoveEntity(removeEntity, Serial);
+
+ GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo);
+
+ SendRemoveTo(root, worldLoc, removeEntity);
+
+ if (tradeFrom != root)
+ {
+ SendRemoveTo(tradeFrom, worldLoc, removeEntity);
+ }
+
+ if (tradeTo != root && tradeTo != tradeFrom)
+ {
+ SendRemoveTo(tradeTo, worldLoc, removeEntity);
+ }
+
+ var openers = cont.Openers;
+
+ if (openers != null)
+ {
+ for (var i = 0; i < openers.Count; ++i)
+ {
+ var mob = openers[i];
+
+ if (mob != root && mob != tradeFrom && mob != tradeTo)
+ {
+ SendRemoveTo(mob, worldLoc, removeEntity);
+ }
+ }
+ }
+
+ return;
+ }
+
foreach (var state in m_Map.GetClientsInRange(worldLoc, GetMaxUpdateRange()))
{
var m = state.Mobile;
@@ -4030,6 +4169,78 @@ public partial class Item : IHued, IComparable
- , ISpawnable, IObjectPropert
}
}
+ private void SendRemoveTo(Mobile m, Point3D worldLoc, ReadOnlySpan removeEntity)
+ {
+ if (m?.NetState != null && m.Map == m_Map && m.InRange(worldLoc, GetUpdateRange(m)))
+ {
+ m.NetState.Send(removeEntity);
+ }
+ }
+
+ // Same recipient gate as SendRemoveTo, plus CanSee: this is an informational OPL push, not
+ // cleanup of a reference the client may already hold regardless of visibility.
+ private void SendPrivateOplTo(Mobile m, Point3D worldLoc)
+ {
+ var ns = m?.NetState;
+
+ if (ns != null && m.CanSee(this) && m.Map == m_Map && m.InRange(worldLoc, GetUpdateRange(m)))
+ {
+ SendOPLPacketTo(ns);
+ }
+ }
+
+ // Shared with IsSentTo so the private-child guard and recipient set (root/trade/openers) each live
+ // in one place.
+ private bool TryGetPrivateParent(out Container cont)
+ {
+ cont = m_Parent as Container;
+ return cont != null && !cont.IsPublicContainer && !cont.IsChildPublic(this);
+ }
+
+ private void GetPrivateChildRecipients(Container cont, out Mobile root, out Mobile tradeFrom, out Mobile tradeTo)
+ {
+ root = cont.RootParent as Mobile;
+
+ var trade = GetSecureTradeCont()?.Trade;
+ // Trade.From/To are unassigned while SecureTrade's own constructor is still adding the
+ // VirtualCheck to each side's container, so both must stay null-conditional.
+ tradeFrom = trade?.From?.Mobile;
+ tradeTo = trade?.To?.Mobile;
+ }
+
+ ///
+ /// Whether 's client is expected to hold this item; gates resends keyed by a
+ /// client-supplied serial.
+ ///
+ public bool IsSentTo(Mobile m)
+ {
+ if (m == null || Deleted)
+ {
+ return false;
+ }
+
+ if (TryGetPrivateParent(out var cont))
+ {
+ GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo);
+
+ if (m == root)
+ {
+ // Mirrors ProcessDelta's own root gate: CanSee and in range.
+ return m.CanSee(this) && m.InRange(GetWorldLocation(), GetUpdateRange(m));
+ }
+
+ var isTradeOrOpener = m == tradeFrom || m == tradeTo || cont.Openers?.Contains(m) == true;
+
+ // Trade parties and openers are re-validated against ProcessDelta's own gate (CanSee,
+ // current map, update range), so an invisible item or a stale Openers entry that hasn't
+ // been pruned yet never resyncs to them.
+ return isTradeOrOpener && m.CanSee(this) && m.Map == m_Map &&
+ m.InRange(GetWorldLocation(), GetUpdateRange(m));
+ }
+
+ return m.CanSee(this) && m.InRange(GetWorldLocation(), GetUpdateRange(m));
+ }
+
public virtual int GetDropSound() => -1;
public Point3D GetWorldLocation()
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/Maps/Map.ClientByDistanceEnumerator.cs b/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs
index eb79410ae..54d61797c 100644
--- a/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs
+++ b/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs
@@ -85,6 +85,9 @@ public partial class Map
private Rectangle2D _bounds;
private int _sectorStartX;
+ private int _sectorStartY;
+ private int _sectorEndX;
+ private int _sectorEndY;
private int _maxRing;
private int _ring; // -1 = uninitialized, then 0.._maxRing
@@ -115,11 +118,11 @@ public partial class Map
var centerSectorX = center.m_X / SectorSize;
var centerSectorY = center.m_Y / SectorSize;
- map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY);
+ map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY);
// Calculate max ring based on bounds
- var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX);
- var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY);
+ var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX);
+ var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY);
_maxRing = Math.Max(dx, dy);
}
@@ -202,7 +205,7 @@ public partial class Map
_ringIndex = 0;
sx = _center.m_X / SectorSize;
sy = _center.m_Y / SectorSize;
- return sx >= _sectorStartX;
+ return IsSectorInBounds(sx, sy);
}
sx = sy = 0;
@@ -225,7 +228,7 @@ public partial class Map
_ringIndex = nextIndex;
CalculatePositionFromIndex(nextIndex, out sx, out sy);
- if (sx >= _sectorStartX)
+ if (IsSectorInBounds(sx, sy))
{
return true;
}
@@ -233,6 +236,11 @@ public partial class Map
}
+ // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsSectorInBounds(int sx, int sy) =>
+ sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void CalculatePositionFromIndex(int index, out int x, out int y)
{
diff --git a/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs b/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs
index 207928f68..91da4969f 100644
--- a/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs
+++ b/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs
@@ -113,6 +113,9 @@ public partial class Map
private Rectangle2D _bounds;
private int _sectorStartX;
+ private int _sectorStartY;
+ private int _sectorEndX;
+ private int _sectorEndY;
private int _maxRing;
private int _ring; // -1 = uninitialized, then 0.._maxRing
@@ -143,11 +146,11 @@ public partial class Map
var centerSectorX = center.m_X / SectorSize;
var centerSectorY = center.m_Y / SectorSize;
- map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY);
+ map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY);
// Calculate max ring based on bounds
- var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX);
- var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY);
+ var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX);
+ var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY);
_maxRing = Math.Max(dx, dy);
}
@@ -228,7 +231,7 @@ public partial class Map
_ringIndex = 0;
sx = _center.m_X / SectorSize;
sy = _center.m_Y / SectorSize;
- return sx >= _sectorStartX;
+ return IsSectorInBounds(sx, sy);
}
sx = sy = 0;
@@ -251,7 +254,7 @@ public partial class Map
_ringIndex = nextIndex;
CalculatePositionFromIndex(nextIndex, out sx, out sy);
- if (sx >= _sectorStartX)
+ if (IsSectorInBounds(sx, sy))
{
return true;
}
@@ -259,6 +262,11 @@ public partial class Map
}
+ // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsSectorInBounds(int sx, int sy) =>
+ sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void CalculatePositionFromIndex(int index, out int x, out int y)
{
diff --git a/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs b/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs
index e5a8d0b91..92e9dcbb2 100644
--- a/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs
+++ b/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs
@@ -108,6 +108,9 @@ public partial class Map
private Rectangle2D _bounds;
private int _sectorStartX;
+ private int _sectorStartY;
+ private int _sectorEndX;
+ private int _sectorEndY;
private int _maxRing;
private int _ring; // -1 = uninitialized, then 0.._maxRing
@@ -138,11 +141,11 @@ public partial class Map
var centerSectorX = center.m_X / SectorSize;
var centerSectorY = center.m_Y / SectorSize;
- map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY);
+ map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY);
// Calculate max ring based on bounds
- var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX);
- var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY);
+ var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX);
+ var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY);
_maxRing = Math.Max(dx, dy);
}
@@ -223,7 +226,7 @@ public partial class Map
_ringIndex = 0;
sx = _center.m_X / SectorSize;
sy = _center.m_Y / SectorSize;
- return sx >= _sectorStartX;
+ return IsSectorInBounds(sx, sy);
}
sx = sy = 0;
@@ -246,7 +249,7 @@ public partial class Map
_ringIndex = nextIndex;
CalculatePositionFromIndex(nextIndex, out sx, out sy);
- if (sx >= _sectorStartX)
+ if (IsSectorInBounds(sx, sy))
{
return true;
}
@@ -254,6 +257,11 @@ public partial class Map
}
+ // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool IsSectorInBounds(int sx, int sy) =>
+ sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY;
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void CalculatePositionFromIndex(int index, out int x, out int y)
{
diff --git a/Projects/Server/Maps/Map.MultiEnumerator.cs b/Projects/Server/Maps/Map.MultiEnumerator.cs
index b0e069a75..bbd04a53c 100644
--- a/Projects/Server/Maps/Map.MultiEnumerator.cs
+++ b/Projects/Server/Maps/Map.MultiEnumerator.cs
@@ -85,8 +85,6 @@ public partial class Map
public MultiBoundsEnumerable GetMultisInBounds(Rectangle2D bounds, bool makeBoundsInclusive = false) where T : BaseMulti =>
new(this, bounds, makeBoundsInclusive);
- private static readonly HashSet _sharedDupes = [];
-
public ref struct MultiSectorEnumerable(Map map, Point2D loc) where T : BaseMulti
{
public static MultiSectorEnumerable Empty
@@ -193,13 +191,11 @@ public partial class Map
private int _currentVersion;
private Sector _currentSector;
private T _current;
-
+ private HashSet _unanchoredSeen;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public MultiBoundsEnumerator(Map map, Rectangle2D bounds, bool makeBoundsInclusive)
{
- _sharedDupes.Clear();
-
_map = map;
_bounds = bounds;
@@ -255,14 +251,10 @@ public partial class Map
while (++_currentIndex < _currentList.Length)
{
var item = _currentList[_currentIndex];
- if (item is T { Deleted: false } o && bounds.Contains(o.Location))
+ if (item is T { Deleted: false } o && bounds.Contains(o.Location) && ShouldYieldFromCurrentSector(map, o))
{
- // Multis can span multiple sectors, so we need to deduplicate
- if (_sharedDupes.Add(o.Serial))
- {
- _current = o;
- return true;
- }
+ _current = o;
+ return true;
}
}
}
@@ -290,6 +282,27 @@ public partial class Map
}
}
+ // A multi is listed in every sector it spans, but its location is in exactly one sector, which the bounds
+ // filter guarantees is visited. Yield it from that sector only. A multi missing from its location's sector
+ // (components changed after it was placed) falls back to a per-enumerator set, so nested queries stay independent.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool ShouldYieldFromCurrentSector(Map map, T multi)
+ {
+ var home = map.GetSector(map.Bound(multi.Location));
+
+ if (home == _currentSector)
+ {
+ return true;
+ }
+
+ if (home.Multis.Contains(multi))
+ {
+ return false;
+ }
+
+ return (_unanchoredSeen ??= []).Add(multi.Serial);
+ }
+
public T Current
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/Projects/Server/Maps/Map.StaticTileEnumerator.cs b/Projects/Server/Maps/Map.StaticTileEnumerator.cs
index 27075b59d..73fec0684 100644
--- a/Projects/Server/Maps/Map.StaticTileEnumerator.cs
+++ b/Projects/Server/Maps/Map.StaticTileEnumerator.cs
@@ -99,7 +99,12 @@ public partial class Map
{
_tiles = multi.Components.Tiles[offsetX][offsetY];
_index = -1;
- return SetTile();
+
+ // A multi can have no tile at this cell; later multis in the sector still may
+ if (SetTile())
+ {
+ return true;
+ }
}
}
diff --git a/Projects/Server/Maps/Map.cs b/Projects/Server/Maps/Map.cs
index e51a848d2..5981f979f 100644
--- a/Projects/Server/Maps/Map.cs
+++ b/Projects/Server/Maps/Map.cs
@@ -34,7 +34,7 @@ public enum MapRules
FreeMovement = 0x0002, // Anyone can move over anyone else without taking stamina loss
BeneficialRestrictions = 0x0004, // Disallow performing beneficial actions on criminals/murderers
HarmfulRestrictions = 0x0008, // Disallow performing harmful actions on innocents
- TrammelRules = FreeMovement | BeneficialRestrictions | HarmfulRestrictions,
+ TrammelRules = FreeMovement, //| BeneficialRestrictions | HarmfulRestrictions,
FeluccaRules = None
}
diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs
index d1db8113b..8d98d2ffe 100644
--- a/Projects/Server/Mobiles/Mobile.cs
+++ b/Projects/Server/Mobiles/Mobile.cs
@@ -825,13 +825,19 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro
{
if (m_TithingPoints != value)
{
+ int oldValue = m_TithingPoints;
m_TithingPoints = value;
Delta(MobileDelta.TithingPoints);
+ OnTithingPointsChange(oldValue);
}
}
}
+ public virtual void OnTithingPointsChange(int oldValue)
+ {
+ }
+
[CommandProperty(AccessLevel.GameMaster)]
public int Followers
{
@@ -2343,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;
@@ -5203,7 +5211,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro
{
state.SendLiftReject(reject);
- if (item.Deleted)
+ // A rejection must not resynchronize state this client was never sent (client-supplied serial).
+ if (item.Deleted || !item.IsSentTo(this))
{
return;
}
@@ -6178,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/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs
index 287fa78a4..f100f85c3 100644
--- a/Projects/Server/Network/NetState/NetState.Network.cs
+++ b/Projects/Server/Network/NetState/NetState.Network.cs
@@ -56,10 +56,7 @@ public partial class NetState
internal static long _availableMemoryBytes;
private static Timer.DelayCallTimer _maintenanceTimer;
- private static long _lastTierCapacityBytes;
- private static int _lastTierInUse;
- private static int _lastTierRetainFloor;
- private static long _lastBaseCapacityBytes;
+ private static SendBufferSweep _lastSweep;
private static readonly Queue _disposed = [];
private static readonly TimeSpan ConnectingSocketIdleLimit = TimeSpan.FromMilliseconds(5000); // 5 seconds
@@ -238,14 +235,23 @@ public partial class NetState
_capRefusals = 0;
var stats = _socketManager.Maintain();
- var changed = stats.TierCapacityBytes != _lastTierCapacityBytes ||
- stats.TierInUse != _lastTierInUse ||
- stats.TierRetainFloor != _lastTierRetainFloor ||
- stats.BaseCapacityBytes != _lastBaseCapacityBytes;
- _lastTierCapacityBytes = stats.TierCapacityBytes;
- _lastTierInUse = stats.TierInUse;
- _lastTierRetainFloor = stats.TierRetainFloor;
- _lastBaseCapacityBytes = stats.BaseCapacityBytes;
+ var changed = stats.TierCapacityBytes != _lastSweep.TierCapacityBytes ||
+ stats.TierInUse != _lastSweep.TierInUse ||
+ stats.TierRetainFloor != _lastSweep.TierRetainFloor ||
+ stats.BaseCapacityBytes != _lastSweep.BaseCapacityBytes;
+ _lastSweep = new SendBufferSweep(
+ true,
+ Core.TickCount,
+ stats.TierCapacityBytes,
+ stats.TierInUse,
+ stats.TierRetainFloor,
+ stats.BuffersReleased,
+ stats.BaseCapacityBytes,
+ stats.BaseBuffersReleased,
+ stats.GrowthRefusals,
+ capRefusals,
+ ceilingRefusals
+ );
// Quiet unless something moved
if (changed || stats.BuffersReleased > 0 || stats.BaseBuffersReleased > 0 || stats.GrowthRefusals > 0 ||
@@ -266,6 +272,48 @@ public partial class NetState
}
}
+ ///
+ /// Read-only diagnostics snapshot; never rotates the maintenance window. Cold path: allocates.
+ ///
+ public static NetworkStats GetNetworkStats()
+ {
+ var manager = _socketManager;
+ var tiers = manager == null ? [] : new SendBufferTierUsage[manager.SendBufferTierCount];
+ for (var i = 0; i < tiers.Length; i++)
+ {
+ var tier = manager.GetSendBufferTierStats(i);
+ tiers[i] = new SendBufferTierUsage(tier.BufferSize, tier.Capacity, tier.InUse, tier.RetainFloor);
+ }
+
+ var authenticated = 0;
+ foreach (var ns in _instances)
+ {
+ if (ns.Account != null)
+ {
+ authenticated++;
+ }
+ }
+
+ return new NetworkStats(
+ manager?.ConnectedCount ?? 0,
+ manager?.MaxSockets ?? MaxConnections,
+ authenticated,
+ RecvBufferSize,
+ SendBufferSize,
+ MaxSendBufferSize,
+ InitialRecvBufferSize,
+ InitialSendBufferSize,
+ _sendBufferGrowthBudget,
+ _memoryCeilingPercent,
+ _availableMemoryBytes,
+ tiers,
+ _lastSweep,
+ _throttled.Count + _throttledPending.Count,
+ _flushPending.Count,
+ _pendingDisconnects.Count
+ );
+ }
+
///
/// Slabs each base pool is divided into, after the transport applies its minimum slab size.
///
diff --git a/Projects/Server/Network/NetworkStats.cs b/Projects/Server/Network/NetworkStats.cs
new file mode 100644
index 000000000..370b8b7ff
--- /dev/null
+++ b/Projects/Server/Network/NetworkStats.cs
@@ -0,0 +1,91 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: NetworkStats.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+namespace Server.Network;
+
+/// One send buffer growth tier's pool usage, in buffers of bytes.
+/// Physical size of every buffer in this tier.
+/// Buffers the tier's allocated slabs hold.
+/// Buffers currently handed out.
+/// Peak usage the retention window is still holding capacity for.
+public readonly record struct SendBufferTierUsage(int BufferSize, int Capacity, int InUse, int RetainFloor);
+
+///
+/// What the last once-a-minute send buffer maintenance sweep saw. The refusal counts cover the
+/// minute before that sweep only.
+///
+/// False until the first sweep; every other field is then meaningless.
+/// when the sweep ran; compare by subtraction only.
+/// Slab capacity allocated across the growth tiers.
+/// Growth-tier buffers handed out, summed across tiers.
+/// Retention floors summed across tiers, in buffers.
+/// Growth-tier buffers the sweep returned to the OS.
+/// Slab capacity allocated across the base and pre-auth pools.
+/// Base and pre-auth buffers the sweep returned to the OS.
+/// Growth refused because the tiers were at the growth budget.
+/// Growth refused because the buffer was already at the maximum size.
+/// Growth refused because the process was above the memory ceiling.
+public readonly record struct SendBufferSweep(
+ bool Ran,
+ long Tick,
+ long TierCapacityBytes,
+ int TierInUse,
+ int TierRetainFloor,
+ int TierBuffersReleased,
+ long BaseCapacityBytes,
+ int BaseBuffersReleased,
+ int BudgetRefusals,
+ int CapRefusals,
+ int CeilingRefusals
+);
+
+///
+/// Read-only snapshot of the network layer for diagnostics. Connection counts and tier usage are
+/// live; everything under is up to a minute old. Cold path: allocates.
+///
+/// Sockets the transport currently holds, authenticated or not.
+/// Socket slots the transport was built with.
+/// Connections past login, and so holding full-size buffers.
+/// Base receive buffer size.
+/// Base send buffer size.
+/// Largest size a send buffer can grow to.
+/// Pre-auth receive buffer size; 0 when pre-auth buffers are off.
+/// Pre-auth send buffer size; 0 when pre-auth buffers are off.
+/// Bytes the growth tiers may hold in total.
+/// Growth stops once the process exceeds this share of available memory.
+/// Memory available to the process as of the last sweep.
+/// Live usage of each growth tier, smallest first.
+/// What the last maintenance sweep saw.
+/// Connections whose parser is paused on a packet throttle.
+/// Connections with unsent bytes queued for the next flush.
+/// Connections closing after their final flush.
+public readonly record struct NetworkStats(
+ int Connected,
+ int MaxConnections,
+ int Authenticated,
+ int RecvBufferSize,
+ int SendBufferSize,
+ int MaxSendBufferSize,
+ int InitialRecvBufferSize,
+ int InitialSendBufferSize,
+ long SendBufferGrowthBudget,
+ int MemoryCeilingPercent,
+ long AvailableMemoryBytes,
+ SendBufferTierUsage[] Tiers,
+ SendBufferSweep LastSweep,
+ int Throttled,
+ int FlushPending,
+ int PendingDisconnects
+);
diff --git a/Projects/Server/Network/Packets/OutgoingContainerPackets.cs b/Projects/Server/Network/Packets/OutgoingContainerPackets.cs
index fdb71bf90..b1d846e7b 100644
--- a/Projects/Server/Network/Packets/OutgoingContainerPackets.cs
+++ b/Projects/Server/Network/Packets/OutgoingContainerPackets.cs
@@ -16,6 +16,7 @@
using System;
using System.Buffers;
using System.IO;
+using Server.Buffers;
using Server.Logging;
namespace Server.Network;
@@ -166,6 +167,10 @@ public static class OutgoingContainerPackets
ns.Send(writer.Span);
}
+ // 0x3C's length and item-count fields are both 16-bit, so a container's contents can never
+ // legally fill more than this many bytes regardless of how many items it actually holds.
+ private const int MaxContainerContentBytes = ushort.MaxValue;
+
public static void SendContainerContent(this NetState ns, Mobile beholder, Item beheld)
{
if (ns.CannotSendPackets())
@@ -175,43 +180,63 @@ public static class OutgoingContainerPackets
var items = beheld.Items;
var count = items.Count;
+ var entrySize = ns.ContainerGridLines ? 20 : 19;
- var writer = new SpanWriter(stackalloc byte[5 + items.Count * (ns.ContainerGridLines ? 20 : 19)]);
- writer.Write((byte)0x3C); // Packet ID
- writer.Seek(4, SeekOrigin.Current); // Length & written count
+ var maxEntries = (MaxContainerContentBytes - 5) / entrySize;
+ var entriesToSend = Math.Min(count, maxEntries);
+ var length = 5 + entriesToSend * entrySize;
- var written = 0;
+ // A container holding thousands of items would blow the stack; rent from the pool past a
+ // small, safe threshold instead.
+ byte[] rented = null;
+ var buffer = length <= 1024 ? stackalloc byte[length] : rented = STArrayPool.Shared.Rent(length);
- for (var i = 0; i < count; ++i)
+ try
{
- var child = items[i];
+ var writer = new SpanWriter(buffer[..length]);
+ writer.Write((byte)0x3C); // Packet ID
+ writer.Seek(4, SeekOrigin.Current); // Length & written count
- if (!child.Deleted && beholder.CanSee(child))
+ var written = 0;
+
+ for (var i = 0; i < count && written < entriesToSend; ++i)
{
- var loc = child.Location;
+ var child = items[i];
- writer.Write(child.Serial);
- writer.Write((ushort)child.ItemID);
- writer.Write((byte)0); // signed, itemID offset
- writer.Write((ushort)Math.Min(child.Amount, ushort.MaxValue));
- writer.Write((short)loc.X);
- writer.Write((short)loc.Y);
- if (ns.ContainerGridLines)
+ if (!child.Deleted && beholder.CanSee(child))
{
- writer.Write((byte)0); // Grid Location?
- }
- writer.Write(beheld.Serial);
- writer.Write((ushort)(child.QuestItem ? Item.QuestItemHue : child.Hue));
+ var loc = child.Location;
- ++written;
+ writer.Write(child.Serial);
+ writer.Write((ushort)child.ItemID);
+ writer.Write((byte)0); // signed, itemID offset
+ writer.Write((ushort)Math.Min(child.Amount, ushort.MaxValue));
+ writer.Write((short)loc.X);
+ writer.Write((short)loc.Y);
+ if (ns.ContainerGridLines)
+ {
+ writer.Write((byte)0); // Grid Location?
+ }
+ writer.Write(beheld.Serial);
+ writer.Write((ushort)(child.QuestItem ? Item.QuestItemHue : child.Hue));
+
+ ++written;
+ }
+ }
+
+ writer.Seek(1, SeekOrigin.Begin);
+ writer.Write((ushort)writer.BytesWritten);
+ writer.Write((ushort)written);
+ writer.Seek(0, SeekOrigin.End);
+
+ ns.Send(writer.Span);
+ }
+ finally
+ {
+ if (rented != null)
+ {
+ STArrayPool.Shared.Return(rented);
}
}
-
- writer.Seek(1, SeekOrigin.Begin);
- writer.Write((ushort)writer.BytesWritten);
- writer.Write((ushort)written);
- writer.Seek(0, SeekOrigin.End);
-
- ns.Send(writer.Span);
}
}
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 ad1a069c1..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);
@@ -371,9 +361,12 @@ public class BitmapAStarAlgorithmTests
{
StepCache.Instance.Clear();
StepCache.Instance.MissPromotionThreshold = 2;
+ // Pinned: promotion needs both Finds inside the window, and Linux's coarse TickCount can
+ // step several ms between two back-to-back Finds.
+ 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);
@@ -398,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.
@@ -423,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 7b630173c..abe9a87d2 100644
--- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
+++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
@@ -14,8 +15,12 @@ namespace Server.Tests.Pathfinding;
/// layers, and LRU eviction.
///
[Collection("Sequential Pathfinding Tests")]
-public class StepCacheLifecycleTests
+public class StepCacheLifecycleTests : IDisposable
{
+ private readonly StepCacheStateScope _cacheState = new();
+
+ public void Dispose() => _cacheState.Dispose();
+
/// Resets to a known state and returns the singleton.
private static StepCache FreshCache(int promotionThreshold)
{
@@ -28,23 +33,13 @@ public class StepCacheLifecycleTests
/// 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 ----
///
@@ -58,7 +53,7 @@ public class StepCacheLifecycleTests
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);
@@ -70,21 +65,19 @@ public class StepCacheLifecycleTests
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);
@@ -93,9 +86,9 @@ public class StepCacheLifecycleTests
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);
}
///
@@ -111,10 +104,10 @@ public class StepCacheLifecycleTests
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);
@@ -126,11 +119,9 @@ public class StepCacheLifecycleTests
/// 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;
@@ -138,7 +129,7 @@ public class StepCacheLifecycleTests
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);
}
@@ -149,7 +140,7 @@ public class StepCacheLifecycleTests
// 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);
@@ -166,12 +157,12 @@ public class StepCacheLifecycleTests
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);
@@ -184,8 +175,8 @@ public class StepCacheLifecycleTests
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);
@@ -207,16 +198,14 @@ public class StepCacheLifecycleTests
/// 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
@@ -259,7 +248,7 @@ public class StepCacheLifecycleTests
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);
@@ -301,8 +290,8 @@ public class StepCacheLifecycleTests
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);
@@ -326,7 +315,7 @@ public class StepCacheLifecycleTests
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);
@@ -399,26 +388,19 @@ public class StepCacheLifecycleTests
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/Doors/DoorChainTests.cs b/Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs
new file mode 100644
index 000000000..ed399714d
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using Server.Items;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class DoorChainTests
+{
+ [Fact]
+ public void GetChain_LoopNotThroughStart_YieldsEachDoorOnce()
+ {
+ var a = new MetalDoor(DoorFacing.WestCW);
+ var b = new MetalDoor(DoorFacing.WestCW);
+ var c = new MetalDoor(DoorFacing.WestCW);
+
+ try
+ {
+ // A -> B -> C -> B: the chain never returns to A
+ a.Link = b;
+ b.Link = c;
+ c.Link = b;
+
+ var chain = new List();
+ foreach (var door in a.GetChain())
+ {
+ chain.Add(door);
+ Assert.True(chain.Count <= 3, "Chain enumeration did not terminate");
+ }
+
+ Assert.Equal([a, b, c], chain);
+ }
+ finally
+ {
+ a.Delete();
+ b.Delete();
+ c.Delete();
+ }
+ }
+}
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
new file mode 100644
index 000000000..ca3c7399f
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Accounting;
+using Server.Items;
+using Server.Network;
+using Server.Tests.Network;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class CorpseChildPublicTests
+{
+ // Weapons take their layer from tiledata, which CI doesn't load; without an explicit layer the
+ // equip is silently refused and the corpse's worn-gear list stays empty.
+ private static VikingSword EquipSword(Mobile owner)
+ {
+ var sword = new VikingSword { Layer = Layer.OneHanded };
+ 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 = 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();
+ owner.Delete();
+ }
+ }
+
+ [Fact]
+ public void IsChildPublic_HumanCorpseWithLootItem_ReturnsFalse()
+ {
+ 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();
+ owner.Delete();
+ }
+ }
+
+ [Fact]
+ public void IsChildPublic_BoneCorpse_ReturnsFalse()
+ {
+ 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);
+ Assert.NotEqual(0x2006, corpse.ItemID);
+ Assert.False(corpse.IsChildPublic(wornItem));
+ }
+ finally
+ {
+ corpse?.Delete();
+ owner.Delete();
+ }
+ }
+
+ [Fact]
+ public void IsChildPublic_NonHumanCorpse_ReturnsFalse()
+ {
+ 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();
+ owner.Delete();
+ }
+ }
+
+ private static bool ReceivedRemove(NetState ns, Serial serial)
+ {
+ Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength];
+ OutgoingEntityPackets.CreateRemoveEntity(expected, serial);
+ return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0;
+ }
+
+ private static 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();
+ 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);
+
+ Assert.True(ReceivedRemove(bystanderNs, sword.Serial));
+
+ lootItem.Delete();
+ Assert.False(ReceivedRemove(bystanderNs, lootItem.Serial));
+ }
+ finally
+ {
+ pouch?.Delete();
+ corpse?.Delete();
+ owner.Delete();
+ DeleteBystander(bystanderNs, bystander);
+ }
+ }
+
+ [Fact]
+ public void HumanCorpse_LiftingWornGear_BroadcastsRemoval()
+ {
+ var corpseLoc = new Point3D(1500, 1500, 0);
+
+ var bystanderNs = PacketTestUtilities.CreateTestNetState();
+ Mobile bystander = null;
+ var owner = NewOwner(0x190);
+ var sword = new VikingSword { Layer = Layer.OneHanded };
+ 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 _);
+
+ Assert.False(rejected);
+ Assert.DoesNotContain(sword, corpse.EquipItems);
+ Assert.True(ReceivedRemove(bystanderNs, sword.Serial));
+ }
+ finally
+ {
+ owner.Holding = null;
+ sword.Delete();
+ corpse?.Delete();
+ owner.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
new file mode 100644
index 000000000..9cfec050a
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs
@@ -0,0 +1,45 @@
+using Server;
+using Server.Items;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class CorpseLootTests
+{
+ [Fact]
+ public void LiftedWornGear_PutBack_StaysLoot()
+ {
+ 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);
+ sword.Internalize();
+ corpse.DropItem(sword);
+
+ Assert.Same(corpse, sword.Parent);
+ Assert.DoesNotContain(sword, corpse.EquipItems);
+ }
+ finally
+ {
+ 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 40ad7c6d7..1669147f2 100644
--- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs
+++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs
@@ -10,24 +10,48 @@ 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 m = new Mobile(World.NewMobile);
+ VikingSword weapon = null;
+ Corpse c = null;
- var weapon = new VikingSword();
- m.EquipItem(weapon);
+ try
+ {
+ m.DefaultMobileInit();
+ weapon = EquipSword(m);
+ c = new Corpse(m, m.Items);
- var c = new Corpse(m, m.Items);
+ var expected = new CorpseEquip(m, c).Compile();
- var expected = new CorpseEquip(m, c).Compile();
+ using var ns = PacketTestUtilities.CreateTestNetState();
+ ns.SendCorpseEquip(m, c);
- using var ns = PacketTestUtilities.CreateTestNetState();
- ns.SendCorpseEquip(m, c);
-
- var result = ns.SendBuffer.GetReadSpan();
- AssertThat.Equal(result, expected);
+ var result = ns.SendBuffer.GetReadSpan();
+ AssertThat.Equal(result, expected);
+ }
+ finally
+ {
+ c?.Delete();
+ weapon?.Delete();
+ m.Delete();
+ }
}
[Theory]
@@ -35,22 +59,31 @@ public class CorpsePacketTests
[InlineData(ProtocolChanges.ContainerGridLines)]
public void TestCorpseContainerPacket(ProtocolChanges changes)
{
- var m = new Mobile((Serial)0x1);
- m.DefaultMobileInit();
+ var m = new Mobile(World.NewMobile);
+ VikingSword weapon = null;
+ Corpse c = null;
- var weapon = new VikingSword();
- m.EquipItem(weapon);
+ try
+ {
+ m.DefaultMobileInit();
+ weapon = EquipSword(m);
+ c = new Corpse(m, m.Items);
- var c = new Corpse(m, m.Items);
+ using var ns = PacketTestUtilities.CreateTestNetState();
+ ns.ProtocolChanges = changes;
- using var ns = PacketTestUtilities.CreateTestNetState();
- ns.ProtocolChanges = changes;
+ var expected = (ns.ContainerGridLines ? (Packet)new CorpseContent6017(m, c) : new CorpseContent(m, c)).Compile();
- var expected = (ns.ContainerGridLines ? (Packet)new CorpseContent6017(m, c) : new CorpseContent(m, c)).Compile();
+ ns.SendCorpseContent(m, c);
- ns.SendCorpseContent(m, c);
-
- var result = ns.SendBuffer.GetReadSpan();
- AssertThat.Equal(result, expected);
+ var result = ns.SendBuffer.GetReadSpan();
+ AssertThat.Equal(result, expected);
+ }
+ finally
+ {
+ 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
new file mode 100644
index 000000000..7a3145d70
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Server;
+using Server.Items;
+using Server.Mobiles;
+using Server.Tests;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class TreasureMapChestGuardianTests
+{
+ // A treasure.cfg chest site with room to dig the chest up and spawn its guardians.
+ private const int SiteX = 2643;
+ private const int SiteY = 851;
+
+ // The guardian list starts null and is created by the first guardian added. A level 0 chest
+ // stays shut to players until its guardians are dead.
+ [Fact]
+ public void AddToGuardians_OnNewChest_KeepsLevelZeroChestShut()
+ {
+ var map = Map.Felucca;
+ var location = new Point3D(5000, 610, 0);
+ var player = CreatePlayerMobile(map, location);
+ var guardian = new Mobile(World.NewMobile);
+ guardian.DefaultMobileInit();
+ var chest = new TreasureMapChest(0);
+
+ try
+ {
+ Assert.Null(chest.Guardians);
+
+ chest.AddToGuardians(guardian);
+
+ Assert.Same(guardian, Assert.Single(chest.Guardians));
+ Assert.True(chest.CheckLocked(player));
+
+ guardian.Delete();
+
+ Assert.False(chest.CheckLocked(player));
+ }
+ finally
+ {
+ chest.Delete();
+ guardian.Delete();
+ player.Delete();
+ }
+ }
+
+ // 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.
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ public void Dig_AddsSpawnedGuardiansToTheChest(int level)
+ {
+ 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));
+ var treasureMap = new TreasureMap(level, map);
+
+ try
+ {
+ Assert.True(map.CanFit(site.X, site.Y, site.Z, 16, true));
+ Assert.True(player.BeginAction());
+
+ var timerType = typeof(TreasureMap).GetNestedType("DigTimer", BindingFlags.NonPublic)!;
+ var onTick = timerType.GetMethod("OnTick", BindingFlags.NonPublic | BindingFlags.Instance)!;
+ var digTimer = Activator.CreateInstance(timerType, player, treasureMap, site, map);
+
+ for (var i = 0; i < 30 && !treasureMap.Completed; i++)
+ {
+ onTick.Invoke(digTimer, BindingFlags.DoNotWrapExceptions, null, null, null);
+ }
+
+ var chest = FindChest(map, site);
+
+ Assert.True(treasureMap.Completed);
+ Assert.NotNull(chest);
+ Assert.NotNull(chest.Guardians);
+ Assert.NotEmpty(chest.Guardians);
+ }
+ finally
+ {
+ Cleanup(map, site);
+ treasureMap.Delete();
+ player.Delete();
+ }
+ }
+
+ private static TreasureMapChest FindChest(Map map, Point3D site)
+ {
+ foreach (var chest in map.GetItemsInRange(site, 0))
+ {
+ return chest;
+ }
+
+ return null;
+ }
+
+ // Sweeps the site rather than trusting references, so a dig that throws partway still leaves
+ // nothing behind for the next case: the chest, both dirt piles and any guardian spawned so far.
+ private static void Cleanup(Map map, Point3D site)
+ {
+ var leftovers = new List();
+
+ foreach (var m in map.GetMobilesInRange(site, 3))
+ {
+ leftovers.Add(m);
+ }
+
+ foreach (var item in map.GetItemsInRange(site, 1))
+ {
+ leftovers.Add(item);
+ }
+
+ foreach (var item in map.GetItemsInRange(site, 1))
+ {
+ leftovers.Add(item);
+ }
+
+ foreach (var entity in leftovers)
+ {
+ entity.Delete();
+ }
+ }
+
+ private static PlayerMobile CreatePlayerMobile(Map map, Point3D location)
+ {
+ var mobile = new PlayerMobile(World.NewMobile);
+ mobile.DefaultMobileInit();
+ mobile.MoveToWorld(location, map);
+ return mobile;
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs
new file mode 100644
index 000000000..b9f13a2f1
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs
@@ -0,0 +1,111 @@
+using Server.Items;
+using Xunit;
+
+namespace Server.Tests.Items;
+
+[Collection("Sequential UOContent Tests")]
+public class VendorBuybackPackTests
+{
+ [Fact]
+ public void AddBuyback_EvictsOldestFirst_WhenFull()
+ {
+ var pack = new VendorBuybackPack();
+ var items = new Item[5];
+
+ try
+ {
+ for (var i = 0; i < items.Length; i++)
+ {
+ items[i] = new Item(0x1234);
+ pack.AddBuyback(items[i], 3);
+ }
+
+ Assert.Equal(3, pack.Items.Count);
+ Assert.True(items[0].Deleted);
+ Assert.True(items[1].Deleted);
+ Assert.Same(items[2], pack.Items[0]);
+ Assert.Same(items[3], pack.Items[1]);
+ Assert.Same(items[4], pack.Items[2]);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+
+ [Fact]
+ public void AddBuyback_WithNoCapacity_ConsumesTheItem()
+ {
+ var pack = new VendorBuybackPack();
+ var item = new Item(0x1234);
+
+ try
+ {
+ pack.AddBuyback(item, 0);
+
+ Assert.True(item.Deleted);
+ Assert.Empty(pack.Items);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+
+ // DropItem rejects deleted items, so the stale entry is forced into Items directly.
+ [Fact]
+ public void AddBuyback_SkipsAlreadyDeletedEntry_WithoutSpinning()
+ {
+ var pack = new VendorBuybackPack();
+ var real1 = new Item(0x1234);
+ var stale = new Item(0x1234);
+ var real2 = new Item(0x1234);
+
+ try
+ {
+ pack.AddBuyback(real1, 100);
+
+ stale.Delete();
+ pack.Items.Insert(0, stale);
+
+ pack.AddBuyback(real2, 1);
+
+ Assert.True(real1.Deleted);
+ Assert.True(stale.Deleted);
+ Assert.Single(pack.Items);
+ Assert.Same(real2, pack.Items[0]);
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+
+ [Fact]
+ public void Purge_DeletesEveryChild()
+ {
+ var pack = new VendorBuybackPack();
+ var items = new Item[10];
+
+ 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);
+ for (var i = 0; i < items.Length; i++)
+ {
+ Assert.True(items[i].Deleted);
+ }
+ }
+ finally
+ {
+ pack.Delete();
+ }
+ }
+}
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
new file mode 100644
index 000000000..68be84e7b
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs
@@ -0,0 +1,41 @@
+using Server;
+using Server.Items;
+using Server.Misc;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class CorpseNotorietyTests
+{
+ [Theory]
+ [InlineData(0, Notoriety.Innocent)] // Human body
+ [InlineData(746, Notoriety.Innocent)] // Horrific Beast, a monster body
+ [InlineData(0, Notoriety.Murderer, 5)]
+ [InlineData(746, Notoriety.Murderer, 5)]
+ public void PlayerCorpseNotorietyIgnoresTransformedBody(int bodyMod, int expected, int kills = 0)
+ {
+ var victim = new PlayerMobile(World.NewMobile);
+ var looter = new PlayerMobile(World.NewMobile);
+ 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();
+ victim.Delete();
+ looter.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs
new file mode 100644
index 000000000..06d940512
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs
@@ -0,0 +1,70 @@
+using System;
+using System.IO;
+using System.Reflection;
+using Server.Configurations;
+using Server.Misc;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+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()
+ {
+ // Missing input must be ignored while email is disabled.
+ var report = Path.Combine(Path.GetTempPath(), $"crash-email-test-{Guid.NewGuid():N}", "Crash.log");
+
+ Email.SendCrashEmail(report);
+ }
+
+ [Fact]
+ public void SendQueueEmailDoesNothingWhileEmailIsDisabled()
+ {
+ // A null entry would throw on the enabled path, so returning cleanly shows the guard held.
+ Email.SendQueueEmail(null, "Other");
+ }
+
+ [Fact]
+ public void RetryWaitsDoubleFromTheConfiguredDelayAndStopAtTheLastAttempt()
+ {
+ TimeSpan[] expected =
+ [
+ TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(24)
+ ];
+
+ Assert.Equal(expected, Email.RetryDelays(5, 3));
+ }
+
+ [Theory]
+ [InlineData(int.MinValue)]
+ [InlineData(0)]
+ [InlineData(1)]
+ public void FewerThanTwoAttemptsNeverWait(int attempts)
+ {
+ Assert.Empty(Email.RetryDelays(attempts, 3));
+ }
+
+ [Theory]
+ [InlineData(8)]
+ [InlineData(int.MaxValue)]
+ public void RetryWaitsNeverOverflowAndStopDoublingAtAnHour(int firstDelaySeconds)
+ {
+ var delays = Email.RetryDelays(64, firstDelaySeconds);
+
+ Assert.All(delays, delay => Assert.InRange(delay, TimeSpan.Zero, TimeSpan.FromHours(1)));
+ Assert.Equal(TimeSpan.FromHours(1), delays[^1]);
+ }
+}
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
new file mode 100644
index 000000000..7bf6ffb5c
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs
@@ -0,0 +1,85 @@
+using System;
+using Server;
+using Server.Mobiles;
+using Server.Tests;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+[Collection("Sequential UOContent Tests")]
+public class DetectHiddenTimerTests
+{
+ private sealed class DetectingCreature : PetTestStub
+ {
+ public override bool PlayerRangeSensitive => false;
+ }
+
+ private sealed class DetectingAI : BaseAI
+ {
+ public int Detections { get; private set; }
+
+ public DetectingAI(BaseCreature creature) : base(creature)
+ {
+ }
+
+ public override bool Think() => true;
+
+ public override void DetectHidden() => Detections++;
+ }
+
+ [Theory]
+ [InlineData(0, 108000, 132000)]
+ [InlineData(1, 108000, 132000)]
+ [InlineData(100, 108000, 132000)]
+ [InlineData(250, 108000, 132000)]
+ [InlineData(251, 107100, 130900)]
+ [InlineData(1000, 27000, 33000)]
+ [InlineData(30000, 900, 1100)]
+ public void DetectHidden_SchedulesBoundedCooldown_AndWaitsBeforeRetry(
+ int intelligence, long minDelay, long maxDelay)
+ {
+ using var clock = new TimerWheelScope();
+ var creature = new DetectingCreature();
+ DetectingAI ai = null;
+
+ try
+ {
+ creature.Int = Math.Max(1, intelligence);
+ if (intelligence == 0)
+ {
+ creature.AddStatMod(new StatMod(StatType.Int, "DetectHiddenZeroInt", -1, TimeSpan.Zero));
+ }
+
+ Assert.Equal(intelligence, creature.Int);
+ creature.Skills.DetectHidden.Base = 100;
+ creature.MoveToWorld(new Point3D(1500, 1600, Map.Felucca.GetAverageZ(1500, 1600)), Map.Felucca);
+ creature.AIObject.AITimer.Stop();
+ ai = new DetectingAI(creature);
+ ai.AITimer.Activate();
+
+ AdvanceUntil(() => ai.Detections > 0, 1024);
+ Assert.Equal(1, ai.Detections);
+ Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay);
+ Assert.True(ai.AITimer.Running);
+
+ // 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);
+ Assert.Equal(2, ai.Detections);
+ Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay);
+ }
+ finally
+ {
+ ai?.AITimer.Stop();
+ creature.Delete();
+ }
+ }
+
+ 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
new file mode 100644
index 000000000..0161ae2dd
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs
@@ -0,0 +1,239 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+[Collection("Sequential UOContent Tests")]
+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()
+ {
+ 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);
+ var pet = OtherPet(owner, new Point3D(1499, 1600, z));
+ pet.IssueOrder(OrderType.Guard, owner);
+ pet.AIObject.AITimer.Stop();
+ return (owner, pet);
+ }
+
+ private PetTestStub OtherPet(PlayerMobile owner, Point3D location)
+ {
+ var pet = new PetTestStub();
+ pet.MoveToWorld(location, owner.Map);
+ pet.SetControlMaster(owner);
+ pet.AIObject.AITimer.Stop();
+ _created.Add(pet);
+ return pet;
+ }
+
+ [Fact]
+ public void GuardFallbackDoesNotAcquireItsOwner()
+ {
+ var (owner, pet) = Spawn();
+ Assert.Null(pet.ControlTarget); // speech dispatch clears this; guard must not fall through to wild acquisition.
+ Assert.True(pet.CanSee(owner));
+ Assert.True(pet.InLOS(owner));
+ pet.NextReacquireTime = Core.TickCount - 1;
+ Assert.False(pet.AIObject.AcquireFocusMob(pet.RangePerception, pet.FightMode, false, false, true));
+ Assert.Null(pet.FocusMob);
+ Assert.Null(pet.Combatant);
+ }
+
+ [Fact]
+ public void GuardDropsStaleCombatAgainstAnotherPetOfItsOwner()
+ {
+ var (owner, pet) = Spawn();
+ var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ pet.IssueOrder(OrderType.Attack, owner, sibling);
+ Assert.Same(sibling, pet.Combatant);
+ pet.IssueOrder(OrderType.Guard, owner);
+ pet.AIObject.DoOrderGuard();
+ Assert.Null(pet.Combatant);
+ Assert.Null(pet.FocusMob);
+ Assert.Equal(OrderType.Guard, pet.ControlOrder);
+ }
+
+ [Fact]
+ public void GuardDoesNotRetaliateAgainstOwnerFriendlyDamage()
+ {
+ var (owner, pet) = Spawn();
+ pet.AggressiveAction(owner, false);
+ Assert.Null(pet.Combatant);
+ Assert.Equal(OrderType.Guard, pet.ControlOrder);
+ }
+
+ [Fact]
+ public void GuardDoesNotRetaliateAgainstSiblingFriendlyDamage()
+ {
+ var (owner, pet) = Spawn();
+ var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ pet.AggressiveAction(sibling, false);
+ Assert.Null(pet.Combatant);
+ }
+
+ [Fact]
+ public void GuardReacquiresAnEnemyAttackingItsOwner()
+ {
+ var (owner, pet) = Spawn();
+ var enemy = new PetTestStub();
+ _created.Add(enemy);
+ enemy.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map);
+ enemy.AIObject.AITimer.Stop();
+ enemy.Combatant = owner;
+ Assert.Same(owner, enemy.Combatant);
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(enemy, pet.FocusMob);
+ }
+
+ [Fact]
+ public void GuardStillDefendsAgainstAnotherPlayersPet()
+ {
+ var (owner, pet) = Spawn();
+ var enemyOwner = new PlayerMobile();
+ _created.Add(enemyOwner);
+ enemyOwner.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map);
+ var enemy = OtherPet(enemyOwner, new Point3D(1498, 1600, owner.Z));
+ enemy.IssueOrder(OrderType.Attack, enemyOwner, owner);
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(enemy, pet.FocusMob);
+ }
+
+ [Fact]
+ public void GuardIgnoresSiblingInOwnersAggressorHistory()
+ {
+ var (owner, pet) = Spawn();
+ var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ owner.AggressiveAction(sibling, false);
+ Assert.Contains(owner.Aggressors, entry => entry.Attacker == sibling);
+ Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Null(pet.FocusMob);
+ }
+
+ [Fact]
+ public void GuardIgnoresOwnControlledSummonAttackingOwner()
+ {
+ var (owner, pet) = Spawn();
+ var summon = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ summon.Summoned = true;
+ summon.Master = owner;
+ summon.Combatant = owner;
+ Assert.Same(owner, summon.Combatant);
+ pet.AggressiveAction(summon, false);
+ Assert.Null(pet.Combatant);
+ Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Null(pet.FocusMob);
+ }
+
+ [Fact]
+ public void GuardDefendsAgainstOwnUncontrolledSummonAttackingOwner()
+ {
+ var (owner, pet) = Spawn();
+ var vortex = new PetTestStub { Summoned = true, Master = owner };
+ _created.Add(vortex);
+ vortex.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map);
+ vortex.AIObject.AITimer.Stop();
+ vortex.Combatant = owner;
+ Assert.Same(owner, vortex.Combatant);
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(vortex, pet.FocusMob);
+ }
+
+ [Fact]
+ public void FriendlyDamageDoesNotInterruptGuardCombat()
+ {
+ var (owner, pet) = Spawn();
+ var enemy = new PetTestStub();
+ _created.Add(enemy);
+ enemy.MoveToWorld(new Point3D(1496, 1600, owner.Z), owner.Map);
+ enemy.AIObject.AITimer.Stop();
+ pet.Combatant = enemy;
+ pet.AggressiveAction(owner, false);
+ Assert.Same(enemy, pet.Combatant);
+ }
+
+ [Fact]
+ public void ExplicitAttackStillAcquiresTheCommandedTarget()
+ {
+ var (owner, pet) = Spawn();
+ var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z));
+ pet.IssueOrder(OrderType.Attack, owner, sibling);
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(sibling, pet.FocusMob);
+ }
+
+ [Fact]
+ public void BardProvocationStillOverridesGuard()
+ {
+ var (owner, pet) = Spawn();
+ pet.BardProvoked = true;
+ pet.BardTarget = owner;
+ Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Same(owner, pet.FocusMob);
+ }
+
+ [Theory]
+ [InlineData(OrderType.Follow)]
+ [InlineData(OrderType.Come)]
+ [InlineData(OrderType.Stay)]
+ public void NonCombatOrdersDoNotAcquireTheirFriendlyTarget(OrderType order)
+ {
+ var (owner, pet) = Spawn();
+ pet.IssueOrder(order, owner, owner);
+ Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true));
+ Assert.Null(pet.FocusMob);
+ }
+
+ [Fact]
+ public void GuardingMageDoesNotTurnOnOwnerWhenEnemyDisappears()
+ {
+ var (owner, _) = Spawn();
+ 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();
+ mage.Combatant = enemy;
+ enemy.Hidden = true;
+ 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()
+ {
+ foreach (var mobile in _created)
+ {
+ mobile.Delete();
+ }
+ }
+}
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/PetFriendMembershipTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs
new file mode 100644
index 000000000..ddebd79f8
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs
@@ -0,0 +1,82 @@
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+[Collection("Sequential UOContent Tests")]
+public class PetFriendMembershipTests
+{
+ [Fact]
+ public void AddPetFriend_Twice_StoresOneMembership()
+ {
+ var pet = new PetTestStub();
+ var friend = new PlayerMobile();
+
+ try
+ {
+ pet.AddPetFriend(friend);
+ pet.AddPetFriend(friend);
+
+ Assert.Single(pet.Friends);
+ Assert.True(pet.IsPetFriend(friend));
+ }
+ finally
+ {
+ pet.Delete();
+ friend.Delete();
+ }
+ }
+
+ [Fact]
+ public void RemovePetFriend_AfterRepeatedAdd_RevokesMembership()
+ {
+ var pet = new PetTestStub();
+ var friend = new PlayerMobile();
+
+ try
+ {
+ pet.AddPetFriend(friend);
+ pet.AddPetFriend(friend);
+ pet.RemovePetFriend(friend);
+
+ Assert.False(pet.IsPetFriend(friend));
+ Assert.Null(pet.Friends);
+ }
+ finally
+ {
+ pet.Delete();
+ friend.Delete();
+ }
+ }
+
+ [Fact]
+ public void RemoveAndReadd_LeavesOtherFriendsUnchanged()
+ {
+ var pet = new PetTestStub();
+ var first = new PlayerMobile();
+ var second = new PlayerMobile();
+
+ try
+ {
+ pet.AddPetFriend(first);
+ pet.AddPetFriend(second);
+ pet.RemovePetFriend(first);
+
+ Assert.False(pet.IsPetFriend(first));
+ Assert.True(pet.IsPetFriend(second));
+ Assert.Single(pet.Friends);
+
+ pet.AddPetFriend(first);
+ Assert.True(pet.IsPetFriend(first));
+ Assert.True(pet.IsPetFriend(second));
+ Assert.Equal(2, pet.Friends.Count);
+ }
+ finally
+ {
+ pet.Delete();
+ first.Delete();
+ second.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
index ca30ee7d9..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)
{
@@ -762,7 +730,7 @@ public class PetOrderTests : IDisposable
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.Summoned = true;
- pet.SummonMaster = master;
+ pet.Master = master;
pet.ControlOrder = OrderType.Release;
@@ -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 d5074704c..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);
}
@@ -197,7 +191,7 @@ public class PetRetaliationTests : IDisposable
var summon = new StandDownPet();
summon.Summoned = true;
- summon.SummonMaster = caster;
+ summon.Master = caster;
summon.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
summon.AIObject.AITimer?.Stop();
_created.Add(summon);
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
new file mode 100644
index 000000000..1a2f6d737
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using Server;
+using Server.Mobiles;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles.AI;
+
+[Collection("Sequential UOContent Tests")]
+public class SummonAcquireRulesTests : IDisposable
+{
+ private readonly List _created = [];
+ private readonly Expansion _previous = Core.Expansion;
+
+ private PlayerMobile Player(Map map, int x, int intel)
+ {
+ var player = new PlayerMobile { Player = true, RawInt = intel };
+ player.MoveToWorld(new Point3D(x, 1600, map.GetAverageZ(x, 1600)), map);
+ _created.Add(player);
+ return player;
+ }
+
+ private BaseCreature Summon(BaseCreature summon, Mobile caster, int x)
+ {
+ _created.Add(summon);
+ summon.Summoned = true;
+ summon.Master = caster;
+ summon.MoveToWorld(new Point3D(x, 1600, caster.Map.GetAverageZ(x, 1600)), caster.Map);
+ summon.AIObject.AITimer.Stop();
+ return summon;
+ }
+
+ 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 PreAosFeluccaVortexPrefersAnyoneOverItsCaster()
+ {
+ Core.Expansion = Expansion.UOR;
+
+ // The caster outranks the bystander on the vortex's int-over-distance ranking.
+ var caster = Player(Map.Felucca, 1500, 100);
+ var bystander = Player(Map.Felucca, 1496, 10);
+ var vortex = Summon(new EnergyVortex(), caster, 1499);
+
+ Assert.True(Acquire(vortex));
+ Assert.Same(bystander, vortex.FocusMob);
+ }
+
+ [Theory]
+ [InlineData(Expansion.UOR, 1)] // Trammel
+ [InlineData(Expansion.AOS, 0)] // Felucca
+ public void VortexSparesItsCasterOutsidePreAosFelucca(Expansion expansion, int mapIndex)
+ {
+ Core.Expansion = expansion;
+
+ var caster = Player(Map.Maps[mapIndex], 1500, 100);
+ var vortex = Summon(new EnergyVortex(), caster, 1499);
+
+ Assert.False(Acquire(vortex));
+ Assert.Null(vortex.FocusMob);
+ }
+
+ [Fact]
+ public void MasterlessSummonDoesNotBreakAcquisition()
+ {
+ Core.Expansion = Expansion.AOS;
+
+ var player = Player(Map.Felucca, 1500, 100);
+ var hunter = new PetTestStub();
+ _created.Add(hunter);
+ hunter.MoveToWorld(new Point3D(1499, 1600, player.Z), player.Map);
+ hunter.AIObject.AITimer.Stop();
+
+ var masterless = new PetTestStub { Summoned = true };
+ _created.Add(masterless);
+ masterless.MoveToWorld(new Point3D(1498, 1600, player.Z), player.Map);
+ masterless.AIObject.AITimer.Stop();
+
+ Assert.True(Acquire(hunter));
+ }
+
+ public void Dispose()
+ {
+ Core.Expansion = _previous;
+
+ foreach (var mobile in _created)
+ {
+ mobile.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs
index 51a4de573..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);
@@ -182,22 +137,35 @@ public class BaseCreatureSerializationTests : IDisposable
Assert.Equal(master, copy.LastOwner);
}
- [Fact]
- public void UncontrolledSummon_KeepsItsSummonMaster()
+ // One serialized master; the Controlled and Summoned flags decide which views expose it.
+ [Theory]
+ [InlineData(true, false)] // controlled pet
+ [InlineData(true, true)] // controlled summon
+ [InlineData(false, true)] // energy vortex: summoned, never controlled
+ [InlineData(false, false)] // EnragedCreature: a summon master without Summoned
+ public void MasterShapes_RoundTrip(bool controlled, bool summoned)
{
var bc = NewCreature();
var master = NewMaster();
- // Energy vortex-style: summoned with a master, never controlled.
- bc.Summoned = true;
- bc.SummonMaster = master;
+ bc.Summoned = summoned;
+
+ if (controlled)
+ {
+ bc.SetControlMaster(master);
+ }
+ else
+ {
+ bc.Master = master;
+ }
var copy = Load(Snapshot(bc));
- Assert.True(copy.Summoned);
- Assert.False(copy.Controlled);
- Assert.Equal(master, copy.SummonMaster);
- Assert.Null(copy.ControlMaster);
+ Assert.Equal(controlled, copy.Controlled);
+ Assert.Equal(summoned, copy.Summoned);
+ Assert.Equal(master, copy.Master);
+ Assert.Equal(controlled ? master : null, copy.ControlMaster);
+ Assert.Equal(summoned ? master : null, copy.SummonMaster);
}
[Fact]
@@ -206,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;
@@ -232,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);
@@ -365,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,
@@ -377,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);
@@ -386,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);
@@ -418,8 +357,8 @@ public class BaseCreatureSerializationTests : IDisposable
Assert.Equal(0.6, copy.CurrentMoveSpeed); // passive mode, inheriting
}
- // ControlMaster and SummonMaster are independent references: a summon master can
- // exist without Summoned (EnragedCreature), and a controlled summon carries both.
+ // A master survives without Summoned (EnragedCreature), and a controlled summon reports the
+ // same mobile as both.
[Theory]
[InlineData(true, true, false, false)] // controlled pet
[InlineData(true, true, true, true)] // controlled summon, SummonEnd on the wire
@@ -445,11 +384,87 @@ public class BaseCreatureSerializationTests : IDisposable
Assert.Equal(controlled, copy.Controlled);
Assert.Equal(summoned, copy.Summoned);
Assert.Equal(hasControlMaster ? master : null, copy.ControlMaster);
- Assert.Equal(hasSummonMaster ? master : null, copy.SummonMaster);
+ Assert.Equal(master, copy.Master);
+ Assert.Equal(summoned ? master : null, copy.SummonMaster);
if (summoned)
{
Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1));
}
}
+
+ // Before summon ownership followed transfers, a traded summon kept its caster as summon master.
+ [Fact]
+ public void LegacyV22Stream_DifferingMasters_OwnerWins()
+ {
+ var owner = NewMaster();
+ var caster = NewMaster();
+
+ var copy = LoadLegacyV22(true, owner, true, caster, Core.Now + TimeSpan.FromMinutes(5));
+
+ Assert.Equal(owner, copy.ControlMaster);
+ Assert.Equal(owner, copy.SummonMaster);
+ }
+
+ // v23 bit positions: every save-flagged field in schema order (DefaultAI is not flagged).
+ private const int V23Controlled = 13;
+ private const int V23ControlMaster = 14;
+ private const int V23Summoned = 20;
+ private const int V23SummonEnd = 21;
+ private const int V23SummonMaster = 22;
+ private const int V23RemoveStep = 50;
+ private const int V23CorpseNameOverride = 52;
+
+ // v23 wrote the owner and the summoner to separate slots; v24 keeps one master.
+ [Fact]
+ public void V23Stream_MigratesToOneMaster_AndDefaultsAbsentFields()
+ {
+ var owner = NewMaster();
+ var caster = NewMaster();
+ var summonEnd = Core.Now + TimeSpan.FromMinutes(5);
+
+ var donor = new MobileStub();
+ donor.DefaultMobileInit();
+ _harness.Track(donor);
+
+ var writer = new BufferWriter(true);
+ donor.Serialize(writer);
+
+ writer.Write(23); // version
+ writer.Write(
+ 1UL << V23Controlled | 1UL << V23ControlMaster | 1UL << V23Summoned | 1UL << V23SummonEnd |
+ 1UL << V23SummonMaster | 1UL << V23RemoveStep | 1UL << V23CorpseNameOverride
+ );
+ writer.WriteEncodedInt((int)AIType.AI_Melee); // DefaultAI
+ writer.Write(owner); // ControlMaster
+ writer.WriteAnchoredTime(summonEnd); // SummonEnd
+ writer.Write(caster); // SummonMaster
+ writer.WriteEncodedInt(3); // RemoveStep
+ writer.Write("a migrated corpse"); // CorpseNameOverride
+
+ var buffer = new byte[writer.Position];
+ writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
+
+ var copy = Load(buffer);
+
+ Assert.True(copy.Controlled);
+ Assert.True(copy.Summoned);
+ Assert.Equal(owner, copy.ControlMaster);
+ Assert.Equal(owner, copy.SummonMaster);
+ Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1));
+ Assert.Equal(3, copy.RemoveStep);
+ Assert.Equal("a migrated corpse", copy.CorpseNameOverride);
+
+ // Absent fields take the class defaults, not default(T).
+ Assert.Equal(AIType.AI_Melee, copy.AI);
+ Assert.Equal(-1, copy.HitsMaxSeed);
+ Assert.Equal(1, copy.ControlSlots);
+ Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty);
+ Assert.Equal(100, copy.PhysicalDamage);
+ Assert.Equal(10, copy.RangeHome);
+ Assert.Equal(FightMode.Closest, copy.FightMode);
+ Assert.Equal(0.3, copy.ActiveSpeed);
+ Assert.Equal(0.6, copy.PassiveSpeed);
+ Assert.NotNull(copy.Owners);
+ }
}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs
new file mode 100644
index 000000000..be314b575
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs
@@ -0,0 +1,87 @@
+using System;
+using Server;
+using Server.Mobiles;
+using Server.Spells.Necromancy;
+using Server.Tests;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles;
+
+// A deleted master must not leave creatures pointing at it.
+[Collection("Sequential UOContent Tests")]
+public class DeletedMasterTests : IDisposable
+{
+ private readonly MobileTestHarness _harness = new();
+
+ public void Dispose() => _harness.Dispose();
+
+ private TestCreature NewCreature() => _harness.NewCreature();
+
+ private PlayerMobile NewPlayer() => _harness.NewPlayer();
+
+ [Fact]
+ public void DeletingAPlayer_ReleasesAnUnbondedPet()
+ {
+ var owner = NewPlayer();
+ var pet = NewCreature();
+ pet.SetControlMaster(owner);
+
+ owner.Delete();
+
+ Assert.False(pet.Deleted);
+ Assert.False(pet.Controlled);
+ Assert.Null(pet.Master);
+ Assert.True(pet.PendingDeleteTimer?.Running);
+ }
+
+ [Theory]
+ [InlineData(false)] // bonded
+ [InlineData(true)] // bonded and dead
+ public void DeletingAPlayer_DeletesABondedPet(bool dead)
+ {
+ var owner = NewPlayer();
+ var pet = NewCreature();
+ pet.SetControlMaster(owner);
+ pet.IsBonded = true;
+ pet.IsDeadPet = dead;
+
+ owner.Delete();
+
+ Assert.True(pet.Deleted);
+ }
+
+ [Theory]
+ [InlineData(false)] // energy vortex
+ [InlineData(true)] // controlled summon
+ public void DeletingAPlayer_DeletesItsSummons(bool controlled)
+ {
+ var caster = NewPlayer();
+ var summon = NewCreature();
+
+ if (controlled)
+ {
+ summon.SetControlMaster(caster);
+ }
+
+ summon.Summoned = true;
+ summon.Master = caster;
+
+ caster.Delete();
+
+ Assert.True(summon.Deleted);
+ }
+
+ [Fact]
+ public void DeletingAFamiliar_UnregistersIt()
+ {
+ var caster = NewPlayer();
+ var familiar = NewCreature();
+ familiar.SetControlMaster(caster);
+ familiar.Summoned = true;
+ SummonFamiliarSpell.Table[caster] = familiar;
+
+ familiar.Delete();
+
+ Assert.False(SummonFamiliarSpell.Table.ContainsKey(caster));
+ }
+}
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
new file mode 100644
index 000000000..bb027a951
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs
@@ -0,0 +1,123 @@
+using System;
+using Server;
+using Server.Mobiles;
+using Server.Tests;
+using Xunit;
+
+namespace UOContent.Tests.Mobiles;
+
+// One stored master, read through gates on Controlled and Summoned.
+[Collection("Sequential UOContent Tests")]
+public class MasterViewsTests : IDisposable
+{
+ private readonly MobileTestHarness _harness = new();
+
+ public void Dispose() => _harness.Dispose();
+
+ private TestCreature NewCreature() => _harness.NewCreature();
+
+ private PlayerMobile NewPlayer() => _harness.NewPlayer();
+
+ public enum Shape
+ {
+ Wild,
+ Pet,
+ ControlledSummon,
+ UncontrolledSummon,
+ Enraged
+ }
+
+ [Theory]
+ [InlineData(Shape.Wild)]
+ [InlineData(Shape.Pet)]
+ [InlineData(Shape.ControlledSummon)]
+ [InlineData(Shape.UncontrolledSummon)]
+ [InlineData(Shape.Enraged)]
+ public void Views_ByShape(Shape shape)
+ {
+ var bc = NewCreature();
+ var master = NewPlayer();
+
+ switch (shape)
+ {
+ case Shape.Pet:
+ {
+ bc.SetControlMaster(master);
+ break;
+ }
+ case Shape.ControlledSummon:
+ {
+ bc.SetControlMaster(master);
+ bc.Summoned = true;
+ break;
+ }
+ case Shape.UncontrolledSummon:
+ {
+ bc.Summoned = true;
+ bc.Master = master;
+ break;
+ }
+ case Shape.Enraged:
+ {
+ bc.Master = master;
+ break;
+ }
+ }
+
+ var controlled = shape is Shape.Pet or Shape.ControlledSummon;
+ var summoned = shape is Shape.ControlledSummon or Shape.UncontrolledSummon;
+
+ Assert.Equal(shape == Shape.Wild ? null : master, bc.Master);
+ Assert.Equal(controlled ? master : null, bc.ControlMaster);
+ Assert.Equal(summoned ? master : null, bc.SummonMaster);
+ Assert.Equal(controlled || summoned ? master : null, bc.GetMaster());
+ }
+
+ [Fact]
+ public void Master_Reassignment_MovesFollowerSlots()
+ {
+ var bc = NewCreature();
+ bc.ControlSlots = 2;
+ var first = NewPlayer();
+ var second = NewPlayer();
+
+ bc.Master = first;
+ Assert.Equal(2, first.Followers);
+
+ bc.Master = second;
+ Assert.Equal(0, first.Followers);
+ Assert.Equal(2, second.Followers);
+ Assert.Null(bc.ControlMaster); // not Controlled
+
+ bc.Master = null;
+ Assert.Equal(0, second.Followers);
+ }
+
+ [Fact]
+ public void SetControlMasterNull_KeepsAnUncontrolledSummonsCaster()
+ {
+ var bc = NewCreature();
+ var caster = NewPlayer();
+ bc.Summoned = true;
+ bc.Master = caster;
+
+ bc.SetControlMaster(null);
+
+ Assert.Equal(caster, bc.SummonMaster);
+ Assert.Equal(bc.ControlSlots, caster.Followers);
+ }
+
+ [Fact]
+ public void Delete_ReturnsTheCastersFollowerSlots()
+ {
+ var bc = NewCreature();
+ var caster = NewPlayer();
+ bc.Summoned = true;
+ bc.Master = caster;
+
+ bc.Delete();
+
+ Assert.Equal(0, caster.Followers);
+ Assert.Null(bc.Master);
+ }
+}
diff --git a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs
new file mode 100644
index 000000000..fac93cb54
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections.Generic;
+using Server.Items;
+using Server.Mobiles;
+using Xunit;
+
+namespace Server.Tests.Mobiles;
+
+[Collection("Sequential UOContent Tests")]
+public class VendorBuybackTests : IDisposable
+{
+ private readonly MobileTestHarness _harness = new();
+
+ public void Dispose() => _harness.Dispose();
+
+ private sealed class BandageSB : SBInfo
+ {
+ private readonly GenericSellInfo _sellInfo = new();
+
+ public BandageSB() => _sellInfo.Add(typeof(Bandage), 2);
+
+ public override IShopSellInfo SellInfo => _sellInfo;
+
+ public override List BuyInfo { get; } = [];
+ }
+
+ private sealed class BandageVendor : TestVendor
+ {
+ 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.
+ private sealed class FullStockSB : SBInfo
+ {
+ private readonly GenericSellInfo _sellInfo = new();
+ private readonly List _buyInfo = [];
+
+ public FullStockSB()
+ {
+ _sellInfo.Add(typeof(Bandage), 2);
+
+ for (var i = 0; i < 250; i++)
+ {
+ _buyInfo.Add(new GenericBuyInfo(typeof(Bandage), 5, 20, 0xE21, 0));
+ }
+ }
+
+ public override IShopSellInfo SellInfo => _sellInfo;
+
+ public override List BuyInfo => _buyInfo;
+ }
+
+ private sealed class FullStockVendor : TestVendor
+ {
+ protected override void AddSBInfos(List sbInfos) => sbInfos.Add(new FullStockSB());
+ }
+
+ private BandageVendor NewVendor() => _harness.Track(new BandageVendor());
+
+ private FullStockVendor NewFullStockVendor() => _harness.Track(new FullStockVendor());
+
+ private Mobile NewSeller()
+ {
+ var seller = _harness.Track(new Mobile(World.NewMobile));
+ seller.DefaultMobileInit();
+ seller.AddItem(new Backpack());
+ 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
+ [InlineData(5.5, 6.0)] // lazy restock hasn't run for hours: stay on the grid, don't fire now
+ public void NextBuybackPurge_IsTheNextRestockBoundaryAfterNow(double hoursSinceRestock, double expectedHours)
+ {
+ var lastRestock = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var now = lastRestock + TimeSpan.FromHours(hoursSinceRestock);
+
+ var next = BaseVendor.GetNextBuybackPurge(lastRestock, TimeSpan.FromHours(1), now);
+
+ Assert.Equal(lastRestock + TimeSpan.FromHours(expectedHours), next);
+ }
+
+ // Backwards clock adjustment: LastRestock ahead of now clamps to now.
+ [Fact]
+ public void NextBuybackPurge_FutureAnchor_IsClampedToNow()
+ {
+ var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var lastRestock = now + TimeSpan.FromHours(4);
+
+ var next = BaseVendor.GetNextBuybackPurge(lastRestock, TimeSpan.FromHours(1), now);
+
+ Assert.Equal(now + TimeSpan.FromHours(1), next);
+ }
+
+ [Fact]
+ public void PartialStackSale_SplitGoesToBuyback_SellerKeepsRemainder()
+ {
+ var vendor = NewVendor();
+ var bandages = SellBandages(vendor, 4, out var seller);
+
+ Assert.Equal(6, bandages.Amount);
+ Assert.Same(seller.Backpack, bandages.Parent);
+
+ var buyback = vendor.BuyPack.Items;
+ Assert.Single(buyback);
+ Assert.IsType(buyback[0]);
+ Assert.Equal(4, buyback[0].Amount);
+ Assert.True(buyback[0].SkipSerialization);
+ Assert.True(vendor.BuybackPurgeScheduled);
+ }
+
+ [Fact]
+ public void Restock_PurgesBuyback()
+ {
+ var vendor = NewVendor();
+ var bandages = SellBandages(vendor, 10);
+ Assert.Single(vendor.BuyPack.Items);
+
+ vendor.Restock();
+
+ Assert.Empty(vendor.BuyPack.Items);
+ Assert.True(bandages.Deleted);
+ }
+
+ [Fact]
+ public void Restock_ClearsPurgeSchedule_AndASubsequentSaleRearmsIt()
+ {
+ var vendor = NewVendor();
+ SellBandages(vendor, 10);
+ Assert.True(vendor.BuybackPurgeScheduled);
+
+ vendor.Restock();
+
+ Assert.False(vendor.BuybackPurgeScheduled);
+
+ SellBandages(vendor, 10);
+
+ Assert.True(vendor.BuybackPurgeScheduled);
+ }
+
+ [Fact]
+ public void Delete_CancelsBuybackPurgeTimer()
+ {
+ var vendor = NewVendor();
+ SellBandages(vendor, 10);
+ Assert.True(vendor.BuybackPurgeScheduled);
+
+ vendor.Delete();
+
+ Assert.False(vendor.BuybackPurgeScheduled);
+ }
+
+ [Fact]
+ public void LegacyBuyPack_IsReplaced_AndItsContentsDeleted()
+ {
+ var vendor = NewVendor();
+ vendor.FindItemOnLayer(Layer.ShopBuy).Delete();
+
+ var legacy = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false };
+ vendor.AddItem(legacy);
+
+ // 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);
+ legacy.DropItem(children[i]);
+ }
+
+ var pack = vendor.BuyPack;
+
+ Assert.IsType(pack);
+ Assert.Same(pack, vendor.FindItemOnLayer(Layer.ShopBuy));
+ Assert.True(legacy.Deleted);
+ for (var i = 0; i < children.Length; i++)
+ {
+ Assert.True(children[i].Deleted);
+ }
+ }
+
+ [Fact]
+ public void SaleWithNoBuybackCapacity_ConsumesItem_AndStillPays()
+ {
+ var vendor = NewFullStockVendor();
+ var bandages = SellBandages(vendor, 10, out var seller);
+
+ Assert.True(bandages.Deleted);
+ Assert.Empty(vendor.BuyPack.Items);
+ Assert.False(vendor.BuybackPurgeScheduled);
+
+ var gold = seller.Backpack.FindItemByType();
+ Assert.NotNull(gold);
+ Assert.Equal(20, gold.Amount);
+ }
+}
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 1dc4440ae..1d7f20e68 100644
--- a/Projects/UOContent.Tests/UOContent.Tests.csproj
+++ b/Projects/UOContent.Tests/UOContent.Tests.csproj
@@ -4,14 +4,14 @@
Debug;Release;Analyze
-
+
-
+