From 6c49038230e6d24c968238e949db8c6b0c69ef77 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:36:20 -0700 Subject: [PATCH 01/26] fix: Fixes spellbook property invalidation when spells change. (#2702) --- Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs index 9447fddfa..34f1bc419 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -108,6 +108,7 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; + [InvalidateProperties] [SerializableField(8)] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _spellCount; @@ -135,7 +136,6 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem [SerializableField(7, fieldChanged: nameof(OnContentChanged))] [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] private ulong _content; private void OnContentChanged(ulong oldValue, ulong newValue) From 577a4da60c0a17dfdd0e5cce85b42c7717a7fca2 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:13:34 -0700 Subject: [PATCH 02/26] fix: restart relaunches correctly when the server path contains a space (#2704) `Core.DoKill(true)` built the relaunch command line by putting the assembly path into a raw argument string without quotes, so `dotnet` split it at the first space. That breaks `restart`, `AutoRestart` and `CrashGuard` recovery for any server installed under a path with a space. - Pass the assembly path through `ProcessStartInfo.ArgumentList`, which quotes it. - Relaunch through the host that started the process (`Environment.ProcessPath`): the `ModernUO` apphost runs directly, and `dotnet` gets the assembly path. Before this, a server started from the apphost came back under `dotnet`. - Use one code path on all platforms. `UseShellExecute = true` on Unix made no difference for a program found on PATH. This doesn't fix the second half of #2700, where the relaunched server loses the console (and gets killed under systemd or Docker). That comes from the server spawning a copy of itself and exiting, and a follow-up PR will add an opt-in restart exit code for a supervisor to act on. **Testing:** relaunched from a directory with spaces, started both through `dotnet x.dll` and through the apphost, on Windows. The old code fails there with the error from the issue. --- Projects/Server/Main.cs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 608256651..2daef9a2d 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -473,22 +473,17 @@ public static class Core 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) From eff9de028136b11e544fd81375d1e0e710e22dd6 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:18:25 -0700 Subject: [PATCH 03/26] feat: optional restart exit code so a supervisor owns the relaunch (#2705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2700 (with #2704) A restart (`restart` console command, `AutoRestart`, `CrashGuard`) launches a copy of the server and exits. That copy is detached from the console it was started from: the shell takes the terminal back and console commands stop working. Under systemd or in a container it's killed as soon as the original process exits. - New setting `server.restartExitCode` (off by default). When it's set, a restart saves and shuts down as usual and then exits with that code, so a shell loop, systemd unit or container restart policy can start the server again on the same console. Values outside 1–255 log a warning and fall back to the current behaviour. - Fixes `CrashGuard` crash recovery never restarting the server. Since #1809, `Kill()` only records the request, and the unhandled-exception path called `DoKill()` without it. - Documents systemd, Docker and shell-loop setups in `dev-docs/server-requirements.md`. --- Projects/Server/Main.cs | 25 +++++++++++++++++++++- Projects/UOContent/Misc/CrashGuard.cs | 2 +- dev-docs/server-requirements.md | 30 +++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 2daef9a2d..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,6 +472,12 @@ 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 @@ -625,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/UOContent/Misc/CrashGuard.cs b/Projects/UOContent/Misc/CrashGuard.cs index 1a6e12cc1..522fd755e 100644 --- a/Projects/UOContent/Misc/CrashGuard.cs +++ b/Projects/UOContent/Misc/CrashGuard.cs @@ -13,7 +13,7 @@ namespace Server.Misc private static bool Enabled; private static bool SaveBackup; - private static bool RestartServer; // Disable this if using a daemon/service + private static bool RestartServer; // Under a daemon/service, set server.restartExitCode rather than disabling this private static bool GenerateReport; public static void Configure() diff --git a/dev-docs/server-requirements.md b/dev-docs/server-requirements.md index 1447ebf67..2bbfc2f25 100644 --- a/dev-docs/server-requirements.md +++ b/dev-docs/server-requirements.md @@ -126,6 +126,36 @@ See the README for the full supported list. Two things are worth calling out: - **Linux kernel 6.1** or newer (Debian 12 and equivalents). io_uring is used where available, with automatic epoll fallback. +## Restarting under a supervisor + +By default a restart (`restart` console command, `AutoRestart`, `CrashGuard` crash recovery) +launches a fresh copy of the server and exits. That copy is detached from the console you started +the server in, and a systemd unit or container kills it as soon as the original process exits. + +If something else keeps the server running, let it do the relaunch instead. Set a restart exit code +in `Distribution/Configuration/modernuo.json`: + +```json +"settings": { + "server.restartExitCode": "75" +} +``` + +A restart then shuts down and saves as usual and exits with that code (1–255) instead of launching +a copy. A normal `shutdown` still exits with `0`. `75` is a suggestion; any code your supervisor +does not use for something else works. Then restart on that code: + +- **systemd**: `Restart=on-failure` in the `[Service]` section. +- **Docker**: `restart: on-failure` in Compose, or `--restart on-failure`. +- **Shell loop** (Linux/macOS), keeps the server on the same terminal: + ```sh + while :; do dotnet ModernUO.dll; [ $? -eq 75 ] || break; done + ``` +- **PowerShell** (Windows): + ```powershell + do { dotnet ModernUO.dll } while ($LASTEXITCODE -eq 75) + ``` + ## Tuning for a small host | Setting | Default | Why change it | From 6ce2b4770328c898ce2e16ef599afce87e976571 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:54:25 -0700 Subject: [PATCH 04/26] fix: Updates dependencies (#2714) --- Projects/Server.Tests/Server.Tests.csproj | 4 ++-- Projects/Server/Server.csproj | 6 +++--- Projects/UOContent.Tests/UOContent.Tests.csproj | 6 +++--- Projects/UOContent/UOContent.csproj | 12 +++++++----- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 8ae939ce1..3d1190779 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,10 +5,10 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 3730f1dc8..49a152e28 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -37,10 +37,10 @@ - + - - + + diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 8b83f7992..85ec80f45 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,14 +4,14 @@ Debug;Release;Analyze - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 5789eabc6..a5584e7ee 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -40,18 +40,20 @@ false + + - - - + + + - - + + From 6b411944b3e2e78e0c20c07a2ab2b060468db4eb Mon Sep 17 00:00:00 2001 From: Jascen Date: Sat, 17 Oct 2026 16:55:17 -0400 Subject: [PATCH 05/26] fix: Skip karma message when karma change is zero (#2710) ## Summary `SendKarmaMessage` maps the karma change to a message, and its fallback arm is "You have lost a little karma." (1019063). A change of `0` lands in that arm, so the player is told they lost karma when nothing changed. How to reproduce on a clean build: kill a creature with 0 karma, such as a grey wolf. The kill awards 0 karma and the player sees "You have lost a little karma." This change returns early when the change is `0`, which matches how `AwardFame` already handles fame: it sends nothing when fame doesn't change. ## Test plan - [x] `dotnet build` succeeds - [x] Kill a 0-karma creature: no karma message - [x] Kill a negative-karma creature: "gained karma" message is unchanged --- Projects/UOContent/Misc/Titles.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Projects/UOContent/Misc/Titles.cs b/Projects/UOContent/Misc/Titles.cs index c5f9f2a18..f047bf758 100644 --- a/Projects/UOContent/Misc/Titles.cs +++ b/Projects/UOContent/Misc/Titles.cs @@ -262,6 +262,11 @@ namespace Server.Misc private static void SendKarmaMessage(Mobile m, int offset) { + if (offset == 0) + { + return; + } + var message = offset switch { > 40 => 1019062, // You have gained a lot of karma. From 24ebce6f84b7d1646777c55ed231360dec0f1916 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:58:04 +0200 Subject: [PATCH 06/26] fix(ai): swap GetDelay bounds so min is the lower one (#2711) GetDelay sets min to 6.0 - del * 0.75 and max to 6.0 - del * 1.25, so for any Magery above zero min is the larger value. The draw min + (max - min) * r still covers the right interval, but it runs downward and the names say the opposite. Swap the two definitions so min is the lower bound. The interval and its uniform distribution do not change; for a given random draw the delay comes out mirrored. --- Projects/UOContent/Mobiles/AI/MageAI.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index 1ed8cbdda..11f02f1aa 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -616,8 +616,8 @@ public class MageAI : BaseAI } var del = ScaleBySkill(3.0, SkillName.Magery); - var min = 6.0 - del * 0.75; - var max = 6.0 - del * 1.25; + var min = 6.0 - del * 1.25; + var max = 6.0 - del * 0.75; return TimeSpan.FromSeconds(min + (max - min) * Utility.RandomDouble()); } From f4ba6036fe89a40d91f72c014b561c51cfb5396d Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:41:58 +0200 Subject: [PATCH 07/26] fix: fishing catch chances by era and the leviathan's treasure map (#2709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #2687. Magic fish and footwear roll the same in every era: magic fish get rarer with skill (5% at 0 skill, 1% at GM) and footwear has a 20% chance at 0 skill. The leviathan rolls the generic 1% for its level 5 map. A fishing table row can now end at an expansion, so each era gets its own magic fish and footwear rows. The chances at GM come from the Fishing Council of Britannia's cast counts before and after Publish 16, which came out in parts from April to December 2002, during LBR [O14]. The leviathan gets its 25% through a `BaseCreature.TreasureMapChance` override; leviathans only come from the fabled net, which already needs AOS. | Change | Era | Source | |---|---|---| | Footwear: 75% at 0 skill, 2.5% at GM | T2A to UOTD | "The beginning fisherman will find this item on his hook most of the times" [S1–S7]; 2.5% of catches [F1] | | Footwear: 75% at 0 skill, 10% at GM | LBR on | The same quote; 10.5% of catches [F2] | | Magic fish: 80 skill, 2.5% at GM | T2A to UOTD | 80 from July 1999 to October 2002 [S1–S6, S9]; 2.4% of catches [F1] | | Magic fish: no minimum, 5% at GM | LBR to SA | 0 from December 2002 [S7], so the minimum went in late 2002, during LBR; 5.6% of catches [F2] | | Magic fish: 80 skill again | HS on | Still 0 in June 2010, during SA [S13]; 80 today [O9]. HS is the first expansion after that | | Leviathan's map: 25% | AOS | Publish 27: "It also has a 25% chance to carry a level 5 treasure map." [O5] | - These are chances per roll. The FCB's counts leave out empty casts, so they line up once the "nothing" row #2687 lists is gone (a follow-up PR removes it); while it stays, footwear is 3.2% of a GM's catches before LBR and 12% from LBR. - No source counts footwear below GM, so 75% at 0 skill only follows Stratics' "most of the times". The numbers in my comment were placeholders. - 80 again from HS is the one change beyond my comment, which proposed no minimum from LBR on. The keys are #2687's, plus: - [O14] uo.com, the 2002 publishes: Publish 16 in parts on April 23, July 23, September 20 and December 2, 2002. https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/ - [F1] The Fishin' Statistician, 11,101 casts from August 2001 to April 2002, before Publish 16; skill not stated, most likely GM. https://web.archive.org/web/20020609120859/http://www.thefcb.com:80/novellettes/stats.html - [F2] The same page, 7,371 casts from July 2002 to February 2003, after Publish 16; skill not stated either. https://web.archive.org/web/20030216074120/http://www.thefcb.com:80/novellettes/stats.html - [S13] Stratics' fishing guide, captured June 5, 2010. https://web.archive.org/web/20100605065528/http://uo.stratics.com:80/content/skills/fishing.shtml --- .../Tests/Engines/Harvest/FishingTests.cs | 108 ++++++++++++++++++ Projects/UOContent/Engines/Harvest/Fishing.cs | 40 ++++--- Projects/UOContent/Mobiles/BaseCreature.cs | 4 +- .../Monsters/Reptile/Magic/Leviathan.cs | 2 + 4 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs new file mode 100644 index 000000000..d77cadcae --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -0,0 +1,108 @@ +using System; +using Server; +using Server.Engines.Harvest; +using Server.Items; +using Server.Mobiles; +using Server.Random; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class FishingTests +{ + private const int Casts = 100_000; + + // On land in Britain, so a cast there never counts as deep water. + private static readonly Point3D _shallow = new(1434, 1699, 0); + + private static readonly Type[] _magicFish = + [typeof(PrizedFish), typeof(WondrousFish), typeof(TrulyRareFish), typeof(PeculiarFish)]; + + private static readonly Type[] _footwear = [typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)]; + + [Theory] + [InlineData(Expansion.None, 50.0, 0.0, 0.0)] + [InlineData(Expansion.T2A, 0.0, 0.0, 0.75)] + [InlineData(Expansion.T2A, 50.0, 0.0, 0.3877)] + [InlineData(Expansion.T2A, 100.0, 0.025, 0.0247)] + [InlineData(Expansion.UOTD, 80.0, 0.02, 0.1669)] + [InlineData(Expansion.LBR, 0.0, 0.0, 0.75)] + [InlineData(Expansion.LBR, 50.0, 0.025, 0.4519)] + [InlineData(Expansion.AOS, 100.0, 0.05, 0.095)] + [InlineData(Expansion.SA, 50.0, 0.025, 0.4519)] + [InlineData(Expansion.HS, 50.0, 0.0, 0.4635)] + [InlineData(Expansion.HS, 120.0, 0.06, 0.047)] + [InlineData(Expansion.EJ, 120.0, 0.06, 0.047)] + public void MutateType_MagicFishAndFootwear_FollowTheEra( + Expansion expansion, double skill, double magicFishRate, double footwearRate + ) + { + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(World.NewMobile); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.DefaultMobileInit(); + fisher.Skills.Fishing.Base = skill; + + Assert.Equal(skill, fisher.Skills.Fishing.Value); + Assert.False(SpecialFishingNet.FullValidation(Map.Felucca, _shallow.X, _shallow.Y)); + + var definition = Fishing.System.Definitions[0]; + var magicFish = 0; + var footwear = 0; + + for (var i = 0; i < Casts; i++) + { + var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _shallow, null); + + if (Array.IndexOf(_magicFish, type) >= 0) + { + magicFish++; + } + else if (Array.IndexOf(_footwear, type) >= 0) + { + footwear++; + } + } + + AssertRate(magicFishRate, magicFish); + AssertRate(footwearRate, footwear); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void Leviathan_CarriesItsTreasureMapAQuarterOfTheTime() + { + var leviathan = new Leviathan(); + var serpent = new SeaSerpent(); + + try + { + Assert.Equal(0.25, leviathan.TreasureMapChance); + Assert.Equal(TreasureMap.LootChance, serpent.TreasureMapChance); + } + finally + { + leviathan.Delete(); + serpent.Delete(); + } + } + + // Within five standard deviations of the expected rate; a rate of zero must never happen. + private static void AssertRate(double expected, int count) + { + var tolerance = 5 * Math.Sqrt(expected * (1 - expected) / Casts); + Assert.InRange((double)count / Casts, expected - tolerance, expected + tolerance); + } +} diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs index b45c44b72..d3784ac3d 100644 --- a/Projects/UOContent/Engines/Harvest/Fishing.cs +++ b/Projects/UOContent/Engines/Harvest/Fishing.cs @@ -10,24 +10,29 @@ namespace Server.Engines.Harvest { private static Fishing _system; + private static readonly Type[] _magicFish = + { + typeof(PrizedFish), typeof(WondrousFish), typeof(TrulyRareFish), typeof(PeculiarFish) + }; + + private static readonly Type[] _footwear = { typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots) }; + private static readonly MutateEntry[] _mutateTable = { new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(SpecialFishingNet)), new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(BigFish)), new(90.0, 80.0, 4080.0, true, Expansion.T2A, typeof(TreasureMap)), new(100.0, 80.0, 4080.0, true, Expansion.T2A, typeof(MessageInABottle)), - new( - 0.0, - 125.0, - -2375.0, - false, - Expansion.T2A, - typeof(PrizedFish), - typeof(WondrousFish), - typeof(TrulyRareFish), - typeof(PeculiarFish) - ), - new(0.0, 105.0, -420.0, false, Expansion.T2A, typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)), + // T2A-UOTD: needs 80; 2.0% at 80, 2.5% at 100. + // LBR-SA: no minimum; 0% at 0, 5% at 100. + // HS onward: needs 80; 4.0% at 80, 5.0% at 100. + new(80.0, 0.0, 4000.0, false, Expansion.T2A, Expansion.UOTD, _magicFish), + new(0.0, 0.0, 2000.0, false, Expansion.LBR, Expansion.SA, _magicFish), + new(80.0, 0.0, 2000.0, false, Expansion.HS, _magicFish), + new(0.0, 103.5, -34.5, false, Expansion.T2A, Expansion.UOTD, _footwear), + // From LBR the two footwear rows make one curve: 75% at 0 skill, 10% at 100 and 5% at 120. + new(0.0, 100.0, -62.5, false, Expansion.LBR, _footwear), + new(0.0, 140.0, -260.0, false, Expansion.LBR, _footwear), new(0.0, 200.0, -200.0, false, Expansion.None, new Type[] { null }) }; @@ -174,7 +179,7 @@ namespace Server.Engines.Harvest { var entry = _mutateTable[i]; - if (Core.Expansion < entry.m_Expansion) + if (Core.Expansion < entry.m_Expansion || Core.Expansion > entry.m_MaxExpansion) { continue; } @@ -541,6 +546,7 @@ namespace Server.Engines.Harvest { public readonly bool m_DeepWater; public readonly Expansion m_Expansion; + public readonly Expansion? m_MaxExpansion; public readonly double m_MaxSkill; public readonly double m_MinSkill; public readonly double m_ReqSkill; @@ -548,6 +554,13 @@ namespace Server.Engines.Harvest public MutateEntry( double reqSkill, double minSkill, double maxSkill, bool deepWater, Expansion expansion, params Type[] types + ) : this(reqSkill, minSkill, maxSkill, deepWater, expansion, null, types) + { + } + + public MutateEntry( + double reqSkill, double minSkill, double maxSkill, bool deepWater, Expansion expansion, + Expansion? maxExpansion, params Type[] types ) { m_ReqSkill = reqSkill; @@ -555,6 +568,7 @@ namespace Server.Engines.Harvest m_MaxSkill = maxSkill; m_DeepWater = deepWater; m_Expansion = expansion; + m_MaxExpansion = maxExpansion; m_Types = types; } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f0210d4f6..bd28ff590 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1310,6 +1310,8 @@ namespace Server.Mobiles public virtual int TreasureMapLevel => -1; + public virtual double TreasureMapChance => TreasureMap.LootChance; + public virtual bool IgnoreYoungProtection => false; public bool NoKillAwards { get; set; } @@ -3246,7 +3248,7 @@ namespace Server.Mobiles { PackItem(new ParagonChest(Name, treasureLevel)); } - else if ((Map == Map.Felucca || Map == Map.Trammel) && Utility.RandomDouble() < TreasureMap.LootChance) + else if ((Map == Map.Felucca || Map == Map.Trammel) && Utility.RandomDouble() < TreasureMapChance) { PackItem(new TreasureMap(treasureLevel, Map)); } diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs index e4ffe5ba3..e4adbf05e 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs @@ -62,6 +62,8 @@ namespace Server.Mobiles public override int TreasureMapLevel => 5; + public override double TreasureMapChance => 0.25; + public override bool OnBeforeDeath() { PackItem(new MessageInABottle(Map == Map.Felucca ? Map.Felucca : Map.Trammel)); From 257b4c8c1b892cd57d096d9eba8aed42cd970fb7 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:17:46 +0200 Subject: [PATCH 08/26] fix: male rangers no longer leak StuddedLegs (#2712) ShortPants and StuddedLegs both occupy Layer.Pants, so the second EquipItem on a male ranger is refused and its item stays in the world with no parent. Nothing deletes such an item, so every male ranger created leaves one behind in the save. StuddedLegs already cover the layer and female rangers wear them, so the male ranger now skips the shorts. A test creates and deletes rangers and checks that World.Items does not grow. --- Projects/UOContent/Mobiles/Hireables/HireRanger.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Projects/UOContent/Mobiles/Hireables/HireRanger.cs b/Projects/UOContent/Mobiles/Hireables/HireRanger.cs index d7349c1b8..ed228ba49 100644 --- a/Projects/UOContent/Mobiles/Hireables/HireRanger.cs +++ b/Projects/UOContent/Mobiles/Hireables/HireRanger.cs @@ -43,7 +43,6 @@ public partial class HireRanger : BaseHire { Body = 0x190; Name = NameList.RandomName("male"); - EquipItem(new ShortPants(Utility.RandomNeutralHue())); } EquipItem(new Shoes(Utility.RandomNeutralHue())); From 410a9647891c517620695abaa5c33b3c2c6fe1c8 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:17:11 -0700 Subject: [PATCH 09/26] fix: NPC outfits no longer stack two items on one layer (#2715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to #2712, which covers HireRanger's refused `EquipItem`. A sweep that built every constructible Mobile 2,000 times under each expansion found these other outfits that put two items on one layer. They don't leak, because `AddItem` skips `CheckEquip`, but the NPC wears both items and the client draws only one. | NPC | Conflict | |---|---| | Miner, Ranger, Thief | Own shirt/pants/ThighBoots added on top of the random `BaseVendor.InitOutfit` (shoes doubled on every spawn) | | MasterTheophilus | Shoes + Robe added over `EvilMageLord`'s Sandals + Robe | | Merchant | Shirt + FancyShirt, or PlainDress + FancyDress | | IronWorker | Bandana/JesterHat + Bascinet | | MeerCaptain | One-handed sword + Crossbow | | BakeKitsune | `BodyMod` isn't saved, so after a restart `RemoveDisguise` bailed and the next `Disguise()` stacked a second robe and pair of shoes | ## Changes - `BaseCreature.ReplaceEquipment(item)` equips an item in place of whatever an inherited outfit put on its layer, and deletes the item if it still can't be equipped. - Miner, Ranger, Thief, MasterTheophilus: their own pieces replace the inherited ones. - Merchant and IronWorker: dropped the duplicate roll. Each kept the item the client was already drawing, which is the last one sent: the fancy set and the Bascinet. - MeerCaptain: the sword goes into its pack as loot. **Behavior change:** `Mobile.Weapon` checks the one-handed layer first, so it was fighting with the sword despite its archer AI, its Archery skill and the bolts in its pack. It now uses the crossbow. - BakeKitsune: `RemoveDisguise` strips the disguise without checking `IsBodyMod`. ## Testing - I re-ran the sweep (local diagnostic, not committed; needs client tiledata) at 500 rolls × 12 expansions. Only HireRanger (#2712) and LordOaks (separate fix coming) remain. - UOContent.Tests: 1218 passed, 2 skipped. --- Projects/UOContent/Mobiles/BaseCreature.cs | 14 ++++++++++++++ .../Mobiles/Monsters/LBR/Meers/MeerCaptain.cs | 3 ++- .../Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs | 4 ++-- .../UOContent/Mobiles/Monsters/SE/BakeKitsune.cs | 7 ++----- Projects/UOContent/Mobiles/Townfolk/Merchant.cs | 9 --------- .../UOContent/Mobiles/Vendors/NPC/IronWorker.cs | 8 -------- Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs | 6 +++--- Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs | 6 +++--- Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs | 6 +++--- 9 files changed, 29 insertions(+), 34 deletions(-) diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index bd28ff590..af619e005 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -5724,6 +5724,20 @@ namespace Server.Mobiles } } + /// + /// Equips in place of whatever an inherited outfit put on its layer. + /// AddItem would stack both on one layer, and EquipItem would refuse and orphan the item. + /// + public void ReplaceEquipment(Item item) + { + FindItemOnLayer(item.Layer)?.Delete(); + + if (!EquipItem(item)) + { + item.Delete(); + } + } + public virtual void HealStart(Mobile patient) { StopHeal(); diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerCaptain.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerCaptain.cs index d4b8eaf82..c7e7ab5d4 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerCaptain.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerCaptain.cs @@ -47,7 +47,8 @@ namespace Server.Mobiles pack.DropItem(new Bolt(Utility.RandomMinMax(10, 20))); pack.DropItem(new Bolt(Utility.RandomMinMax(10, 20))); - AddItem( + // The crossbow is wielded; the sword would share its hands, so it is carried as loot. + pack.DropItem( Utility.Random(6) switch { 0 => new Longsword(), diff --git a/Projects/UOContent/Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs b/Projects/UOContent/Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs index fd3747095..d326916de 100644 --- a/Projects/UOContent/Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs +++ b/Projects/UOContent/Mobiles/Monsters/ML/Bedlam/MasterTheophilus.cs @@ -42,8 +42,8 @@ namespace Server.Mobiles Fame = 18000; Karma = -18000; - AddItem(new Shoes(0x537)); - AddItem(new Robe(0x452)); + ReplaceEquipment(new Shoes(0x537)); + ReplaceEquipment(new Robe(0x452)); for (var i = 0; i < 2; ++i) { diff --git a/Projects/UOContent/Mobiles/Monsters/SE/BakeKitsune.cs b/Projects/UOContent/Mobiles/Monsters/SE/BakeKitsune.cs index 96a8954d6..1e00abeb9 100644 --- a/Projects/UOContent/Mobiles/Monsters/SE/BakeKitsune.cs +++ b/Projects/UOContent/Mobiles/Monsters/SE/BakeKitsune.cs @@ -176,11 +176,8 @@ namespace Server.Mobiles { _disguiseTimerToken.Cancel(); - if (!IsBodyMod) - { - return; - } - + // BodyMod is not saved, so after a restart a disguised kitsune comes back without it but still + // wearing the disguise; strip it regardless. Name = null; Title = null; BodyMod = 0; diff --git a/Projects/UOContent/Mobiles/Townfolk/Merchant.cs b/Projects/UOContent/Mobiles/Townfolk/Merchant.cs index e5867c645..f9e5425df 100644 --- a/Projects/UOContent/Mobiles/Townfolk/Merchant.cs +++ b/Projects/UOContent/Mobiles/Townfolk/Merchant.cs @@ -33,15 +33,6 @@ public partial class Merchant : BaseEscortable public override void InitOutfit() { - if (Female) - { - AddItem(new PlainDress()); - } - else - { - AddItem(new Shirt(GetRandomHue())); - } - var lowHue = GetRandomHue(); AddItem(new ThighBoots()); diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/IronWorker.cs b/Projects/UOContent/Mobiles/Vendors/NPC/IronWorker.cs index e3ef3b2a5..a75e3efad 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/IronWorker.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/IronWorker.cs @@ -58,14 +58,6 @@ namespace Server.Mobiles AddItem(new FullApron(Utility.RandomBrightHue())); } - AddItem( - Utility.Random(3) switch - { - 0 => new Bandana(Utility.RandomBrightHue()), - _ => new JesterHat(Utility.RandomBrightHue()) // 1-2 - } - ); - AddItem(new Bascinet()); AddItem(new SmithHammer()); diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs index 3c8c33c77..9d8a439df 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Miner.cs @@ -26,10 +26,10 @@ namespace Server.Mobiles { base.InitOutfit(); - AddItem(new FancyShirt(0x3E4)); - AddItem(new LongPants(0x192)); + ReplaceEquipment(new FancyShirt(0x3E4)); + ReplaceEquipment(new LongPants(0x192)); AddItem(new Pickaxe()); - AddItem(new ThighBoots(0x283)); + ReplaceEquipment(new ThighBoots(0x283)); } } } diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs index 7a4f499ba..4e8a9d462 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Ranger.cs @@ -31,10 +31,10 @@ namespace Server.Mobiles { base.InitOutfit(); - AddItem(new Shirt(Utility.RandomNeutralHue())); - AddItem(new LongPants(Utility.RandomNeutralHue())); + ReplaceEquipment(new Shirt(Utility.RandomNeutralHue())); + ReplaceEquipment(new LongPants(Utility.RandomNeutralHue())); AddItem(new Bow()); - AddItem(new ThighBoots(Utility.RandomNeutralHue())); + ReplaceEquipment(new ThighBoots(Utility.RandomNeutralHue())); } } } diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs b/Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs index f9c802295..031bc4c97 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/Thief.cs @@ -31,10 +31,10 @@ namespace Server.Mobiles { base.InitOutfit(); - AddItem(new Shirt(Utility.RandomNeutralHue())); - AddItem(new LongPants(Utility.RandomNeutralHue())); + ReplaceEquipment(new Shirt(Utility.RandomNeutralHue())); + ReplaceEquipment(new LongPants(Utility.RandomNeutralHue())); AddItem(new Dagger()); - AddItem(new ThighBoots(Utility.RandomNeutralHue())); + ReplaceEquipment(new ThighBoots(Utility.RandomNeutralHue())); } } } From b2c9b18ca1197bd6f3cbe87de6d8ed9640c6c1b5 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:34:10 +0200 Subject: [PATCH 10/26] fix: fishing by era: deep water, serpents, SOS, big fish and magic fish (#2718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #2687, after #2709. One commit per section, in the same order. ### Failures, maps, bottles and colored nets A GM's cast fails about a quarter of the time in any water, from the fishing table's last row, and deep water is no harder than a river. Maps and bottles are 0.5% per roll from T2A on, and 1 net in 100 is colored in every era. This removes the last row, so a cast only fails its skill check. From UO:R a cast in deep water checks skill from 65 to 115, through a `Fishing.MutateResource` override. Publish 5, which "was known as the Renaissance Publish", made deep water harder: "because the difficulty level is higher, the rate of success for pulling up SoS bottles will be lower." [O3] From LBR maps and bottles come up more often and 1 net in 10 is colored, both from Publish 16, which came out in parts during LBR [O14]. | Change | Era | Source | |---|---|---| | No "nothing" row | All | #2687 found no source for it; "A prepatch, you never failed catching something at GM" [S15]; a GM succeeds every time in lakes and rivers [C1] | | Deep water: nothing below 65 skill | UO:R on | "The minimum required skill level to catch *anything* in deep water is 65." [S5–S7] | | Deep water: 10% at 70, 70% at GM | UO:R on | "Even at 70, it is only about a 10% chance of success in deep water." [S14]; 29% of casts fail before Publish 16 and 30% after [F1, F2]; 70% success at 100 offshore [C1] | | Maps (skill − 80) / 2400, bottles (skill − 80) / 1500 | LBR on | A bottle "about once per hour" until October 2002 [S1–S6], then "a special item (a net, a message in a bottle or a treasure map) about five times per hour" [S7]; bottles went from 0.4% of catches before Publish 16 [F1] to 1.7% after [F2] | | Colored nets: none, then 10% | LBR on | "With Publish 16, we got a nice surprise--colored special fish nets!" [F4]; "Any net created has a 10% chance of being colored." [F3] | - From LBR a GM's deep-water catches are 0.8% maps and 1.3% bottles. Chrome counted 0.9% and 1.3% [C1], the FCB 0.7% and 1.7% after Publish 16 [F2]. Nets and big fish stay at 0.5%, close to both counts. - At Stratics' 280 casts an hour [S1–S6], that is about one bottle an hour from UO:R to UOTD and five nets, maps or bottles an hour from LBR, the two rates Stratics gives. - The gain chance rises as the success chance falls, so above 65 a cast in deep water gains faster than one near land, Publish 5's "best method for gaining at high levels" [O3]. Below 65 it neither catches nor gains. - Chrome's table also has three kinds of water near land, with difficulties of 15, 35 and 55. No other source has them, so near land stays at 0–100. ### Deep water is at least 14 tiles from land `SpecialFishingNet.FullValidation` decides what counts as deep water: where a special fishing net works, where maps, bottles, nets and big fish can be caught, and from UO:R where a cast is harder. It checks the target and 20 points on its four diagonals, 5 to 25 tiles out. Nothing off those points is read, so a spot 8 tiles from shore can count as deep, while one with land 15 to 25 tiles away on a diagonal does not. The sources give one rule in every era: land at least 14 tiles away. This checks every land tile within 13 of the target. ModernUO has no server lines inside a map, so the only boundary is the map's edge, which counts as land as before. Only land tiles are read, so statics such as rocks are ignored. `SOS.FindLocation` had its own copy of the old check and now calls `FullValidation`, so wrecks follow the same rule. - 1999 to 2000: "which means you must be at least 14 tiles away from all land" [S1–S4] - March 2001: "14 tiles away from any land mass" [F5] - 2001 to 2002: "which means you must be at least 14 tiles away from all land and server lines" [S5–S7] - Today: "You need to be a minimum of 14 tiles from land or a server boundary to be in ‘deep water’." [O9] One guide from January 2000 says "16 tiles away from land" [S9]; every other source says 14. - In the area an SOS picks from on Felucca, 59% of spots pass the old check and 63% pass the new one; in Ilshenar's two sea regions, 16% and 30%. - The check reads up to 729 land tiles instead of 21: about 6 µs per check in a Release build, against under 0.1 µs before; a cast makes at most two. ### Sea serpents In ModernUO a sea serpent comes up from fishing only with a treasure map, a bottle or a net, which it carries. A quarter are deep sea serpents. It has no timer, and nothing makes it attack the fisherman first. This follows the notes of March 28, 1999 and Publish 4. A fished serpent attacks the fisherman at once. Five minutes after it surfaced it goes back to deep water, taking what it carries, unless someone is still fighting it: it took damage in the last two minutes and has not healed to full since, which is what ModernUO's damage entries record. From T2A, at 80 skill or more in deep water, 1% of casts also bring up a serpent that carries nothing, whether the cast catches something or not. 1 serpent in 10 is a deep sea serpent. | Change | Era | Source | |---|---|---| | Attacks the fisherman at once | All | "Sea Serpents will now immediately attack the fisherman that brought them from the deep." [O1] | | Goes back after 5 minutes unless fought | All | "Sea Serpents will return to deep water after 5 minutes if not attacked." [O1]; "will return to the depths after a brief period if they are not engaged" [S10] | | 1% of casts bring up an empty serpent | T2A on | "Fishermen at over 80 skill will have a chance to pull up a sea serpent regardless of whether they succeed or fail at fishing when in deep sea." [O2]; 1% at 80 or more, before the skill check [B1] | | 1 in 10 is a deep sea serpent | All | "A small percentage of Sea Serpents will be Deep-Sea Serpents." [O1]; about 10% [B1]; 9.8% and 10.8% of the serpents counted [F1, F2] | - Publish 4 gives no rate. The 1% comes from a post by Hance, a UO developer, in December 2000 [B1]; T2A uses it too, since it is the rule Publish 4 brought in (our inference). The FCB's counts agree: 1.76% of casts brought up a serpent and 55% of serpents carried nothing, so about 1% of casts brought up an empty one [F1] (our calculation). - Before Publish 4 serpents came up "on failure" [O1], and Stratics kept "if you are fishing with 80+ skill and you fail" from July 1999 to 2002 [S1–S7]. Publish 4 is the last rule in T2A, so it is the one used from T2A. - Hance also relays a side comment that the empty serpent cancels the rest of the cast [B1]. If it did, no cast could bring up two serpents, but the FCB saw two from one cast on 0.96% of serpents [F1], and Chrome caught empty serpents together with fish and footwear [C1]. So the cast goes on. - Hance's post also needs room in the backpack for a fish and fish left in the spot [B1]. A spot with no fish left already ends the cast before the roll, unless an SOS keeps the cast going (next section). A full backpack doesn't: ModernUO doesn't check for room before a cast, so neither does the serpent. - Serpents from a special fishing net are unchanged; Hance's post says nets don't follow its formula [B1]. ### The SOS chest In ModernUO, a fisherman carrying an SOS within 60 tiles of its spot brings up the chest on 1 catch in 8 and a shipwrecked item on the other 7. The SOS also lets a cast go on where the fish have run out. This makes the chest 1 catch in 4, in every era. From UO:R an SOS needs 65 skill, and below that the cast is an ordinary one. | Change | Era | Source | |---|---|---| | The chest on 1 catch in 4 | All | "You have a 25% chance of getting the chest and a 75% chance of getting something else with each cast." [F3]; "a 25% chance to fish up the actual treasure chest" [S7] | | An SOS needs 65 skill | UO:R on | 65 in the chest's row of Stratics' table: "The skills mentioned above are minimum skills needed to fish up the corresponding item." [S5–S7] | - No source gives a number before October 2002, or any for ModernUO's 1 in 8, so the 25% applies in every era. The other catches keep ModernUO's seven kinds of shipwrecked items, equally likely. Hanse's die splits them in three: "One roll is body parts; another roll is shells, barrel staves, globes, etc.; another roll is paintings, broken clocks, etc.; and another is the chest itself." [F3] - Stratics' guide of November 2000 has neither the chest's row nor the 65 for deep water [S4]. The May 2001 version, during Third Dawn, has both: "The minimum required skill level to catch *anything* in deep water is 65." [S5] The first section applies that from UO:R, after Publish 5 made deep water harder [O3], so the chest follows it (our inference). An SOS's spot is picked in deep water, but a cast within 60 tiles of it can be nearer land, so the 65 is checked on its own. - Below 65 there are no shipwrecked items either, because Hanse's die is rolled once you "can even attempt to get a treasure chest" [F3] (our reading). Before UO:R an SOS still works at any skill; the only period line on it is "You do not have to be GM Fisherman to actually fish up the treasure from a bottle, only to fish up A bottle." [S9] ### Fishing reveals from T2A ModernUO reveals a hidden fisherman from ML. The notes of March 28, 1999 already say "If you are hidden, fishing will now unhide you." [O1], so this reveals from T2A, at the same two points: the splash and the end of the cast. ### Big fish and the leviathan's artifact In ModernUO every big fish weighs 3 to 200 stones and gives max(16, weight) / 4 steaks, and the net thrower has a 1 in 4 chance at an artifact at any skill. | Change | Era | Source | |---|---|---| | Big fish weigh 100 stones | Before SE | "They weigh 100 stones each" [S5–S7]; Publish 28 "Added variable weight and fisherman's mark on big fish" [O6] | | A steak per 5 stones, at least 4 | SE on | "now its weight / 5 will determine number of fish steaks (minimum of 4)" [O7] | | The net thrower's own chance at an artifact: 1 in 4, only at GM | SE on | "Give the fabled net thrower (if a GM Fisherman) a 1 in 4 chance of getting a minor artifact (not modified by luck or damage)." [O6] | - Before SE a big fish keeps ModernUO's weight / 4 steaks, 25 for 100 stones: no source gives a number before Publish 29. - Publish 27 names only the attackers' chance: "Minor Artifacts will have a chance of spawning in each attacker's backpack after a paragon creature or a leviathan is slain." [O5] Publish 28 adds the thrower's roll [O6], so before SE there is none (our inference). The attackers' chance is unchanged. ### Magic fish From SE the prized, wondrous and truly rare fish give +1 for 12 seconds instead of +5 for a minute [C1]. A Stratics FAQ of January 5, 2005 already says "The stat boost fish used to provide the person who ate it a +5 boost, but now it's only one or two points, and lasts only a short while." [S16] - Stratics still gives +5 in December 2002 [S7] and no note dates the change. [S16], during SE, is the earliest source with the new values, so the eras before SE keep +5 and ModernUO's minute (our inference). - Today's guide gives "Adds 8 – 12% to your Dexterity for 150 seconds when eaten" [O9]. No note dates that change either, so it is left out. ### The white pearl at an SOS's spot From ML the white pearl is a bonus on top of the catch, but it went through `Fishing.Construct`'s SOS branch: at an SOS's spot the fisherman read "You have found a white pearl!" and got a shipwrecked item or the chest, a second SOS roll in the same cast. The pearl now skips that branch. The keys are #2687's, plus: - [O14] uo.com, the 2002 publishes: Publish 16 in parts on April 23, July 23, September 20 and December 2, 2002. https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/ - [S14] Stratics fishing board, FAQ 1.2 "How do I train fishing?", January 5, 2005. https://web.archive.org/web/20050410171430/http://boards.stratics.com/php-bin/uo/showthreaded.php?Cat=&Board=uofisherman&Number=5622892 - [S15] Stratics fishing board, a post on Publish 5's SOS change, April 25, 2000. https://web.archive.org/web/20000511000424/http://boards.stratics.com:80/cgi-bin/forums/showflat.pl?Cat=21&Board=uofisherman&Number=144300&page=0&view=collapsed&sb=5 - [S16] The FAQ of [S14], 1.4 "What's with the horned leather and scales? (Resources)", January 5, 2005, in the FAQ's flat view. https://web.archive.org/web/20050410171356/http://boards.stratics.com/php-bin/uo/showflat.php?Cat=&Number=5622877&page=0&view=collapsed&sb=5&o=&fpart=1 - [F1] The Fishin' Statistician, 11,101 casts from August 2001 to April 2002, before Publish 16. Skill and water not stated; most likely GM and mostly deep water, since the counts include big fish and serpents. https://web.archive.org/web/20020609120859/http://www.thefcb.com:80/novellettes/stats.html - [F2] The same page, 7,371 casts from July 2002 to February 2003, after Publish 16, with nets, maps and bottles counted too. https://web.archive.org/web/20030216074120/http://www.thefcb.com:80/novellettes/stats.html - [F3] The FCB's interview with Hanse, an OSI designer, October 21, 2002. https://web.archive.org/web/20021104194424/http://www.thefcb.com:80/Hanse.htm - [F4] The FCB's encyclopaedia, colored nets, last modified July 13, 2002. https://web.archive.org/web/20020715111230/http://www.thefcb.com:80/encyclopaedia/colornets.html - [F5] The FCB's "Illustrated Treatise on Deep Water Defined", by Captain Pickles and Mason, last modified March 20, 2001. https://web.archive.org/web/20010419135823/http://www.thefcb.com:80/novellettes/deep-water.html - [B1] Brikan's skill guide, which quotes a post on Origin's boards by Hance, a UO developer, on December 20, 2000; most likely Hanse, the OSI designer the FCB interviewed in 2002. Page last modified February 3, 2007. https://web.archive.org/web/20070715201811/http://www.jgnn.com:80/uo/bk/2skill/skill.htm - [C1] Chrome's fishing page on OSI's Yamato shard, captured November 25, 2007: 5,000 offshore catches at GM, a table of success by water and what each magic fish does. https://web.archive.org/web/20071125144104/http://www.geocities.co.jp:80/Playtown-Yoyo/4812/fishing02.html --- .../Tests/Engines/Harvest/FishingTests.cs | 698 ++++++++++++++++++ Projects/UOContent/Engines/Harvest/Fishing.cs | 320 ++++---- .../Items/Resources/Fishing/BigFish.cs | 5 +- .../Items/Resources/Fishing/MagicFish.cs | 9 +- .../Items/Skill Items/Fishing/Misc/SOS.cs | 48 +- .../Fishing/Misc/SpecialFishingNet.cs | 50 +- .../Monsters/Reptile/Magic/Leviathan.cs | 2 +- 7 files changed, 883 insertions(+), 249 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs index d77cadcae..65ea66386 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using Server; using Server.Engines.Harvest; using Server.Items; using Server.Mobiles; using Server.Random; +using Server.Targeting; +using Server.Tests; using Xunit; namespace UOContent.Tests; @@ -16,6 +19,9 @@ public class FishingTests // On land in Britain, so a cast there never counts as deep water. private static readonly Point3D _shallow = new(1434, 1699, 0); + // Open sea with no land within 30 tiles: deep water once the client's map is loaded. + private static readonly Point3D _deep = new(1840, 1760, -5); + private static readonly Type[] _magicFish = [typeof(PrizedFish), typeof(WondrousFish), typeof(TrulyRareFish), typeof(PeculiarFish)]; @@ -99,10 +105,702 @@ public class FishingTests } } + [Theory] + [InlineData(Expansion.None, 0.0)] + [InlineData(Expansion.T2A, 100.0)] + [InlineData(Expansion.UOR, 50.0)] + [InlineData(Expansion.LBR, 0.0)] + [InlineData(Expansion.EJ, 120.0)] + public void MutateType_NeverComesBackEmpty(Expansion expansion, double skill) + { + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(World.NewMobile); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.DefaultMobileInit(); + fisher.Skills.Fishing.Base = skill; + + var definition = Fishing.System.Definitions[0]; + + for (var i = 0; i < Casts; i++) + { + var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _shallow, null); + Assert.NotNull(type); + } + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [SkippableTheory] + [InlineData(Expansion.T2A, 100.0, 0.005, 0.004975)] + [InlineData(Expansion.UOTD, 100.0, 0.00495, 0.004925)] + [InlineData(Expansion.LBR, 95.0, 0.0062, 0.0)] + [InlineData(Expansion.LBR, 100.0, 0.00825, 0.01309)] + [InlineData(Expansion.HS, 120.0, 0.016335, 0.0257)] + [InlineData(Expansion.EJ, 120.0, 0.016335, 0.0257)] + public void MutateType_DeepWaterMapsAndBottles_FollowTheEra( + Expansion expansion, double skill, double mapRate, double bottleRate + ) + { + TileDataRequirement.SkipIfMissing(); + Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); + + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(World.NewMobile); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.DefaultMobileInit(); + fisher.Skills.Fishing.Base = skill; + + var definition = Fishing.System.Definitions[0]; + var maps = 0; + var bottles = 0; + + for (var i = 0; i < Casts; i++) + { + var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _deep, null); + + if (type == typeof(TreasureMap)) + { + maps++; + } + else if (type == typeof(MessageInABottle)) + { + bottles++; + } + } + + AssertRate(mapRate, maps); + AssertRate(bottleRate, bottles); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [SkippableTheory] + [InlineData(Expansion.T2A, true, 0.0, 100.0)] + [InlineData(Expansion.UOR, false, 0.0, 100.0)] + [InlineData(Expansion.UOR, true, 65.0, 115.0)] + [InlineData(Expansion.EJ, true, 65.0, 115.0)] + public void MutateResource_DeepWaterIsHarderFromUOR(Expansion expansion, bool deep, double minSkill, double maxSkill) + { + if (deep) + { + TileDataRequirement.SkipIfMissing(); + Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); + } + + var previousExpansion = Core.Expansion; + var fisher = new PlayerMobile(World.NewMobile); + + try + { + Core.Expansion = expansion; + fisher.DefaultMobileInit(); + + var definition = Fishing.System.Definitions[0]; + var vein = definition.Veins[0]; + var resource = Fishing.System.MutateResource( + fisher, null, definition, Map.Felucca, deep ? _deep : _shallow, vein, vein.PrimaryResource, null + ); + + Assert.Equal(minSkill, resource.MinSkill); + Assert.Equal(maxSkill, resource.MaxSkill); + } + finally + { + fisher.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.T2A, 0.0)] + [InlineData(Expansion.UOTD, 0.0)] + [InlineData(Expansion.LBR, 0.1)] + [InlineData(Expansion.EJ, 0.1)] + public void SpecialFishingNet_IsColoredOneTimeInTenFromLBR(Expansion expansion, double coloredRate) + { + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + + var colored = 0; + + for (var i = 0; i < Casts; i++) + { + var net = new SpecialFishingNet(); + + if (net.Hue != 0x8A0) + { + colored++; + } + + net.Delete(); + } + + AssertRate(coloredRate, colored); + } + finally + { + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [SkippableTheory] + [InlineData(1840, 1760, true)] // land 115 tiles away + [InlineData(1660, 1760, true)] // land 14 tiles away + [InlineData(1659, 1760, false)] // land 13 tiles away + [InlineData(2188, 1000, true)] // land 23 tiles away, on a diagonal + [InlineData(1718, 1913, false)] // land 8 tiles away, between the diagonals + [InlineData(13, 200, true)] // the edge of the map 14 tiles away + [InlineData(12, 200, false)] // the edge of the map 13 tiles away + public void FullValidation_NeedsLandAndTheMapEdgeAtLeast14TilesAway(int x, int y, bool deep) + { + TileDataRequirement.SkipIfMissing(); + + Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, x, y)); + } + + [SkippableFact] + public void SOS_FindLocation_PicksDeepWater() + { + TileDataRequirement.SkipIfMissing(); + + var previousGenerator = BuiltInRng.Generator; + + try + { + BuiltInRng.Generator = new System.Random(1); + + for (var i = 0; i < 100; i++) + { + var location = SOS.FindLocation(Map.Felucca); + + Assert.NotEqual(Point3D.Zero, location); + Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, location.X, location.Y)); + } + } + finally + { + BuiltInRng.Generator = previousGenerator; + } + } + + [Fact] + public void Give_SurfacesASerpentThatAttacksAndIsADeepSeaSerpentOneTimeInTen() + { + const int serpents = 2_000; + + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + BuiltInRng.Generator = new System.Random(1); + fisher.MoveToWorld(_deep, Map.Felucca); + + var deepSeaSerpents = 0; + + for (var i = 0; i < serpents; i++) + { + var bottle = new MessageInABottle(Map.Felucca); + + Assert.True(Fishing.System.Give(fisher, bottle, false)); + + var serpent = Assert.IsAssignableFrom(bottle.RootParent); + Assert.Same(fisher, serpent.Combatant); + + if (serpent is DeepSeaSerpent) + { + deepSeaSerpents++; + } + + serpent.Delete(); + } + + AssertRate(0.1, deepSeaSerpents, serpents); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + } + } + + [Theory] + [InlineData(null, false)] + [InlineData(2.5, false)] + [InlineData(4.5, true)] + public void Give_SerpentGoesBackAfterFiveMinutesUnlessHitInTheLastTwo(double? hitAtMinutes, bool stays) + { + Core._tickCount = 0; + Timer.Init(0); + + var previousNow = Core._now; + var previousHitsRate = Mobile.DefaultHitsRate; + var fisher = new PlayerMobile(); + BaseCreature serpent = null; + + try + { + // The rate RegenRates sets before AOS, so the hit is not healed before the serpent's five minutes are up. + Mobile.DefaultHitsRate = TimeSpan.FromSeconds(11.0); + fisher.MoveToWorld(_deep, Map.Felucca); + + var bottle = new MessageInABottle(Map.Felucca); + Fishing.System.Give(fisher, bottle, false); + + serpent = Assert.IsAssignableFrom(bottle.RootParent); + serpent.AIObject?.AITimer.Stop(); + serpent.Combatant = null; + + var elapsed = TimeSpan.Zero; + + if (hitAtMinutes is { } minutes) + { + elapsed = TimeSpan.FromMinutes(minutes); + Advance(elapsed); + serpent.Damage(20, fisher); + Assert.Same(fisher, serpent.FindMostRecentDamager(false)); + } + + Advance(TimeSpan.FromMinutes(4.9) - elapsed); + Assert.False(serpent.Deleted); + + Advance(TimeSpan.FromMinutes(0.2)); + Assert.Equal(!stays, serpent.Deleted); + Assert.Equal(!stays, bottle.Deleted); + } + finally + { + serpent?.Delete(); + fisher.Delete(); + Mobile.DefaultHitsRate = previousHitsRate; + Core._now = previousNow; + } + } + + [SkippableTheory] + [InlineData(Expansion.None, 100.0, true, 0.0)] + [InlineData(Expansion.T2A, 79.9, true, 0.0)] + [InlineData(Expansion.T2A, 80.0, true, 0.01)] + [InlineData(Expansion.T2A, 100.0, false, 0.0)] + [InlineData(Expansion.EJ, 120.0, true, 0.01)] + public void OnHarvestFinished_SurfacesAnEmptySerpentOneCastInAHundred( + Expansion expansion, double skill, bool deep, double serpentRate + ) + { + TileDataRequirement.SkipIfMissing(); + Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); + + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.Skills.Fishing.Base = skill; + fisher.MoveToWorld(_deep, Map.Felucca); + + var definition = Fishing.System.Definitions[0]; + var target = new LandTarget(deep ? _deep : _shallow, Map.Felucca); + var surfaced = new List(); + var serpents = 0; + + for (var i = 0; i < Casts; i++) + { + Fishing.System.OnHarvestFinished(fisher, null, definition, null, null, null, target); + + foreach (var serpent in Map.Felucca.GetMobilesInRange(fisher.Location, 10)) + { + surfaced.Add(serpent); + } + + serpents += surfaced.Count; + + foreach (var serpent in surfaced) + { + serpent.Delete(); + } + + surfaced.Clear(); + } + + AssertRate(serpentRate, serpents); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.T2A, 0.0)] + [InlineData(Expansion.UOR, 65.0)] + [InlineData(Expansion.EJ, 120.0)] + public void Construct_BringsUpTheSOSChestOneCatchInFour(Expansion expansion, double skill) + { + const int casts = 2_000; + + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.Skills.Fishing.Base = skill; + fisher.AddItem(new Backpack()); + fisher.MoveToWorld(_deep, Map.Felucca); + + var sos = AddSOS(fisher); + var chests = 0; + + for (var i = 0; i < casts; i++) + { + var item = Fishing.System.Construct(typeof(Fish), fisher); + + if (item is WoodenChest or MetalGoldenChest) + { + chests++; + Assert.True(sos.Deleted); + sos = AddSOS(fisher); + } + else + { + Assert.True(Assert.IsAssignableFrom(item).IsShipwreckedItem); + } + + item.Delete(); + } + + AssertRate(0.25, chests, casts); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.T2A, 0.0, true)] + [InlineData(Expansion.UOR, 64.9, false)] + [InlineData(Expansion.UOR, 65.0, true)] + [InlineData(Expansion.EJ, 64.9, false)] + public void SOS_Needs65SkillFromUOR(Expansion expansion, double skill, bool recovers) + { + var previousExpansion = Core.Expansion; + var definition = Fishing.System.Definitions[0]; + var fisher = new PlayerMobile(); + Item item = null; + + try + { + Core.Expansion = expansion; + fisher.Skills.Fishing.Base = skill; + fisher.AddItem(new Backpack()); + fisher.MoveToWorld(_deep, Map.Felucca); + AddSOS(fisher); + + // With no fish left, only an SOS that works lets the cast go on. + var bank = definition.GetBank(Map.Felucca, _deep.X, _deep.Y); + bank.Consume(bank.Current, fisher); + Assert.Equal(recovers, Fishing.System.CheckResources(fisher, null, definition, Map.Felucca, _deep, false)); + + item = Fishing.System.Construct(typeof(Fish), fisher); + Assert.Equal(recovers, item is not Fish); + } + finally + { + item?.Delete(); + fisher.Delete(); + definition.Banks.GetValueOrDefault(Map.Felucca) + ?.Remove(new Point2D(_deep.X / definition.BankWidth, _deep.Y / definition.BankHeight)); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void Construct_KeepsTheWhitePearlAtAnSOSSpot() + { + var previousExpansion = Core.Expansion; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = Expansion.ML; + fisher.Skills.Fishing.Base = 100.0; + fisher.AddItem(new Backpack()); + fisher.MoveToWorld(_deep, Map.Felucca); + + var sos = AddSOS(fisher); + + for (var i = 0; i < 100; i++) + { + var item = Fishing.System.Construct(typeof(WhitePearl), fisher); + Assert.IsType(item); + item.Delete(); + } + + Assert.False(sos.Deleted); + } + finally + { + fisher.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.None, false)] + [InlineData(Expansion.T2A, true)] + [InlineData(Expansion.AOS, true)] + [InlineData(Expansion.ML, true)] + public void OnHarvestStartedAndFinished_RevealAHiddenFishermanFromT2A(Expansion expansion, bool reveals) + { + Core._tickCount = 0; + Timer.Init(0); + + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + fisher.MoveToWorld(_deep, Map.Felucca); + + var definition = Fishing.System.Definitions[0]; + var target = new LandTarget(_deep, Map.Felucca); + + fisher.Hidden = true; + Fishing.System.OnHarvestStarted(fisher, null, definition, target); + Advance(TimeSpan.FromSeconds(2.0)); + Assert.Equal(!reveals, fisher.Hidden); + + fisher.Hidden = true; + Fishing.System.OnHarvestFinished(fisher, null, definition, null, null, null, target); + Assert.Equal(!reveals, fisher.Hidden); + } + finally + { + fisher.Delete(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.UOR, true)] + [InlineData(Expansion.AOS, true)] + [InlineData(Expansion.SE, false)] + public void BigFish_WeighsAHundredStonesBeforeSE(Expansion expansion, bool weighsAHundred) + { + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var weights = new HashSet(); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + + for (var i = 0; i < 100; i++) + { + var fish = new BigFish(); + weights.Add((int)fish.Weight); + fish.Delete(); + } + } + finally + { + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + + Assert.All(weights, weight => Assert.InRange(weight, 3, 200)); + Assert.Equal(weighsAHundred, weights.SetEquals([100])); + } + + [Theory] + [InlineData(Expansion.AOS, 100, 25)] + [InlineData(Expansion.AOS, 12, 4)] + [InlineData(Expansion.SE, 100, 20)] + [InlineData(Expansion.SE, 200, 40)] + [InlineData(Expansion.SE, 12, 4)] + public void BigFish_GivesASteakPerFiveStonesFromSE(Expansion expansion, int weight, int steaks) + { + var previousExpansion = Core.Expansion; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + fisher.AddItem(new Backpack()); + + var fish = new BigFish { Weight = weight }; + fisher.Backpack.DropItem(fish); + fish.Carve(fisher, null); + + Assert.True(fish.Deleted); + Assert.Equal(steaks, fisher.Backpack.FindItemByType()?.Amount); + } + finally + { + fisher.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.AOS, 100.0, 0.0)] + [InlineData(Expansion.SE, 99.9, 0.0)] + [InlineData(Expansion.SE, 100.0, 0.25)] + public void Leviathan_GivesAGMNetThrowerAnArtifactOneTimeInFourFromSE(Expansion expansion, double skill, double rate) + { + const int leviathans = 400; + + var previousExpansion = Core.Expansion; + var previousGenerator = BuiltInRng.Generator; + var fisher = new PlayerMobile(); + + try + { + Core.Expansion = expansion; + BuiltInRng.Generator = new System.Random(1); + fisher.Skills.Fishing.Base = skill; + fisher.AddItem(new Backpack()); + fisher.MoveToWorld(_deep, Map.Felucca); + + var pack = fisher.Backpack; + var artifacts = 0; + + // Nobody damages it, so only the net thrower's own roll can give an artifact. + for (var i = 0; i < leviathans; i++) + { + var leviathan = new Leviathan(fisher); + leviathan.MoveToWorld(_deep, Map.Felucca); + leviathan.Kill(); + + artifacts += pack.Items.Count; + + for (var j = pack.Items.Count - 1; j >= 0; j--) + { + pack.Items[j].Delete(); + } + + leviathan.Corpse?.Delete(); + leviathan.Delete(); + } + + AssertRate(rate, artifacts, leviathans); + } + finally + { + fisher.Delete(); + BuiltInRng.Generator = previousGenerator; + Core.Expansion = previousExpansion; + } + } + + [Theory] + [InlineData(Expansion.AOS, 5, 60)] + [InlineData(Expansion.SE, 1, 12)] + [InlineData(Expansion.EJ, 1, 12)] + public void MagicFish_GiveAPointFor12SecondsFromSE(Expansion expansion, int bonus, int seconds) + { + Core._tickCount = 0; + Timer.Init(0); + + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var fisher = new PlayerMobile(); + BaseMagicFish[] fish = [new PrizedFish(), new WondrousFish(), new TrulyRareFish()]; + + try + { + Core.Expansion = expansion; + + foreach (var magicFish in fish) + { + Assert.True(magicFish.Apply(fisher)); + Assert.Equal(bonus, fisher.GetStatOffset(magicFish.Type)); + } + + Advance(TimeSpan.FromSeconds(seconds - 1)); + Assert.All(fish, magicFish => Assert.Equal(bonus, fisher.GetStatOffset(magicFish.Type))); + + Advance(TimeSpan.FromSeconds(1)); + Assert.All(fish, magicFish => Assert.Equal(0, fisher.GetStatOffset(magicFish.Type))); + } + finally + { + foreach (var magicFish in fish) + { + magicFish.Delete(); + } + + fisher.Delete(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + } + } + // Within five standard deviations of the expected rate; a rate of zero must never happen. private static void AssertRate(double expected, int count) { var tolerance = 5 * Math.Sqrt(expected * (1 - expected) / Casts); Assert.InRange((double)count / Casts, expected - tolerance, expected + tolerance); } + + private static void AssertRate(double expected, int count, int trials) + { + var tolerance = 5 * Math.Sqrt(expected * (1 - expected) / trials); + Assert.InRange((double)count / trials, expected - tolerance, expected + tolerance); + } + + private static void Advance(TimeSpan time) + { + Core._now += time; + Core._tickCount += (long)time.TotalMilliseconds; + Timer.Slice(Core.TickCount); + } + + // Map.Internal skips FindLocation's search; the wreck is then where the fisher stands. + private static SOS AddSOS(Mobile fisher) + { + var sos = new SOS(Map.Internal, 1) { TargetMap = fisher.Map, TargetLocation = fisher.Location }; + fisher.Backpack.DropItem(sos); + return sos; + } } diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs index d3784ac3d..2eb04702d 100644 --- a/Projects/UOContent/Engines/Harvest/Fishing.cs +++ b/Projects/UOContent/Engines/Harvest/Fishing.cs @@ -11,18 +11,24 @@ namespace Server.Engines.Harvest private static Fishing _system; private static readonly Type[] _magicFish = - { + [ typeof(PrizedFish), typeof(WondrousFish), typeof(TrulyRareFish), typeof(PeculiarFish) - }; + ]; - private static readonly Type[] _footwear = { typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots) }; + private static readonly Type[] _footwear = [typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)]; private static readonly MutateEntry[] _mutateTable = - { + [ new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(SpecialFishingNet)), new(80.0, 80.0, 4080.0, true, Expansion.UOR, typeof(BigFish)), - new(90.0, 80.0, 4080.0, true, Expansion.T2A, typeof(TreasureMap)), - new(100.0, 80.0, 4080.0, true, Expansion.T2A, typeof(MessageInABottle)), + // T2A-UOTD: needs 90; 0.25% at 90, 0.5% at 100. + // LBR onward: needs 90; 0.42% at 90, 0.83% at 100. + new(90.0, 80.0, 4080.0, true, Expansion.T2A, Expansion.UOTD, typeof(TreasureMap)), + new(90.0, 80.0, 2480.0, true, Expansion.LBR, typeof(TreasureMap)), + // T2A-UOTD: needs 100; 0.5% at 100. + // LBR onward: needs 100; 1.33% at 100. + new(100.0, 80.0, 4080.0, true, Expansion.T2A, Expansion.UOTD, typeof(MessageInABottle)), + new(100.0, 80.0, 1580.0, true, Expansion.LBR, typeof(MessageInABottle)), // T2A-UOTD: needs 80; 2.0% at 80, 2.5% at 100. // LBR-SA: no minimum; 0% at 0, 5% at 100. // HS onward: needs 80; 4.0% at 80, 5.0% at 100. @@ -32,44 +38,49 @@ namespace Server.Engines.Harvest new(0.0, 103.5, -34.5, false, Expansion.T2A, Expansion.UOTD, _footwear), // From LBR the two footwear rows make one curve: 75% at 0 skill, 10% at 100 and 5% at 120. new(0.0, 100.0, -62.5, false, Expansion.LBR, _footwear), - new(0.0, 140.0, -260.0, false, Expansion.LBR, _footwear), - new(0.0, 200.0, -200.0, false, Expansion.None, new Type[] { null }) - }; + new(0.0, 140.0, -260.0, false, Expansion.LBR, _footwear) + ]; + + // From UO:R deep water needs 65 skill to catch anything, and a GM succeeds 70% of the time. + private static readonly HarvestResource _deepWaterFish = new(0.0, 65.0, 115.0, 1043297, typeof(Fish)); private static readonly int[] _waterLandTiles = - { + [ 0x00A8, 0x00AB, 0x0136, 0x0137 - }; + ]; private static readonly int[] waterStaticTiles = - { + [ 0x1797, 0x179C, 0x346E, 0x3485, 0x3490, 0x34AB, 0x34B5, 0x35D5 - }; + ]; - private static readonly int[] bodyParts = { + private static readonly int[] bodyParts = + [ 0x1CDD, 0x1CE5, // arm 0x1CE0, 0x1CE8, // torso 0x1CE1, 0x1CE9, // head 0x1CE2, 0x1CEC // leg - }; + ]; - private static readonly int[] boneParts = { + private static readonly int[] boneParts = + [ 0x1AE0, 0x1AE1, 0x1AE2, 0x1AE3, 0x1AE4, // skulls 0x1B09, 0x1B0A, 0x1B0B, 0x1B0C, 0x1B0D, 0x1B0E, 0x1B0F, 0x1B10, // bone piles 0x1B15, 0x1B16 // pelvis bones - }; + ]; - private static readonly int[] miscItems = { + private static readonly int[] miscItems = + [ 0x1EB5, // unfinished barrel 0xA2A, // stool 0xC1F, // broken clock 0x1047, 0x1048, // globe 0x1EB1, 0x1EB2, 0x1EB3, 0x1EB4 // barrel staves - }; + ]; private Fishing() { @@ -88,9 +99,9 @@ namespace Server.Engines.Harvest MaxRange = 4, ConsumedPerHarvest = 1, ConsumedPerFeluccaHarvest = 1, - EffectActions = new[] { 12 }, - EffectSounds = Array.Empty(), - EffectCounts = new[] { 1 }, + EffectActions = [12], + EffectSounds = [], + EffectCounts = [1], EffectDelay = TimeSpan.Zero, EffectSoundDelay = TimeSpan.FromSeconds(8.0), NoResourcesMessage = 503172, // The fish don't seem to be biting here. @@ -101,29 +112,23 @@ namespace Server.Engines.Harvest ToolBrokeMessage = 503174 // You broke your fishing pole. }; - HarvestResource[] res = - { - new(00.0, 00.0, 100.0, 1043297, typeof(Fish)) - }; + HarvestResource[] res = [new(00.0, 00.0, 100.0, 1043297, typeof(Fish))]; - HarvestVein[] veins = - { - new(1000, 0.0, res[0], null) - }; + HarvestVein[] veins = [new(1000, 0.0, res[0], null)]; fish.Resources = res; fish.Veins = veins; if (Core.ML) { - fish.BonusResources = new[] - { + fish.BonusResources = + [ new BonusHarvestResource(0, 99.4, null, null), // set to same chance as mining ml gems new BonusHarvestResource(80.0, .6, 1072597, typeof(WhitePearl)) - }; + ]; } - Definitions = new[] { fish }; + Definitions = [fish]; } public static Fishing System => _system ??= new Fishing(); @@ -165,6 +170,13 @@ namespace Server.Engines.Harvest return false; } + public override HarvestResource MutateResource( + Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, + HarvestVein vein, HarvestResource primary, HarvestResource fallback + ) => Core.UOR && SpecialFishingNet.FullValidation(map, loc.X, loc.Y) + ? _deepWaterFish + : base.MutateResource(from, tool, def, map, loc, vein, primary, fallback); + public override Type MutateType( Type type, Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, HarvestResource resource @@ -203,11 +215,14 @@ namespace Server.Engines.Harvest return type; } + // From UO:R the chest needs 65 skill, and below that an SOS changes nothing. + private static bool CanFishUpShipwreck(Mobile from) => !Core.UOR || from.Skills.Fishing.Base >= 65.0; + public override bool CheckResources(Mobile from, Item tool, HarvestDefinition def, Map map, Point3D loc, bool timed) { var pack = from.Backpack; - if (pack != null) + if (pack != null && CanFishUpShipwreck(from)) { foreach (var sos in pack.FindItemsByType()) { @@ -244,91 +259,67 @@ namespace Server.Engines.Harvest return new MessageInABottle(from.Map == Map.Felucca ? Map.Felucca : Map.Trammel); } + // The pearl is a bonus on top of the catch, so an SOS never takes its place. + if (type == typeof(WhitePearl)) + { + return base.Construct(type, from); + } + var pack = from.Backpack; - if (pack != null) + if (pack != null && CanFishUpShipwreck(from)) { // We don't have to queue since we are returning on the first SOS. foreach (var sos in pack.FindItemsByType()) { - if (from.Map == sos.TargetMap && from.InRange(sos.TargetLocation, 60)) + if (from.Map != sos.TargetMap || !from.InRange(sos.TargetLocation, 60)) { - Item preLoot = null; - - switch (Utility.Random(8)) - { - case 0: // Body parts - { - preLoot = new ShipwreckedItem(bodyParts.RandomElement()); - break; - } - case 1: // Bone parts - { - preLoot = new ShipwreckedItem(boneParts.RandomElement()); - break; - } - case 2: // Paintings and portraits - { - preLoot = new ShipwreckedItem(Utility.Random(0xE9F, 10)); - break; - } - case 3: // Pillows - { - preLoot = new ShipwreckedItem(Utility.Random(0x13A4, 11)); - break; - } - case 4: // Shells - { - preLoot = new ShipwreckedItem(Utility.Random(0xFC4, 9)); - break; - } - case 5: // Hats - { - preLoot = Utility.RandomBool() ? new SkullCap() : new TricorneHat(); - break; - } - case 6: // Misc - { - var rand = Utility.Random(miscItems.Length + 1); - - preLoot = rand == miscItems.Length - ? new Candelabra() - : new ShipwreckedItem(miscItems[rand]); - - break; - } - } - - if (preLoot != null) - { - ((IShipwreckedItem)preLoot).IsShipwreckedItem = true; - return preLoot; - } - - LockableContainer chest = Utility.RandomBool() ? new MetalGoldenChest() : new WoodenChest(); - - if (sos.IsAncient && Core.SE) - { - chest.Hue = 0x481; - } - - TreasureMapChest.Fill(chest, Math.Max(1, Math.Min(4, sos.Level))); - - if (Core.UOR) - { - chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet()); - } - - chest.Movable = true; - chest.Locked = false; - chest.TrapType = TrapType.None; - chest.TrapPower = 0; - chest.TrapLevel = 0; - - sos.Delete(); - - return chest; + continue; } + + if (Utility.Random(4) != 0) + { + return Utility.Random(7) switch + { + 0 => new ShipwreckedItem(bodyParts.RandomElement()), // Body Parts + 1 => new ShipwreckedItem(boneParts.RandomElement()), // Bone Parts + 2 => new ShipwreckedItem(Utility.Random(0xE9F, 10)), // Paintings and portraits + 3 => new ShipwreckedItem(Utility.Random(0x13A4, 11)), // Pillows + 4 => new ShipwreckedItem(Utility.Random(0xFC4, 9)), // Shells + 5 => Utility.RandomBool() // Hats + ? new SkullCap { IsShipwreckedItem = true } + : new TricorneHat { IsShipwreckedItem = true }, + 6 => Utility.Random(miscItems.Length + 1) switch // Misc + { + var r when r < miscItems.Length => new ShipwreckedItem(miscItems[r]), + _ => new Candelabra { IsShipwreckedItem = true }, + } + }; + } + + LockableContainer chest = Utility.RandomBool() ? new MetalGoldenChest() : new WoodenChest(); + + if (sos.IsAncient && Core.SE) + { + chest.Hue = 0x481; + } + + TreasureMapChest.Fill(chest, Math.Max(1, Math.Min(4, sos.Level))); + + if (Core.UOR) + { + chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet()); + } + + chest.Movable = true; + chest.Locked = false; + chest.TrapType = TrapType.None; + chest.TrapPower = 0; + chest.TrapLevel = 0; + + sos.Delete(); + + return chest; } } @@ -339,45 +330,7 @@ namespace Server.Engines.Harvest { if (item is TreasureMap or MessageInABottle or SpecialFishingNet) { - BaseCreature serp; - - if (Utility.RandomDouble() < 0.25) - { - serp = new DeepSeaSerpent(); - } - else - { - serp = new SeaSerpent(); - } - - int x = m.X, y = m.Y; - - var map = m.Map; - - for (var i = 0; map != null && i < 20; ++i) - { - var tx = m.X - 10 + Utility.Random(21); - var ty = m.Y - 10 + Utility.Random(21); - - var t = map.Tiles.GetLandTile(tx, ty); - - if (t.Z == -5 && (t.ID >= 0xA8 && t.ID <= 0xAB || t.ID >= 0x136 && t.ID <= 0x137) && - !SpellHelper.CheckMulti(new Point3D(tx, ty, -5), map)) - { - x = tx; - y = ty; - break; - } - } - - serp.MoveToWorld(new Point3D(x, y, -5), map); - - serp.Home = serp.Location; - serp.RangeHome = 10; - - serp.PackItem(item); - - m.SendLocalizedMessage(503170); // Uh oh! That doesn't look like a fish! + SurfaceSerpent(m).PackItem(item); return true; // we don't want to give the item to the player, it's on the serpent } @@ -385,6 +338,52 @@ namespace Server.Engines.Harvest return base.Give(m, item, placeAtFeet || item is BigFish or WoodenChest or MetalGoldenChest); } + private static BaseCreature SurfaceSerpent(Mobile m) + { + BaseCreature serp = Utility.RandomDouble() < 0.1 ? new DeepSeaSerpent() : new SeaSerpent(); + + int x = m.X, y = m.Y; + + var map = m.Map; + + for (var i = 0; map != null && i < 20; ++i) + { + var tx = m.X - 10 + Utility.Random(21); + var ty = m.Y - 10 + Utility.Random(21); + + var t = map.Tiles.GetLandTile(tx, ty); + + if (t.Z == -5 && (t.ID >= 0xA8 && t.ID <= 0xAB || t.ID >= 0x136 && t.ID <= 0x137) && + !SpellHelper.CheckMulti(new Point3D(tx, ty, -5), map)) + { + x = tx; + y = ty; + break; + } + } + + serp.MoveToWorld(new Point3D(x, y, -5), map); + + serp.Home = serp.Location; + serp.RangeHome = 10; + serp.Combatant = m; + + Timer.StartTimer(TimeSpan.FromMinutes(5.0), () => ReturnToDeepWater(serp)); + + m.SendLocalizedMessage(503170); // Uh oh! That doesn't look like a fish! + + return serp; + } + + // Damage entries last 2 minutes and clear at full health, so a serpent nobody is fighting goes back to deep water. + private static void ReturnToDeepWater(BaseCreature serpent) + { + if (!serpent.Deleted && serpent.FindMostRecentDamager(false) == null) + { + serpent.Delete(); + } + } + public override void SendSuccessTo(Mobile from, Item item, HarvestResource resource) { if (item is BigFish fish) @@ -406,12 +405,7 @@ namespace Server.Engines.Harvest number = 1008124; name = "a mess of small fish"; } - else if (item is Fish) - { - number = 1008124; - name = item.ItemData.Name; - } - else if (item is BaseShoes) + else if (item is Fish or BaseShoes) { number = 1008124; name = item.ItemData.Name; @@ -472,7 +466,7 @@ namespace Server.Engines.Harvest TimeSpan.FromSeconds(1.5), () => { - if (Core.ML) + if (Core.T2A) { from.RevealingAction(); } @@ -491,10 +485,18 @@ namespace Server.Engines.Harvest { base.OnHarvestFinished(from, tool, def, vein, bank, resource, harvested); - if (Core.ML) + if (Core.T2A) { from.RevealingAction(); } + + // From T2A, 1% of casts in deep water at 80 skill or more bring up an empty sea serpent, whatever the catch. + if (Core.T2A && from.Skills.Fishing.Base >= 80.0 && Utility.RandomDouble() < 0.01 && + GetHarvestDetails(from, tool, harvested, out _, out var map, out var loc, out _) && + SpecialFishingNet.FullValidation(map, loc.X, loc.Y)) + { + SurfaceSerpent(from); + } } public override object GetLock(Mobile from, Item tool, HarvestDefinition def, object toHarvest) => this; diff --git a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs index b19e17290..3e58f67f8 100644 --- a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs +++ b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs @@ -15,7 +15,7 @@ public partial class BigFish : Item, ICarvable public BigFish() : base(0x09CC) { // TODO: Find correct formula. max on OSI currently 200, OSI dev says it's not 200 as max, and ~ 1/1,000,000 chance to get highest - Weight = Utility.RandomMinMax(3, 200); + Weight = Core.SE ? Utility.RandomMinMax(3, 200) : 100; Hue = Utility.RandomBool() ? 0x847 : 0x58C; } @@ -23,7 +23,8 @@ public partial class BigFish : Item, ICarvable public void Carve(Mobile from, Item item) { - ScissorHelper(from, new RawFishSteak(), Math.Max(16, (int)Weight) / 4, false); + var steaks = Core.SE ? Math.Max(4, (int)Weight / 5) : Math.Max(16, (int)Weight) / 4; + ScissorHelper(from, new RawFishSteak(), steaks, false); } public override void GetProperties(IPropertyList list) diff --git a/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs b/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs index e4dea06dc..184473e50 100644 --- a/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs +++ b/Projects/UOContent/Items/Resources/Fishing/MagicFish.cs @@ -16,7 +16,8 @@ public abstract partial class BaseMagicFish : Item public virtual bool Apply(Mobile from) { - var applied = SpellHelper.AddStatOffset(from, Type, Bonus, TimeSpan.FromMinutes(1.0)); + var duration = Core.SE ? TimeSpan.FromSeconds(12.0) : TimeSpan.FromMinutes(1.0); + var applied = SpellHelper.AddStatOffset(from, Type, Bonus, duration); if (!applied) { @@ -50,7 +51,7 @@ public partial class PrizedFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Int; public override int LabelNumber => 1041073; // prized fish @@ -64,7 +65,7 @@ public partial class WondrousFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Dex; public override int LabelNumber => 1041074; // wondrous fish @@ -78,7 +79,7 @@ public partial class TrulyRareFish : BaseMagicFish { } - public override int Bonus => 5; + public override int Bonus => Core.SE ? 1 : 5; public override StatType Type => StatType.Str; public override int LabelNumber => 1041075; // truly rare fish diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs index 5374f8716..374fb21cd 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -12,12 +12,6 @@ public partial class SOS : Item // By default let's use the old gump, because we can. private static bool UseNewMessages = false; - private static int[] WaterTiles = - { - 0x00A8, 0x00AB, - 0x0136, 0x0137 - }; - private static Rectangle2D[] BritRegions = { new(0, 0, 5120, 4096) }; private static Rectangle2D[] IlshRegions = @@ -179,34 +173,7 @@ public partial class SOS : Item var x = Utility.Random(reg.X, reg.Width); var y = Utility.Random(reg.Y, reg.Height); - if (!ValidateDeepWater(map, x, y)) - { - continue; - } - - var valid = true; - - for (int j = 1, offset = 5; valid && j <= 5; ++j, offset += 5) - { - if (!ValidateDeepWater(map, x + offset, y + offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x + offset, y - offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x - offset, y + offset)) - { - valid = false; - } - else if (!ValidateDeepWater(map, x - offset, y - offset)) - { - valid = false; - } - } - - if (valid) + if (SpecialFishingNet.FullValidation(map, x, y)) { return new Point3D(x, y, 0); } @@ -215,19 +182,6 @@ public partial class SOS : Item return Point3D.Zero; } - private static bool ValidateDeepWater(Map map, int x, int y) - { - var tileID = map.Tiles.GetLandTile(x, y).ID; - var water = false; - - for (var i = 0; !water && i < WaterTiles.Length; i += 2) - { - water = tileID >= WaterTiles[i] && tileID <= WaterTiles[i + 1]; - } - - return water; - } - private class MessageGump : StaticGump { private readonly string _fmt; diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs index 7017af81f..dc7eef960 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SpecialFishingNet.cs @@ -10,8 +10,8 @@ namespace Server.Items; [SerializationGenerator(0, false)] public partial class SpecialFishingNet : Item { - private static int[] _hues = - { + private static readonly int[] _hues = + [ 0x09B, 0x0CD, 0x0D3, @@ -26,25 +26,19 @@ public partial class SpecialFishingNet : Item 0x492, 0x4B5, 0x8AA - }; + ]; - private static int[] _waterTiles = - { - 0x00A8, 0x00AB, - 0x0136, 0x0137 - }; + private static readonly int[] _waterTiles = [0x00A8, 0x00AB, 0x0136, 0x0137 ]; - private static int[] _shallowWaterTiles = - { - 0x1797, 0x179C - }; + private static readonly int[] _shallowWaterTiles = [0x1797, 0x179C]; [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _inUse; [Constructible] - public SpecialFishingNet() : base(0x0DCA) => Hue = Utility.RandomDouble() < 0.01 ? _hues.RandomElement() : 0x8A0; + public SpecialFishingNet() : base(0x0DCA) => + Hue = Core.LBR && Utility.RandomDouble() < 0.1 ? _hues.RandomElement() : 0x8A0; public override double DefaultWeight => 1.0; @@ -322,31 +316,15 @@ public partial class SpecialFishingNet : Item public static bool FullValidation(Map map, int x, int y) { - if (!ValidateDeepWater(map, x, y)) + // Every tile closer than 14 must be water, so land and the edge of the map are at least 14 tiles away. + for (var ty = y - 13; ty <= y + 13; ++ty) { - return false; - } - - for (int j = 1, offset = 5; j <= 5; ++j, offset += 5) - { - if (!ValidateDeepWater(map, x + offset, y + offset)) + for (var tx = x - 13; tx <= x + 13; ++tx) { - return false; - } - - if (!ValidateDeepWater(map, x + offset, y - offset)) - { - return false; - } - - if (!ValidateDeepWater(map, x - offset, y + offset)) - { - return false; - } - - if (!ValidateDeepWater(map, x - offset, y - offset)) - { - return false; + if (!ValidateDeepWater(map, tx, ty)) + { + return false; + } } } diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs index e4adbf05e..b0cfaf697 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs @@ -149,7 +149,7 @@ namespace Server.Mobiles { base.OnDeath(c); - if (Fisher != null && Utility.Random(100) < 25) + if (Core.SE && Fisher?.Skills.Fishing.Base >= 100.0 && Utility.Random(100) < 25) { GiveArtifactTo(Fisher); } From 8cced36794795ef7984db89555078946687fe05d Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:40:38 +0200 Subject: [PATCH 11/26] fix(spawns): replace unknown type escortablewanderinghealer with EscortableMage (#2713) The two `Spawner (225)` entries at 1333,1808 in `shared/felucca/Vendors.json` and `shared/trammel/Vendors.json` name `escortablewanderinghealer`, which no class has. `BaseSpawner` marks the entry `InvalidType`, so the spawner never spawns anything. This changes the entry to `EscortableMage`, the only `Escortable` class. It already spawns in Britain in `TownsPeople.json`, and a vendor spawner at 1351,1816 nearby uses it too. If the original intent was a different type, or the spawner should go, I can change that. --- Distribution/Data/Spawns/shared/felucca/Vendors.json | 2 +- Distribution/Data/Spawns/shared/trammel/Vendors.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Distribution/Data/Spawns/shared/felucca/Vendors.json b/Distribution/Data/Spawns/shared/felucca/Vendors.json index ed769fcf9..183aad145 100644 --- a/Distribution/Data/Spawns/shared/felucca/Vendors.json +++ b/Distribution/Data/Spawns/shared/felucca/Vendors.json @@ -5333,7 +5333,7 @@ "homeRange": 4, "walkingRange": 4, "entries": [ - { "name": "escortablewanderinghealer", "maxCount": 1, "probability": 100 } + { "name": "EscortableMage", "maxCount": 1, "probability": 100 } ] }, { diff --git a/Distribution/Data/Spawns/shared/trammel/Vendors.json b/Distribution/Data/Spawns/shared/trammel/Vendors.json index fafd38b70..1bca14c34 100644 --- a/Distribution/Data/Spawns/shared/trammel/Vendors.json +++ b/Distribution/Data/Spawns/shared/trammel/Vendors.json @@ -3762,7 +3762,7 @@ "homeRange": 4, "walkingRange": 4, "entries": [ - { "name": "escortablewanderinghealer", "maxCount": 1, "probability": 100 } + { "name": "EscortableMage", "maxCount": 1, "probability": 100 } ] }, { From 4e57ec237379035edcdbcdff36bc20d671d74142 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:41:27 -0700 Subject: [PATCH 12/26] fix: monster ability queries no longer have side effects (#2716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `GetMonsterAbilities()` answers three questions through one virtual: which abilities a creature has (`GetAbility`, `AOS.cs` reflect/barrier, bard fire-breath difficulty), which abilities are enabled right now, and where cooldown state must be cleaned up (`InvalidateNextAbilityTriggers` on death and delete). Every caller assumes it is a pure, stable list. About 70 creatures return a static array; only LordOaks computed it: ``` Mobile.Delete() → BaseCreature.OnDelete() → CreatureDeletedEvent → MonsterAbility.InvalidateNextAbilityTriggers() → LordOaks.GetMonsterAbilities() → CheckQueen() → new Silvani().MoveToWorld(...) ``` - Deleting a Lord Oaks nobody had fought (champion reset, `DeleteCreatures`, GM delete) spawned an orphaned Silvani where he stood. - The spell damage scalar (`AlterDamageScalarFrom`) spawned her too. - With the queen dead his list was empty, so cleanup skipped his cooldown entries. The static `Dictionary` on the ability singleton kept a reference to the deleted creature. `MonsterAbilityGroup` children's entries were never cleaned either. - Neira threw unholy bones from `AlterDamageScalarFrom`, so any caller asking for a damage scalar (a preview gump, for example) could fire the effect. ## Changes - **Per-creature cooldowns:** a lazy `Dictionary` on `BaseCreature` (`IsAbilityOnCooldown`, `SetAbilityCooldown`, `ClearAbilityCooldowns`). The static dictionaries and the query-driven cleanup are gone, and death still resets cooldowns. Tick comparisons are wraparound-safe. - **Pure contract:** `GetMonsterAbilities()` is documented as pure. The new `virtual bool CanUseAbility(MonsterAbility)` gates abilities that only work some of the time. Every `TriggerAbility*` loop and `GetAbility` check it. - **LordOaks:** always lists his abilities; `CanUseAbility` and the 10% spell shield read "queen alive". The queen spawns when he is first aggressed or first given a combatant, never on the internal map. Aggression lands before the damage it causes, so the first spell is still shielded, as before. - **Neira:** the unholy bone throw moves to `OnDamagedBySpell`. A follow-up PR makes minions such as the queen, pixies and Barracoon's rats belong to their champion spawn, so a spawn reset cleans them up. ## Testing - `LordOaksTests` covers three cases: - querying or deleting an unengaged Lord Oaks spawns nothing; - aggression spawns the queen exactly once, and the shield and abilities follow her life; - an Oaks on the internal map never spawns her. - UOContent.Tests: 1234 passed, 2 skipped. - A local constructor sweep (every Mobile built and deleted repeatedly) no longer reports LordOaks. --- .../Tests/Mobiles/LordOaksTests.cs | 98 +++++++++++++++++++ .../Mobiles/Abilities/MonsterAbility.cs | 36 +------ Projects/UOContent/Mobiles/BaseCreature.cs | 52 +++++++++- .../UOContent/Mobiles/Special/LordOaks.cs | 51 ++++++---- Projects/UOContent/Mobiles/Special/Neira.cs | 6 +- 5 files changed, 182 insertions(+), 61 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/LordOaksTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/LordOaksTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/LordOaksTests.cs new file mode 100644 index 000000000..dae82a332 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/LordOaksTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles; + +// Lord Oaks' abilities and spell shield depend on his queen, so the queen must spawn from combat, +// never from a query such as GetMonsterAbilities or a damage calculation. +[Collection("Sequential UOContent Tests")] +public class LordOaksTests : IDisposable +{ + private readonly List _created = []; + + public void Dispose() + { + foreach (var m in World.Mobiles.Values.OfType().ToList()) + { + m.Delete(); + } + + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private LordOaks NewOaks(Map map) + { + var oaks = new LordOaks(); + _created.Add(oaks); + oaks.MoveToWorld(new Point3D(1000, 1000, 0), map); + return oaks; + } + + private Mobile NewAttacker() + { + var attacker = new Mongbat(); + _created.Add(attacker); + return attacker; + } + + private static int SilvaniCount() => World.Mobiles.Values.Count(m => m is Silvani); + + [Fact] + public void QueryingOrDeletingAnUnengagedOaks_SpawnsNoQueen() + { + var oaks = NewOaks(Map.Felucca); + + var scalar = 1.0; + oaks.AlterDamageScalarFrom(null, ref scalar); + oaks.GetMonsterAbilities(); + oaks.Delete(); + + Assert.Equal(1.0, scalar); + Assert.Equal(0, SilvaniCount()); + } + + [Fact] + public void Aggression_SpawnsTheQueenOnce_AndShieldsOaksWhileSheLives() + { + var oaks = NewOaks(Map.Felucca); + var attacker = NewAttacker(); + + oaks.AggressiveAction(attacker, false); + oaks.AggressiveAction(attacker, false); + + var queen = World.Mobiles.Values.OfType().Single(); + + var shielded = 1.0; + oaks.AlterDamageScalarFrom(attacker, ref shielded); + Assert.Equal(0.1, shielded, 6); + Assert.True(oaks.CanUseAbility(MonsterAbilities.SummonPixiesCounter)); + + queen.Delete(); + + var exposed = 1.0; + oaks.AlterDamageScalarFrom(attacker, ref exposed); + oaks.AggressiveAction(attacker, false); + + Assert.Equal(1.0, exposed); + Assert.False(oaks.CanUseAbility(MonsterAbilities.SummonPixiesCounter)); + Assert.Equal(0, SilvaniCount()); + } + + [Fact] + public void AnOaksOnTheInternalMap_NeverSpawnsTheQueen() + { + var oaks = new LordOaks(); + _created.Add(oaks); + + oaks.AggressiveAction(NewAttacker(), false); + + Assert.Equal(0, SilvaniCount()); + } +} diff --git a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs index 75c6fc3dc..0fcbae30c 100644 --- a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs +++ b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using ModernUO.CodeGeneratedEvents; namespace Server.Mobiles; @@ -9,8 +8,6 @@ namespace Server.Mobiles; /// public abstract class MonsterAbility { - private Dictionary _nextTriggerTicks; - public virtual MonsterAbilityType AbilityType => MonsterAbilityType.Generic; public virtual MonsterAbilityTrigger AbilityTrigger => MonsterAbilityTrigger.None; @@ -30,18 +27,9 @@ public abstract class MonsterAbility return false; } - if (_nextTriggerTicks?.TryGetValue(source, out var nextTrigger) == true) + if (source.IsAbilityOnCooldown(this)) { - if (nextTrigger - Core.TickCount > 0) - { - return false; - } - - _nextTriggerTicks.Remove(source); - if (_nextTriggerTicks.Count == 0) - { - _nextTriggerTicks = null; - } + return false; } var c = ChanceToTrigger; @@ -65,10 +53,7 @@ public abstract class MonsterAbility ? MinTriggerCooldown : Utility.RandomMinMax(MinTriggerCooldown, MaxTriggerCooldown); - var nextTrigger = Core.TickCount + (long)randomAmount.TotalMilliseconds; - - _nextTriggerTicks ??= new Dictionary(); - _nextTriggerTicks[source] = nextTrigger; + source.SetAbilityCooldown(this, randomAmount); } public virtual void AlterMeleeDamageFrom(BaseCreature source, Mobile target, ref int damage) @@ -100,18 +85,5 @@ public abstract class MonsterAbility } [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] - [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] - public static void InvalidateNextAbilityTriggers(BaseCreature source) - { - var abilities = source.GetMonsterAbilities(); - if (abilities == null || abilities.Length == 0) - { - return; - } - - for (var i = 0; i < abilities.Length; i++) - { - abilities[i]._nextTriggerTicks?.Remove(source); - } - } + public static void ResetCooldownsOnDeath(BaseCreature source) => source.ClearAbilityCooldowns(); } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index af619e005..d799013a5 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using ModernUO.Serialization; @@ -1500,10 +1500,47 @@ namespace Server.Mobiles public List MLQuests => (m_MLQuests ??= StaticMLQuester ? MLQuestSystem.FindQuestList(GetType()) : ConstructQuestList()) ?? MLQuestSystem.EmptyList; + /// + /// The abilities this creature has. Must be a pure, stable query: it is called from lookups, damage + /// calculations and cleanup, so it must not spawn, mutate state, or change with the creature's state. + /// Gate abilities that should only work some of the time with . + /// public virtual MonsterAbility[] GetMonsterAbilities() => null; + /// + /// Whether may trigger or be looked up right now. Must be a pure read. + /// + public virtual bool CanUseAbility(MonsterAbility ability) => true; + private MonsterAbilityTrigger _activeTriggers; + // Deadlines in Core.TickCount, keyed by ability. Lives on the creature so it is collected with it. + private Dictionary _abilityCooldowns; + + public bool IsAbilityOnCooldown(MonsterAbility ability) + { + if (_abilityCooldowns?.TryGetValue(ability, out var nextTrigger) != true) + { + return false; + } + + if (nextTrigger - Core.TickCount > 0) + { + return true; + } + + _abilityCooldowns.Remove(ability); + return false; + } + + public void SetAbilityCooldown(MonsterAbility ability, TimeSpan cooldown) + { + _abilityCooldowns ??= new Dictionary(); + _abilityCooldowns[ability] = Core.TickCount + (long)cooldown.TotalMilliseconds; + } + + public void ClearAbilityCooldowns() => _abilityCooldowns = null; + public virtual MonsterAbility GetAbility(MonsterAbilityType type) { var abilities = GetMonsterAbilities(); @@ -1516,6 +1553,11 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; + if (!CanUseAbility(ability)) + { + continue; + } + if (ability is MonsterAbilityGroup group) { ability = group.GetAbilityWithType(type); @@ -1579,7 +1621,7 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; - if (ability.CanTrigger(this, trigger)) + if (CanUseAbility(ability) && ability.CanTrigger(this, trigger)) { ability.Trigger(trigger, this, defender); triggered = true; @@ -1606,7 +1648,7 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; - if (ability.CanTrigger(this, trigger)) + if (CanUseAbility(ability) && ability.CanTrigger(this, trigger)) { ability.Move(this, d); } @@ -1625,7 +1667,7 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; - if (ability.CanTrigger(this, trigger)) + if (CanUseAbility(ability) && ability.CanTrigger(this, trigger)) { if ((trigger & MonsterAbilityTrigger.GiveMeleeDamage) != 0) { @@ -1666,7 +1708,7 @@ namespace Server.Mobiles for (var i = 0; i < abilities.Length; i++) { var ability = abilities[i]; - if (ability.CanTrigger(this, trigger)) + if (CanUseAbility(ability) && ability.CanTrigger(this, trigger)) { if ((trigger & MonsterAbilityTrigger.GiveSpellDamage) != 0) { diff --git a/Projects/UOContent/Mobiles/Special/LordOaks.cs b/Projects/UOContent/Mobiles/Special/LordOaks.cs index f8fb6d444..28577c90b 100644 --- a/Projects/UOContent/Mobiles/Special/LordOaks.cs +++ b/Projects/UOContent/Mobiles/Special/LordOaks.cs @@ -86,7 +86,11 @@ public partial class LordOaks : BaseChampion public override Poison PoisonImmune => Poison.Deadly; private static MonsterAbility[] _abilities = { MonsterAbilities.SummonPixiesCounter }; - public override MonsterAbility[] GetMonsterAbilities() => CheckQueen() ? _abilities : MonsterAbilities.Empty; + public override MonsterAbility[] GetMonsterAbilities() => _abilities; + + public override bool CanUseAbility(MonsterAbility ability) => IsQueenAlive; + + private bool IsQueenAlive => _queen?.Deleted == false; public override void GenerateLoot() { @@ -103,36 +107,41 @@ public partial class LordOaks : BaseChampion public override int GetDeathSound() => 0x2F7; - public bool CheckQueen() + // Aggression lands before the damage it causes, so the queen is up before the first hit is scaled. + public override void AggressiveAction(Mobile aggressor, bool criminal) { - if (Map == null) + base.AggressiveAction(aggressor, criminal); + SpawnQueen(); + } + + public override void OnCombatantChange() + { + base.OnCombatantChange(); + + if (Combatant != null) { - return false; + SpawnQueen(); + } + } + + private void SpawnQueen() + { + if (_spawnedQueen || Deleted || Map == null || Map == Map.Internal) + { + return; } - if (!_spawnedQueen) - { - Say(1042153); // Come forth my queen! + Say(1042153); // Come forth my queen! - _queen = new Silvani { Team = Team }; - _queen.MoveToWorld(Location, Map); + Queen = new Silvani { Team = Team }; + Queen.MoveToWorld(Location, Map); - _spawnedQueen = true; - return true; - } - - if (_queen?.Deleted != false) - { - _queen = null; - return false; - } - - return true; + SpawnedQueen = true; } public override void AlterDamageScalarFrom(Mobile caster, ref double scalar) { - if (CheckQueen()) + if (IsQueenAlive) { scalar *= 0.1; } diff --git a/Projects/UOContent/Mobiles/Special/Neira.cs b/Projects/UOContent/Mobiles/Special/Neira.cs index 4cbcbb5c9..b55e20d25 100644 --- a/Projects/UOContent/Mobiles/Special/Neira.cs +++ b/Projects/UOContent/Mobiles/Special/Neira.cs @@ -163,13 +163,13 @@ public partial class Neira : BaseChampion } } - public override void AlterDamageScalarFrom(Mobile caster, ref double scalar) + public override void OnDamagedBySpell(Mobile from, int damage) { - base.AlterDamageScalarFrom(caster, ref scalar); + base.OnDamagedBySpell(from, damage); if (Utility.RandomDouble() < 0.1) // 10% chance to throw an unholy bone { - AddUnholyBone(caster, 1.0); + AddUnholyBone(from, 1.0); } } From 69ff5f3940abf2ca5836ea880357db1556f05937 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:48:31 -0700 Subject: [PATCH 13/26] fix: champion minions belong to their spawn (#2717) Stacked on #2716. Retarget to `main` after it merges. ## Problem Mobiles a champion summons mid-fight aren't tracked by anything: | Summoner | Minions | |---|---| | LordOaks | Silvani queen | | `SummonPixiesCounter` (LordOaks, Silvani, Twaulo) | 3-6 pixies per trigger | | Barracoon | 3-6 ratmen per trigger | `ChampionSpawn.DeleteCreatures` (called on expiry and when the spawn is deleted) clears only `Creatures` and the champion. These minions survive every reset and stay in the save. I surveyed every content creature that creates other mobiles; the others already clean up after themselves: Meraktus deletes its followers, Harrower kills its tentacles, PlagueSpawn and the Enraged animals delete themselves when their owner is gone, and BogThing eats its boglings. ## Changes - **`BaseCreature.SpawnMinion(minion, target, location)`:** sets the team, places the minion and sets its combatant. Ownership: if the summoner is a champion spawn's champion, one of its creatures, or one of its minions (found through the spawn region), the minion joins that spawn. It refuses, and deletes the minion, if the summoner isn't on a real map. `SummonPixiesCounter` read `source.Map` even though `MonsterAbility.Trigger` notes it can fire after the creature has left the world. - **`ChampionSpawn.Minions`:** a new serialized list (v12, `MigrateFrom(V11Content)`) kept apart from `Creatures`: - killing a minion never counts toward a level, so a leftover summoner can't feed a restarted spawn; - `DeleteCreatures` deletes minions along with the creatures, which matches when the spawn is deactivated (`ExpireCreatures`, 10 minutes after `Stop`) or deleted. - **Callers:** LordOaks, `SummonPixiesCounter` and Barracoon spawn through `SpawnMinion`. A summoner that isn't part of a spawn (a GM `[add]`) still makes standalone minions, as today. ## Testing - `ChampionMinionTests`: - the champion's queen is listed in `Minions` and deleted by `DeleteCreatures`; - a minion's minion also belongs to the spawn; - a Lord Oaks in the region that isn't the spawn's champion keeps a standalone queen. - UOContent.Tests: 1237 passed, 2 skipped. The Application build is clean. --- .../Engines/CannedEvil/ChampionMinionTests.cs | 106 ++++++++++ .../Engines/CannedEvil/ChampionSpawn.cs | 61 +++++- ....Engines.CannedEvil.ChampionSpawn.v12.json | 199 ++++++++++++++++++ .../Mobiles/Abilities/SummonPixiesCounter.cs | 7 +- Projects/UOContent/Mobiles/BaseCreature.cs | 28 +++ .../UOContent/Mobiles/Special/Barracoon.cs | 8 +- .../UOContent/Mobiles/Special/LordOaks.cs | 5 +- 7 files changed, 402 insertions(+), 12 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs create mode 100644 Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json diff --git a/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs b/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs new file mode 100644 index 000000000..07abaa538 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server; +using Server.Engines.CannedEvil; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Engines.CannedEvil; + +// Minions summoned by a champion belong to its spawn, so clearing the spawn removes them instead of +// leaving them behind for good. +[Collection("Sequential UOContent Tests")] +public class ChampionMinionTests : IDisposable +{ + private static readonly Point3D _location = new(1500, 1500, 0); + + private readonly List _created = []; + + public void Dispose() + { + foreach (var m in World.Mobiles.Values.OfType().ToList()) + { + m.Delete(); + } + + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private ChampionSpawn NewSpawn() + { + var spawn = new ChampionSpawn(); + _created.Add(spawn); + spawn.MoveToWorld(_location, Map.Felucca); + spawn.SpawnArea = new Rectangle2D(new Point2D(_location.X - 24, _location.Y - 24), new Point2D(_location.X + 24, _location.Y + 24)); + return spawn; + } + + private LordOaks NewOaks() + { + var oaks = new LordOaks(); + _created.Add(oaks); + oaks.MoveToWorld(_location, Map.Felucca); + return oaks; + } + + private Mobile NewAttacker() + { + var attacker = new Mongbat(); + _created.Add(attacker); + return attacker; + } + + [Fact] + public void AChampionsMinion_IsDeletedWithTheSpawnsCreatures() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); + spawn.Champion = oaks; + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + Assert.Contains(queen, spawn.Minions); + + spawn.DeleteCreatures(); + + Assert.True(queen.Deleted); + Assert.Empty(spawn.Minions); + } + + [Fact] + public void AMinionOfAMinion_AlsoBelongsToTheSpawn() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); + spawn.Champion = oaks; + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + var pixie = new Pixie(); + _created.Add(pixie); + Assert.True(queen.SpawnMinion(pixie)); + + Assert.Contains(pixie, spawn.Minions); + } + + [Fact] + public void ACreatureOutsideAnySpawn_SummonsAStandaloneMinion() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); // in the spawn's region, but not its champion + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + spawn.DeleteCreatures(); + + Assert.False(queen.Deleted); + Assert.Null(ChampionSpawn.FindSpawnOf(oaks)); + } +} diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index 3a024576b..a5d457e2f 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -28,7 +28,7 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(11, false)] +[SerializationGenerator(12, false)] public partial class ChampionSpawn : Item { private void MigrateFrom(V10Content content) @@ -59,6 +59,35 @@ public partial class ChampionSpawn : Item _restartTime = content.RestartTime; } + private void MigrateFrom(V11Content content) + { + _level = content.Level; + _activatedByProximity = content.ActivatedByProximity; + _nextProximityTime = content.NextProximityTime; + _maxLevel = content.MaxLevel; + _activatedByValor = content.ActivatedByValor; + _damageEntries = content.DamageEntries; + _confinedRoaming = content.ConfinedRoaming; + _idol = content.Idol; + _hasBeenAdvanced = content.HasBeenAdvanced; + _spawnArea = content.SpawnArea; + _randomizeType = content.RandomizeType; + _kills = content.Kills; + _active = content.Active; + _type = content.Type; + _creatures = content.Creatures; + _redSkulls = content.RedSkulls; + _whiteSkulls = content.WhiteSkulls; + _platform = content.Platform; + _altar = content.Altar; + _expireDelay = content.ExpireDelay; + _expireTime = content.ExpireTime; + _champion = content.Champion; + _restartDelay = content.RestartDelay; + _restartTime = content.RestartTime; + _minions = []; + } + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn)); [SerializableField(1)] @@ -143,6 +172,12 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; + // Mobiles summoned by the champion or the spawn's creatures. Deleted with the creatures, never counted as kills. + [Tidy] + [SerializableField(24)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private List _minions; + private ChampionSpawnRegion m_Region; //Goes back each level, below level 0 and it goes off! @@ -173,6 +208,7 @@ public partial class ChampionSpawn : Item Visible = false; _creatures = new List(); + _minions = []; _redSkulls = new List(); _whiteSkulls = new List(); @@ -294,6 +330,21 @@ public partial class ChampionSpawn : Item public bool IsChampionSpawn(Mobile m) => _creatures.Contains(m); + /// + /// The spawn fights for: as its champion, one of its creatures, or a minion of either. + /// + public static ChampionSpawn FindSpawnOf(BaseCreature creature) + { + var spawn = Region.Find(creature.Location, creature.Map).GetRegion()?.Spawn; + + return spawn?.Deleted == false && + (spawn.Champion == creature || spawn._minions.Contains(creature) || spawn._creatures.Contains(creature)) + ? spawn + : null; + } + + public void AddMinion(Mobile minion) => AddToMinions(minion); + [CommandProperty(AccessLevel.GameMaster)] public virtual int MaxKills { @@ -1171,6 +1222,13 @@ public partial class ChampionSpawn : Item ClearCreatures(); } + + for (var i = 0; i < _minions.Count; ++i) + { + _minions[i].Delete(); + } + + ClearMinions(); } public virtual void RegisterDamageTo(Mobile m) @@ -1321,6 +1379,7 @@ public partial class ChampionSpawn : Item _active = reader.ReadBool(); _type = (ChampionSpawnType)reader.ReadInt(); _creatures = reader.ReadEntityList(); + _minions = []; _redSkulls = reader.ReadEntityList(); _whiteSkulls = reader.ReadEntityList(); _platform = reader.ReadEntity(); diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json new file mode 100644 index 000000000..55d3c3ec8 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json @@ -0,0 +1,199 @@ +{ + "version": 12, + "type": "Server.Engines.CannedEvil.ChampionSpawn", + "properties": [ + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByProximity", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextProximityTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "MaxLevel", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByValor", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DamageEntries", + "type": "System.Collections.Generic.Dictionary\u003CServer.Mobile, int\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule", + "0", + "int", + "PrimitiveTypeMigrationRule", + "1", + "" + ] + }, + { + "name": "ConfinedRoaming", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Idol", + "type": "Server.Engines.CannedEvil.IdolOfTheChampion", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HasBeenAdvanced", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnArea", + "type": "Server.Rectangle2D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect2D" + ] + }, + { + "name": "RandomizeType", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Kills", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Active", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Type", + "type": "Server.Engines.CannedEvil.ChampionSpawnType", + "rule": "EnumMigrationRule" + }, + { + "name": "Creatures", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RedSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "WhiteSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Platform", + "type": "Server.Engines.CannedEvil.ChampionPlatform", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Altar", + "type": "Server.Engines.CannedEvil.ChampionAltar", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ExpireDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Champion", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "RestartDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "RestartTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Minions", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs b/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs index 3fe6ba737..41b2f9185 100644 --- a/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs +++ b/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs @@ -11,9 +11,10 @@ public class SummonPixiesCounter : MonsterAbility var amount = Utility.RandomMinMax(3, 6); for (var i = 0; i < amount; i++) { - var pixie = new Pixie { Team = source.Team, FightMode = FightMode.Closest }; - pixie.MoveToWorld(source.Map.GetRandomNearbyLocation(source.Location), source.Map); - pixie.Combatant = target; + if (!source.SpawnMinion(new Pixie { FightMode = FightMode.Closest }, target)) + { + break; + } } base.Trigger(trigger, source, target); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index d799013a5..551b4ec22 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; +using Server.Engines.CannedEvil; using Server.Engines.ConPVP; using Server.Engines.MLQuests; using Server.Engines.Quests.Doom; @@ -5780,6 +5781,33 @@ namespace Server.Mobiles } } + /// + /// Brings into the world on this creature's side. A minion of a champion spawn's + /// champion or creatures belongs to that spawn, which deletes it when it clears its creatures. + /// + /// False, and the minion is deleted, if this creature is not in the world. + public bool SpawnMinion(BaseCreature minion, Mobile target = null, Point3D? location = null) + { + var map = Map; + + if (Deleted || map == null || map == Map.Internal) + { + minion.Delete(); + return false; + } + + minion.Team = Team; + ChampionSpawn.FindSpawnOf(this)?.AddMinion(minion); + minion.MoveToWorld(location ?? map.GetRandomNearbyLocation(Location), map); + + if (target != null) + { + minion.Combatant = target; + } + + return true; + } + public virtual void HealStart(Mobile patient) { StopHeal(); diff --git a/Projects/UOContent/Mobiles/Special/Barracoon.cs b/Projects/UOContent/Mobiles/Special/Barracoon.cs index 50e941515..0bc542746 100644 --- a/Projects/UOContent/Mobiles/Special/Barracoon.cs +++ b/Projects/UOContent/Mobiles/Special/Barracoon.cs @@ -131,9 +131,7 @@ public partial class Barracoon : BaseChampion public void SpawnRatmen(Mobile target) { - var map = Map; - - if (map == null) + if (Map == null || Map == Map.Internal) { return; } @@ -162,9 +160,7 @@ public partial class Barracoon : BaseChampion _ => new Ratman() }; - rat.Team = Team; - rat.MoveToWorld(map.GetRandomNearbyLocation(Location), map); - rat.Combatant = target; + SpawnMinion(rat, target); } } diff --git a/Projects/UOContent/Mobiles/Special/LordOaks.cs b/Projects/UOContent/Mobiles/Special/LordOaks.cs index 28577c90b..40a0d2d68 100644 --- a/Projects/UOContent/Mobiles/Special/LordOaks.cs +++ b/Projects/UOContent/Mobiles/Special/LordOaks.cs @@ -133,9 +133,10 @@ public partial class LordOaks : BaseChampion Say(1042153); // Come forth my queen! - Queen = new Silvani { Team = Team }; - Queen.MoveToWorld(Location, Map); + var queen = new Silvani(); + SpawnMinion(queen, location: Location); + Queen = queen; SpawnedQueen = true; } From 0f89870793ee38969a2b3b7406f83a7046ef4740 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:10:03 -0700 Subject: [PATCH 14/26] fix: ranged weapon crafting falls back to the recipe resource (#2719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BaseRanged.OnCraft` passed the raw resource type to `ApplyWoodBonuses`, which can be null; `CraftResources.GetFromType` threw on the null key and crashed the server. Every other `OnCraft` override already falls back to `craftItem.Resources[0].ItemType` — `BaseRanged` now does the same. `GetFromType` also returns `CraftResource.None` for a null type, matching its documented contract for unregistered types, so other callers can't hit the same throw. --- Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs | 2 +- Projects/UOContent/Misc/ResourceInfo.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs b/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs index 4fd786809..a47a43543 100644 --- a/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs +++ b/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs @@ -206,7 +206,7 @@ namespace Server.Items var result = base.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, craftItem, resHue); if (Core.ML) { - ApplyWoodBonuses(typeRes); + ApplyWoodBonuses(typeRes ?? craftItem.Resources[0].ItemType); } return result; diff --git a/Projects/UOContent/Misc/ResourceInfo.cs b/Projects/UOContent/Misc/ResourceInfo.cs index 521216d58..dae8b4836 100644 --- a/Projects/UOContent/Misc/ResourceInfo.cs +++ b/Projects/UOContent/Misc/ResourceInfo.cs @@ -736,7 +736,7 @@ namespace Server.Items /// public static CraftResource GetFromType(Type resourceType) { - if (m_TypeTable == null) + if (resourceType == null || m_TypeTable == null) { return CraftResource.None; } From 078be52777c417afef6d82e1f301e3f65789d256 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:06:23 -0700 Subject: [PATCH 15/26] fix: reject archive entries that extract outside the output directory (#2720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `ManagedArchive.ExtractTarFromStream` joined each tar entry name directly onto the output directory, so an entry such as `../x`, `sub/../../x` or an absolute path was written outside it. The restore path (`AutoArchive` → `ExtractTarZstd`/`ExtractTarGz`/`ExtractTar`) feeds archives through this method. Each entry now resolves to a full path and must stay under the output root (case-insensitive on Windows). An entry that escapes fails the extraction; the existing `catch` in each `Extract*` method logs it and returns `false`. The input is the server's own backup archives, so this is hardening against a tampered or foreign archive, not something a player can reach. ## Tests - `ExtractTar_EntryOutsideOutputDirectory_IsRejected` (`../escaped.txt`, `sub/../../escaped.txt`) and `ExtractTar_RootedEntry_IsRejected`. All three fail without the fix. - All 18 `ManagedArchiveTests` pass. --- .../Tests/WorldSaves/ManagedArchiveTests.cs | 44 +++++++++++++++++++ .../UOContent/Compression/ManagedArchive.cs | 10 ++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs index b5a2c0dc9..7103a65c5 100644 --- a/Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs +++ b/Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs @@ -311,6 +311,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/Compression/ManagedArchive.cs b/Projects/UOContent/Compression/ManagedArchive.cs index 32142d9e4..4b7e9da2b 100644 --- a/Projects/UOContent/Compression/ManagedArchive.cs +++ b/Projects/UOContent/Compression/ManagedArchive.cs @@ -224,9 +224,17 @@ public static class ManagedArchive { using var tarReader = new TarReader(stream); + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(outputDirectory)) + Path.DirectorySeparatorChar; + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + while (tarReader.GetNextEntry() is { } entry) { - var destPath = Path.Combine(outputDirectory, entry.Name); + // Rooted names and ".." segments would otherwise let the archive write anywhere the process can. + var destPath = Path.GetFullPath(Path.Combine(root, entry.Name)); + if (!destPath.StartsWith(root, comparison)) + { + throw new InvalidDataException($"Archive entry '{entry.Name}' resolves outside '{root}'"); + } var destDir = Path.GetDirectoryName(destPath); if (destDir != null) From 9a250548099ba644d569941a0666f6d29fd6b7ba Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:08:19 -0700 Subject: [PATCH 16/26] test: remove trivial, duplicate and content-value tests (#2721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Many tests proved a specific change when it landed but don't need to run on every CI build forever. This removes **202 test methods and 9 files: 313 test cases, 2,247 → 1,934**. UOContent.Tests drops from about 14s to about 5s locally. Removed: - **Tautologies.** Asserting a constant, a constructor readback or a trivial getter, or testing BCL behavior. - **Exact duplicates** of a surviving test on the same code path. - **Obsolete regressions** whose failure can no longer be expressed. - **Game-content values** shard owners customize (era drop rates, loot and skill tables, single-creature tuning). Each customization would otherwise force a test fix. ## How it was decided 1. Every test was classified, with a reason, by a per-shard sweep over all 287 test files. 2. Each proposed deletion was then checked by a second, adversarial reviewer against the surviving tests and the production code. 3. Anything that was the only coverage of a separate code path was **kept**, even where it looked like a duplicate. Examples: the unsigned `SpanReader`/`SpanWriter` methods, the at-location versus bounds enumerators, null-map enumerables, the security filters, archive-journal state transitions. About 130 proposed deletions were rejected this way. Whole files removed: - `FishingTests`, apart from 3 tests: the map-edge scan, serpent cleanup, and SOS chest consumption. The SOS test now stops at the first chest instead of measuring a catch rate. The multi-million-cast rate simulations are gone. - `LordOaksTests` and `MeerEnragedTests` (single-creature tests). - `AutoArchiveHelperTests`, `JsonDiscoverableTypeAttributeTests`, `DecimalSerializationTests` and `EnumConversionTests` (tautologies, or tests of their own local helpers). - `NetworkStatsTests`. - `MigratedDataLoadTests` (a subset of `AllSpawnFilesLoadTests`). - `SpawnerFixtureCapture`: a capture tool, not a test, and its `version == 12` assert can no longer pass. The `.bin` fixtures it produced stay. ## Tests - Server.Tests: 811 pass. - UOContent.Tests: 1,123 pass. A follow-up PR, stacked on this one, fixes how the remaining tests behave: shared state, CI-vacuous tests, harness duplication. --- .../RawInterpolatedStringHandlerTests.cs | 19 - .../Tests/Buffers/SpanReaderTests.cs | 54 -- .../Tests/Buffers/SpanWriterTests.cs | 72 -- .../Tests/Buffers/ValueStringBuilderTests.cs | 41 -- .../Tests/Collections/ValueLinkListTests.cs | 32 - .../Tests/Geometry/Point2DTests.cs | 25 - .../Tests/Geometry/Point3DTests.cs | 27 - .../Tests/Geometry/Rectangle2DTests.cs | 27 - .../Tests/Geometry/Rectangle3DTests.cs | 27 - .../Tests/Geometry/WorldLocationTests.cs | 34 - .../Tests/Items/ContainerChildSkipTests.cs | 21 - .../Tests/Items/ContainerTests.cs | 42 -- .../Tests/Items/DecayRegistrationTests.cs | 105 --- .../Items/PlayerConstructedStackingTests.cs | 19 - .../JsonDiscoverableTypeAttributeTests.cs | 30 - .../Tests/Json/Rectangle3DConverterTests.cs | 8 - .../Tests/Maps/CanFitItemTests.cs | 33 - .../Tests/Maps/CanSpawnMobileTileDataTests.cs | 71 -- .../Tests/Maps/ClientEnumeratorTests.cs | 76 -- .../Maps/ItemByDistanceEnumeratorTests.cs | 217 ------ .../Tests/Maps/ItemEnumeratorTests.cs | 72 -- .../Tests/Maps/MapSelectionTests.cs | 9 - .../Maps/MobileByDistanceEnumeratorTests.cs | 193 ----- .../Tests/Maps/MobileEnumeratorTests.cs | 7 - .../Tests/Maps/MultiEnumeratorTests.cs | 79 -- .../Tests/Maps/StaticTileEnumeratorTests.cs | 48 -- .../Network/Bans/BanConfigurationTests.cs | 9 - .../Tests/Network/EventLoopIdleTests.cs | 8 - .../Network/MovementThrottleLatencyTests.cs | 292 -------- .../Tests/Network/MovementThrottleTests.cs | 43 -- .../Tests/Network/NetStateSendBufferTests.cs | 86 --- .../Network/NetworkCompressionBoundsTests.cs | 14 - .../Tests/Network/NetworkStatsTests.cs | 80 -- .../ObjectPropertyListReentrancyTests.cs | 25 - .../DecimalSerializationTests.cs | 46 -- .../Serialization/EnumConversionTests.cs | 21 - .../Tests/Utility/ActivatorExtensionsTests.cs | 9 - .../Tests/Utility/HtmlEscapeTests.cs | 157 ---- .../Tests/Utility/StringHelperTests.cs | 11 - .../Tests/Accounting/AccountHandlerTests.cs | 11 - .../Tests/Accounting/PasswordWorkerTests.cs | 21 - .../Security/PasswordProtectionTest.cs | 9 - .../Tests/Commands/ChainedBindingSortTests.cs | 15 - .../Tests/Commands/EmitterRobustnessTests.cs | 21 - .../Engines/Events/RecurrencePatternTests.cs | 20 - .../Engines/Factions/FactionRankTests.cs | 16 - .../Tests/Engines/Harvest/FishingTests.cs | 686 +----------------- .../Engines/Pathing/Multi/BoatPathTests.cs | 25 - .../Pathing/Multi/FoundationRedesignTests.cs | 22 - .../Pathing/Multi/MultiEdgeCaseTests.cs | 29 - .../Pathing/Multi/MultiMaskCacheTests.cs | 39 - .../Pathing/StepCacheFileFormatTests.cs | 11 - .../Pathing/StepCacheLifecycleTests.cs | 10 - .../Tests/Engines/Pathing/StepProbeTests.cs | 170 ----- .../Spawners/Json/ImportCleanupTests.cs | 26 - .../Spawners/Json/MigratedDataLoadTests.cs | 47 -- .../Spawners/Json/SpawnerDtoRoundTripTests.cs | 27 - .../Engines/Spawners/SectorSpawnCacheTests.cs | 146 ---- .../Spawners/SpawnerEntryOwnershipTests.cs | 93 --- .../Engines/Spawners/SpawnerFixtureCapture.cs | 50 -- .../Tests/Gumps/GridLayoutTests.cs | 39 - .../Items/Jewels/T2AJewelGemCraftTests.cs | 28 - .../Misc/Corpses/CorpseHairMigrationTests.cs | 11 - .../TreasureChests/TreasureChestDecayTests.cs | 21 - .../Tests/Misc/NameVerificationTests.cs | 15 - .../Tests/Mobiles/AI/FamiliarAITests.cs | 12 - .../Tests/Mobiles/AI/GuardFollowTests.cs | 44 -- .../Tests/Mobiles/AI/MeerEnragedTests.cs | 77 -- .../Tests/Mobiles/AI/PetOrderTests.cs | 88 --- .../Tests/Mobiles/AI/PetPacingTests.cs | 51 -- .../Mobiles/AI/SummonAcquireRulesTests.cs | 15 - .../Tests/Mobiles/LootingRightsTests.cs | 28 - .../Tests/Mobiles/LordOaksTests.cs | 98 --- .../Tests/Network/BanExemptionsTests.cs | 9 - .../Blocklist/BlocklistConfigurationTests.cs | 6 - .../Bans/Blocklist/BlocklistFileTests.cs | 10 - .../Bans/Blocklist/BlocklistSnapshotTests.cs | 6 - .../Network/Bans/CrowdSecAlertClientTests.cs | 8 - .../Tests/Network/Firewall/FirewallTests.cs | 10 - .../ManualAllowlistConfigurationTests.cs | 6 - .../Tests/Network/Packets/AuthIdTests.cs | 19 - .../Tests/Skills/DetectHiddenTests.cs | 62 -- .../Tests/Skills/SkillEventsTests.cs | 40 - .../Spells/Necromancy/BloodOathSpellTests.cs | 24 - .../Tests/Spells/Ninjitsu/AnimalFormTests.cs | 26 - .../Tests/Utilities/TryParseTests.cs | 15 - .../Tests/WorldSaves/ArchiveJournalTests.cs | 12 - .../WorldSaves/AutoArchiveHelperTests.cs | 104 --- .../Tests/WorldSaves/ManagedArchiveTests.cs | 47 -- 89 files changed, 11 insertions(+), 4603 deletions(-) delete mode 100644 Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs delete mode 100644 Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs delete mode 100644 Projects/Server.Tests/Tests/Serialization/DecimalSerializationTests.cs delete mode 100644 Projects/Server.Tests/Tests/Serialization/EnumConversionTests.cs delete mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/Json/MigratedDataLoadTests.cs delete mode 100644 Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs delete mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs delete mode 100644 Projects/UOContent.Tests/Tests/Mobiles/LordOaksTests.cs delete mode 100644 Projects/UOContent.Tests/Tests/WorldSaves/AutoArchiveHelperTests.cs diff --git a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs index d4777b758..71ce89b3d 100644 --- a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs @@ -56,25 +56,6 @@ public class RawInterpolatedStringHandlerTests handler.Clear(); } - [Fact] - public void TestNoFormatPreservesCase() - { - var handler = new RawInterpolatedStringHandler(0, 1); - handler.AppendFormatted("Hello WORLD"); - Assert.Equal("Hello WORLD", handler.Text.ToString()); - handler.Clear(); - } - - [Fact] - public void TestUnicodeLowercase() - { - // ToLowerInvariant on Greek letter - var handler = new RawInterpolatedStringHandler(0, 1); - handler.AppendFormatted("ΑΒΓ", format: "L"); - Assert.Equal("αβγ", handler.Text.ToString()); - handler.Clear(); - } - [Fact] public void TestLowercaseFormatSurrogatePairAtChunkBoundary() { diff --git a/Projects/Server.Tests/Tests/Buffers/SpanReaderTests.cs b/Projects/Server.Tests/Tests/Buffers/SpanReaderTests.cs index 8d6e24e6d..6fa212acd 100644 --- a/Projects/Server.Tests/Tests/Buffers/SpanReaderTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/SpanReaderTests.cs @@ -497,60 +497,6 @@ public class SpanReaderTests Assert.Equal(3, reader.Position); } - [Fact] - public void TestLength() - { - ReadOnlySpan buffer = [0x01, 0x02, 0x03, 0x04, 0x05]; - var reader = new SpanReader(buffer); - - Assert.Equal(5, reader.Length); - } - - [Fact] - public void TestPosition() - { - ReadOnlySpan buffer = [0x01, 0x02, 0x03]; - var reader = new SpanReader(buffer); - - Assert.Equal(0, reader.Position); - - reader.ReadByte(); - Assert.Equal(1, reader.Position); - - reader.ReadUInt16(); - Assert.Equal(3, reader.Position); - } - - [Fact] - public void TestRemaining() - { - ReadOnlySpan buffer = [0x01, 0x02, 0x03, 0x04, 0x05]; - var reader = new SpanReader(buffer); - - Assert.Equal(5, reader.Remaining); - - reader.ReadByte(); - Assert.Equal(4, reader.Remaining); - - reader.ReadUInt16(); - Assert.Equal(2, reader.Remaining); - - reader.ReadUInt16(); - Assert.Equal(0, reader.Remaining); - } - - [Fact] - public void TestBuffer() - { - ReadOnlySpan buffer = [0x01, 0x02, 0x03]; - var reader = new SpanReader(buffer); - - var bufferProperty = reader.Buffer; - - Assert.Equal(3, bufferProperty.Length); - AssertThat.Equal(bufferProperty, buffer); - } - [Fact] public void TestReadStringEmptyFixedLength() { diff --git a/Projects/Server.Tests/Tests/Buffers/SpanWriterTests.cs b/Projects/Server.Tests/Tests/Buffers/SpanWriterTests.cs index 370024370..10fe1b73f 100644 --- a/Projects/Server.Tests/Tests/Buffers/SpanWriterTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/SpanWriterTests.cs @@ -75,19 +75,6 @@ public class SpanWriterTests AssertThat.Equal(writer.Span, [1, 0]); } - [Fact] - public void TestWriteByte() - { - Span buffer = stackalloc byte[2]; - var writer = new SpanWriter(buffer); - - writer.Write((byte)0x12); - writer.Write((byte)0x34); - - Assert.Equal(2, writer.Position); - AssertThat.Equal(writer.Span, [0x12, 0x34]); - } - [Fact] public void TestWriteSByte() { @@ -235,19 +222,6 @@ public class SpanWriterTests AssertThat.Equal(writer.Span, [0x01, 0x02, 0x03, 0x01, 0x02, 0x03]); } - [Fact] - public void TestWriteAsciiChar() - { - Span buffer = stackalloc byte[2]; - var writer = new SpanWriter(buffer); - - writer.WriteAscii('A'); - writer.WriteAscii('B'); - - Assert.Equal(2, writer.Position); - AssertThat.Equal(writer.Span, "AB"u8); - } - [Fact] public void TestWriteAsciiString() { @@ -457,16 +431,6 @@ public class SpanWriterTests writer.Dispose(); } - [Fact] - public void TestEnsureCapacityNoResize() - { - Span buffer = stackalloc byte[10]; - var writer = new SpanWriter(buffer); - - writer.EnsureCapacity(10); - Assert.Equal(10, writer.Capacity); - } - [Fact] public void TestEnsureCapacityNoResizeThrows() { @@ -552,40 +516,4 @@ public class SpanWriterTests writer.Write((byte)0xFF); Assert.Equal(3, writer.BytesWritten); } - - [Fact] - public void TestCapacity() - { - Span buffer = stackalloc byte[10]; - var writer = new SpanWriter(buffer); - - Assert.Equal(10, writer.Capacity); - } - - [Fact] - public void TestRawBuffer() - { - Span buffer = stackalloc byte[10]; - var writer = new SpanWriter(buffer); - - writer.Write((byte)0x01); - - var raw = writer.RawBuffer; - Assert.Equal(10, raw.Length); - Assert.Equal(0x01, raw[0]); - } - - [Fact] - public void TestSpan() - { - Span buffer = stackalloc byte[10]; - var writer = new SpanWriter(buffer); - - writer.Write((byte)0x01); - writer.Write((byte)0x02); - - var span = writer.Span; - Assert.Equal(2, span.Length); - AssertThat.Equal(span, [0x01, 0x02]); - } } diff --git a/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs b/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs index 7299a94c4..d3dfd12f2 100644 --- a/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/ValueStringBuilderTests.cs @@ -52,19 +52,6 @@ public class ValueStringBuilderTests // writes into the shared buffer, and Append() reconciles via `this = handler._builder`. // Critical to detect if C# compiler codegen changes break this assumption. - [Fact] - public void Interpolation_Stackalloc_NoGrow() - { - // Stackalloc buffer large enough — no Grow needed. - // Validates: copy's Span shares same stackalloc memory, _length is reconciled. - var sb = new ValueStringBuilder(stackalloc char[64]); - sb.Append($"Hello {42} world"); - - Assert.Equal("Hello 42 world", sb.ToString()); - Assert.Equal(14, sb.Length); - sb.Dispose(); - } - [Fact] public void Interpolation_Stackalloc_WithGrow() { @@ -82,20 +69,6 @@ public class ValueStringBuilderTests sb.Dispose(); } - [Fact] - public void Interpolation_Stackalloc_PreExistingContent_NoGrow() - { - // Append plain text first, then interpolation. Buffer large enough. - // Validates: interpolation appends after existing content, doesn't overwrite. - var sb = new ValueStringBuilder(stackalloc char[64]); - sb.Append("prefix-"); - sb.Append($"value={99}"); - - Assert.Equal("prefix-value=99", sb.ToString()); - Assert.Equal(15, sb.Length); - sb.Dispose(); - } - [Fact] public void Interpolation_Stackalloc_PreExistingContent_WithGrow() { @@ -264,20 +237,6 @@ public class ValueStringBuilderTests sb.Dispose(); } - [Fact] - public void Interpolation_Stackalloc_DisposeAfterGrow() - { - // After Grow moves stackalloc to pool, Dispose should return the pooled array. - // Validates: _arrayToReturnToPool is correctly reconciled so Dispose works. - var sb = new ValueStringBuilder(stackalloc char[4]); - sb.Append($"grow beyond stackalloc: {12345}"); - // If _arrayToReturnToPool wasn't reconciled, Dispose would either - // not return the pooled array (leak) or return null (no-op when it shouldn't be). - sb.Dispose(); - // No assertion needed — if _arrayToReturnToPool was wrong, pool corruption - // would surface as test failures elsewhere. The test passing = no crash. - } - [Fact] public void Interpolation_Heap_DoubleGrow() { diff --git a/Projects/Server.Tests/Tests/Collections/ValueLinkListTests.cs b/Projects/Server.Tests/Tests/Collections/ValueLinkListTests.cs index 9683c8712..fa2a18417 100644 --- a/Projects/Server.Tests/Tests/Collections/ValueLinkListTests.cs +++ b/Projects/Server.Tests/Tests/Collections/ValueLinkListTests.cs @@ -16,13 +16,6 @@ public class ValueLinkListTests public TestEntity(int serial) => Serial = serial; } - [Fact] - public void TestDefaultListIsEmpty() - { - var linkList = new ValueLinkList(); - Assert.Equal(0, linkList.Count); - } - [Fact] public void TestAddingElementsFirst() { @@ -397,29 +390,4 @@ public class ValueLinkListTests } ); } - - [Fact] - public void TestThrowsIfModifiedWhileIteratingNested() - { - Assert.Throws( - () => - { - var linkList = new ValueLinkList(); - - var entity1 = new TestEntity(1); - var entity2 = new TestEntity(2); - - linkList.AddFirst(entity1); - linkList.AddLast(entity2); - - foreach (var item in linkList) - { - foreach (var nestedItem in linkList) - { - linkList.Remove(nestedItem); - } - } - } - ); - } } diff --git a/Projects/Server.Tests/Tests/Geometry/Point2DTests.cs b/Projects/Server.Tests/Tests/Geometry/Point2DTests.cs index b93a391af..81ae7ad84 100644 --- a/Projects/Server.Tests/Tests/Geometry/Point2DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Point2DTests.cs @@ -16,31 +16,6 @@ public class Point2DTests Assert.Equal($"({int.MaxValue}, {int.MinValue})", new Point2D(int.MaxValue, int.MinValue).ToString()); } - [Fact] - public void TestPoint2DTryFormatSucceeds() - { - var array = new char[128]; - - var p1 = new Point2D(0, 0); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(6, cp1); - Array.Clear(array); - - var p2 = new Point2D(1, 1); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(6, cp2); - Array.Clear(array); - - var p3 = new Point2D(int.MaxValue, int.MaxValue); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(4 + 20, cp3); - Array.Clear(array); - - var p4 = new Point2D(int.MinValue, int.MinValue); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(4 + 22, cp4); - } - [Fact] public void TestPoint2DTryFormatFails() { diff --git a/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs b/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs index 8bdd279f5..7fae1f3bf 100644 --- a/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs @@ -27,33 +27,6 @@ public class Point3DTests Assert.Equal($"({min}, {min}, {min})", new Point3D(min, min, min).ToString()); } - [Fact] - public void TestPoint3DTryFormatSucceeds() - { - const int max = int.MaxValue; - const int min = int.MinValue; - var array = new char[128]; - - var p1 = new Point3D(0, 0, 0); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(6 + 3, cp1); - Array.Clear(array); - - var p2 = new Point3D(1, 1, 1); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(6 + 3, cp2); - Array.Clear(array); - - var p3 = new Point3D(max, max, max); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(6 + 30, cp3); - Array.Clear(array); - - var p4 = new Point3D(min, min, min); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(6 + 33, cp4); - } - [Fact] public void TestPoint3DTryFormatFails() { diff --git a/Projects/Server.Tests/Tests/Geometry/Rectangle2DTests.cs b/Projects/Server.Tests/Tests/Geometry/Rectangle2DTests.cs index 1c9bb58fd..87c47c786 100644 --- a/Projects/Server.Tests/Tests/Geometry/Rectangle2DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Rectangle2DTests.cs @@ -16,33 +16,6 @@ public class Rectangle2DTests Assert.Equal($"({min}, {min})+({min}, {min})", new Rectangle2D(min, min, min, min).ToString()); } - [Fact] - public void TestRectangle2DTryFormatSucceeds() - { - const int max = int.MaxValue; - const int min = int.MinValue; - var array = new char[128]; - - var p1 = new Rectangle2D(0, 0, 0, 0); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(9 + 4, cp1); - Array.Clear(array); - - var p2 = new Rectangle2D(1, 1, 1, 1); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(9 + 4, cp2); - Array.Clear(array); - - var p3 = new Rectangle2D(max, max, max, max); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(9 + 4 * 10, cp3); - Array.Clear(array); - - var p4 = new Rectangle2D(min, min, min, min); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(9 + 4 * 11, cp4); - } - [Fact] public void TestRectangle2DTryFormatFails() { diff --git a/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs b/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs index 2b030a183..d225b625f 100644 --- a/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs @@ -16,33 +16,6 @@ public class Rectangle3DTests Assert.Equal($"({min}, {min}, {min})+({min}, {min}, {min})", new Rectangle3D(min, min, min, min, min, min).ToString()); } - [Fact] - public void TestRectangle3DTryFormatSucceeds() - { - const int max = int.MaxValue; - const int min = int.MinValue; - var array = new char[128]; - - var p1 = new Rectangle3D(0, 0, 0, 0, 0, 0); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(13 + 6, cp1); - Array.Clear(array); - - var p2 = new Rectangle3D(1, 1, 1, 1, 1, 1); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(13 + 6, cp2); - Array.Clear(array); - - var p3 = new Rectangle3D(max, max, max, max, max, max); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(13 + 6 * 10, cp3); - Array.Clear(array); - - var p4 = new Rectangle3D(min, min, min, min, min, min); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(13 + 6 * 11, cp4); - } - [Fact] public void TestRectangle3DTryFormatFails() { diff --git a/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs b/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs index b9fcc6f7a..561ece1f9 100644 --- a/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs @@ -39,40 +39,6 @@ public sealed class WorldLocationTests Assert.Equal($"({min}, {min}, {min}) [{_superLongMapName}]", new WorldLocation(min, min, min, map).ToString()); } - [Theory] - [InlineData(null)] - [InlineData("Internal Map")] - [InlineData(_superLongMapName)] - public void TestWorldLocationTryFormatSucceeds(string mapName) - { - var map = mapName != null ? CreateMap(mapName) : null; - var mapNameLength = mapName?.Length ?? _nullMap.Length; - - const int max = int.MaxValue; - const int min = int.MinValue; - var array = new char[128]; - - var p1 = new WorldLocation(0, 0, 0, map); - Assert.True(p1.TryFormat(array, out var cp1, null, null)); - Assert.Equal(9 + 3 + mapNameLength, cp1); - Array.Clear(array); - - var p2 = new WorldLocation(1, 1, 1, map); - Assert.True(p2.TryFormat(array, out var cp2, null, null)); - Assert.Equal(9 + 3 + mapNameLength, cp2); - Array.Clear(array); - - array = new char[256]; // We need a bigger buffer! - var p3 = new WorldLocation(max, max, max, map); - Assert.True(p3.TryFormat(array, out var cp3, null, null)); - Assert.Equal(9 + 30 + mapNameLength, cp3); - Array.Clear(array); - - var p4 = new WorldLocation(min, min, min, map); - Assert.True(p4.TryFormat(array, out var cp4, null, null)); - Assert.Equal(9 + 33 + mapNameLength, cp4); - } - [Theory] [InlineData(null)] [InlineData("Internal Map")] diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs index 5b1cd471c..550574770 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs @@ -66,27 +66,6 @@ public class ContainerChildSkipTests } // A skipped child is not saved, so the container must load without it. - [Fact] - public void SkippingContainer_LoadsWithoutItsSkippedChildren() - { - var pack = new SkippingContainer(); - var item = new Item(0x1234); - pack.DropItem(item); - - var writer = new BufferWriter(true); - pack.Serialize(writer); - var buffer = new byte[writer.Position]; - writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); - - item.Delete(); - pack.Delete(); - - var copy = new SkippingContainer(World.NewItem); - copy.Deserialize(new BufferReader(buffer)); - - Assert.Empty(copy.Items); - } - // A written child serial could resolve at load to an unrelated item that was handed the same serial. [Fact] public void SkippingContainer_DoesNotWriteAliveChildSerial() diff --git a/Projects/Server.Tests/Tests/Items/ContainerTests.cs b/Projects/Server.Tests/Tests/Items/ContainerTests.cs index 6b7634da8..6cef3a040 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerTests.cs @@ -419,15 +419,6 @@ public class ContainerTests Assert.Equal(0, c.GetBestGroupAmount(new[] { typeof(TestReagentA) }, true, HueGrouper)); } - [Fact] - public void TestGetBestGroupAmount_NullGrouper_Throws() - { - var c = MakeContainer(0x2C0); - Assert.Throws( - () => c.GetBestGroupAmount(new[] { typeof(TestReagentA) }, true, null) - ); - } - [Fact] public void TestGetAmount_TypeArray_SumsAcrossTypes() { @@ -470,39 +461,6 @@ public class ContainerTests Assert.Equal(1, s2.Amount); } - [Fact] - public void TestConsumeTotal_LengthMismatch_Throws() - { - var c = MakeContainer(0x300); - Assert.Throws( - () => c.ConsumeTotal(new[] { typeof(TestReagentA) }, new[] { 1, 2 }) - ); - } - - [Fact] - public void TestConsumeTotalGrouped_LengthMismatch_Throws() - { - var c = MakeContainer(0x310); - Assert.Throws( - () => c.ConsumeTotalGrouped( - new[] { typeof(TestReagentA) }, new[] { 1, 2 }, - true, null, HueGrouper - ) - ); - } - - [Fact] - public void TestConsumeTotalGrouped_NullGrouper_Throws() - { - var c = MakeContainer(0x320); - Assert.Throws( - () => c.ConsumeTotalGrouped( - new[] { typeof(TestReagentA) }, new[] { 1 }, - true, null, null - ) - ); - } - // ------------------------------------------------------------------------- // FindItemsByType(Type) and FindItemsByType(ReadOnlySpan) — Phase 10: // these used to allocate a Predicate per call (method-group conversion diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 59d736c6f..0f97eaa28 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -21,32 +21,6 @@ public class DecayRegistrationTests } // The item is already dequeued, so a refusal that does not reschedule strands it forever. - [Fact] - public void ItemWhoseDecayIsRefused_StaysTrackedAndSurvives() - { - var start = Core._now; - - try - { - var item = new RefusesDecayItem(); - item.MoveToWorld(new Point3D(106, 100, 0), Map.Felucca); - - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); - - Assert.False(item.Deleted, "A refused decay must not delete the item."); - Assert.True( - DecayScheduler.IsRegistered(item), - "A refused decay must leave the item tracked so it is retried." - ); - - item.Delete(); - } - finally - { - Core._now = start; - } - } - // Drives the scheduler and the game clock without depending on the shared timer wheel. private static void AdvanceDecay(DateTime start, TimeSpan duration, Item item) { @@ -103,68 +77,9 @@ public class DecayRegistrationTests } } - [Fact] - public void ItemPlacedInWorld_IsRegisteredForDecay() - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - - Assert.True(item.CanDecay()); - Assert.True(DecayScheduler.IsRegistered(item), "Item on the ground must be tracked for decay."); - - item.Delete(); - } - // The real client path: Mobile.Lift internalizes, then Mobile.Drop calls MoveToWorld. - [Fact] - public void ItemLiftedThenDroppedToGround_IsRegisteredForDecay() - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - - // Player lifts the item to the cursor. - item.Internalize(); - Assert.Equal(Map.Internal, item.Map); - - // Player drops it back onto the ground. - item.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca); - - Assert.True(item.CanDecay()); - Assert.True( - DecayScheduler.IsRegistered(item), - "Item dropped to the ground after being lifted must be tracked for decay." - ); - - item.Delete(); - } - // Held on the cursor means Map.Internal, which must not be tracked. - [Fact] - public void ItemLiftedToCursor_IsNotRegisteredForDecay() - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - - item.Internalize(); - - Assert.False(item.CanDecay()); - Assert.False(DecayScheduler.IsRegistered(item), "Item held on the cursor must not be tracked for decay."); - - item.Delete(); - } - // A new item is on Map.Internal, so construction must not touch the scheduler. - [Fact] - public void NewItem_IsNotTracked() - { - var item = new Item(0x1234); - - Assert.Equal(DecayScheduler.SlotNone, item.DecaySlot); - Assert.False(DecayScheduler.IsRegistered(item), "A newly constructed item must not be tracked."); - - item.Delete(); - } - // Unregister() trusts DecaySlot; IsRegistered() scans the structures. Assert both to catch drift. [Fact] public void DecaySlot_AgreesWithStructures_AcrossLifecycle() @@ -475,24 +390,4 @@ public class DecayRegistrationTests } // Dropping into a container must untrack; taking it back out to the ground must re-track. - [Fact] - public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() - { - var pack = new Container(0xE75); - pack.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - - var item = new Item(0x1234); - pack.AddItem(item); - - Assert.False(DecayScheduler.IsRegistered(item), "Item inside a container must not be tracked for decay."); - - item.Internalize(); - item.MoveToWorld(new Point3D(101, 100, 0), Map.Felucca); - - Assert.True(item.CanDecay()); - Assert.True(DecayScheduler.IsRegistered(item), "Item taken out of a container must be tracked for decay."); - - item.Delete(); - pack.Delete(); - } } diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs index 955a87293..aa094365d 100644 --- a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs +++ b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs @@ -24,25 +24,6 @@ public class PlayerConstructedStackingTests private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; - [Theory] - [InlineData(false)] - [InlineData(true)] - public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed) - { - var first = MakeStack((Serial)0x1, 5, playerConstructed); - var second = MakeStack((Serial)0x2, 7, playerConstructed); - - try - { - Assert.True(first.CanStackWith(second)); - } - finally - { - first.Delete(); - second.Delete(); - } - } - [Theory] [InlineData(false)] [InlineData(true)] diff --git a/Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs b/Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs deleted file mode 100644 index 437b674ff..000000000 --- a/Projects/Server.Tests/Tests/Json/JsonDiscoverableTypeAttributeTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Server.Json; -using Xunit; - -namespace Server.Tests.Json; - -public class JsonDiscoverableTypeAttributeTests -{ - [JsonDiscoverableType] - private sealed class DefaultName { } - - [JsonDiscoverableType("custom")] - private sealed class Overridden { } - - [Fact] - public void DefaultDiscriminator_IsNull() - { - var attr = (JsonDiscoverableTypeAttribute)System.Attribute.GetCustomAttribute( - typeof(DefaultName), typeof(JsonDiscoverableTypeAttribute), false); - Assert.NotNull(attr); - Assert.Null(attr.Discriminator); - } - - [Fact] - public void OverrideDiscriminator_IsReturned() - { - var attr = (JsonDiscoverableTypeAttribute)System.Attribute.GetCustomAttribute( - typeof(Overridden), typeof(JsonDiscoverableTypeAttribute), false); - Assert.Equal("custom", attr.Discriminator); - } -} diff --git a/Projects/Server.Tests/Tests/Json/Rectangle3DConverterTests.cs b/Projects/Server.Tests/Tests/Json/Rectangle3DConverterTests.cs index b91c521dc..13e33f09f 100644 --- a/Projects/Server.Tests/Tests/Json/Rectangle3DConverterTests.cs +++ b/Projects/Server.Tests/Tests/Json/Rectangle3DConverterTests.cs @@ -35,12 +35,4 @@ public class Rectangle3DConverterTests Assert.DoesNotContain("z1", json); Assert.DoesNotContain("z2", json); } - - [Fact] - public void HomeRangeBounds_WritesZ() - { - var json = JsonSerializer.Serialize(new Rectangle3D(0, 0, -128, 10, 10, 256), JsonConfig.DefaultOptions); - Assert.Contains("\"z1\":", json); - Assert.Contains("\"z2\":", json); - } } diff --git a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs index 57a6431ef..684c77743 100644 --- a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs @@ -220,39 +220,6 @@ public class CanFitItemTests } } - [SkippableFact] - public void CanFitItem_CanPlaceOnTopOfImpassable() - { - SkipIfNoTileData(); - Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData"); - - var map = Map.Felucca; - const int x = 1950; - const int y = 1950; - - TestMulti multi = null; - try - { - // Get the actual tile height from TileData - var tileData = TileData.ItemTable[ServerFixture.SurfaceImpassableTileId]; - var tileHeight = tileData.CalcHeight; - const int tileZ = 10; - - // Create a Surface+Impassable tile at Z=10 - multi = CreateSurfaceImpassableMulti(map, new Point3D(x, y, 0), surfaceZ: tileZ); - - // Place item on top should work - var surfaceTop = tileZ + tileHeight; - var result = map.CanFitItem(x, y, surfaceTop, 1); - - Assert.True(result, $"Should be able to place item on top of Surface+Impassable tile at Z={surfaceTop}"); - } - finally - { - multi?.Delete(); - } - } - private TestMulti CreateSurfaceImpassableMulti(Map map, Point3D location, int surfaceZ) { // Use SurfaceImpassableTileId - a tile that has both Surface and Impassable flags diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs index 87135f080..ea12926f1 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs @@ -23,32 +23,6 @@ public class CanSpawnMobileTileDataTests Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); } - [SkippableFact] - public void CanSpawnMobile_FindsSurfaceOnMulti() - { - SkipIfNoTileData(); - - var map = Map.Felucca; - const int x = 1100; - const int y = 1100; - - TestMulti multi = null; - try - { - // Create a multi with a floor at Z=20 using a real surface tile - multi = CreateFloorMulti(map, new Point3D(x, y, 0), floorZ: 20); - - var result = map.CanSpawnMobile(x, y, 15, 30, false, false, out var spawnZ); - - Assert.True(result); - Assert.True(spawnZ >= 15 && spawnZ <= 30); - } - finally - { - multi?.Delete(); - } - } - [SkippableFact] public void CanSpawnMobile_FindsLowestValidSurface() { @@ -239,51 +213,6 @@ public class CanSpawnMobileTileDataTests } } - [SkippableFact] - public void CanSpawnMobile_MalasBuilding_FindsGroundFloor() - { - SkipIfNoTileData(); - - var map = Map.Malas; - const int x = 991; - const int y = 519; - - var result = map.CanSpawnMobile(x, y, -60, -40, false, false, out var spawnZ); - - Assert.True(result); - Assert.True(spawnZ >= -60 && spawnZ <= -40, $"Expected ground floor around -50, got {spawnZ}"); - } - - [SkippableFact] - public void CanSpawnMobile_MalasBuilding_FindsSecondFloor() - { - SkipIfNoTileData(); - - var map = Map.Malas; - const int x = 991; - const int y = 519; - - var result = map.CanSpawnMobile(x, y, -30, 0, false, false, out var spawnZ); - - Assert.True(result); - Assert.True(spawnZ >= -30 && spawnZ <= 0, $"Expected second floor, got {spawnZ}"); - } - - [SkippableFact] - public void CanSpawnMobile_MalasBuilding_LowestFloorPreferred() - { - SkipIfNoTileData(); - - var map = Map.Malas; - const int x = 991; - const int y = 519; - - var result = map.CanSpawnMobile(x, y, -60, 0, false, false, out var spawnZ); - - Assert.True(result); - Assert.True(spawnZ <= -40, $"Expected lowest floor around -50, got {spawnZ}"); - } - private TestMulti CreateFloorMulti(Map map, Point3D location, int floorZ) { var multi = new TestMulti(CreateFloorComponents(floorZ)); diff --git a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs index 629ec3961..9588be01c 100644 --- a/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ClientEnumeratorTests.cs @@ -308,49 +308,6 @@ public class ClientEnumeratorTests } } - [Fact] - public void ClientAtEnumerator_UsesDifferentPoint3DOverloads() - { - var map = Map.Felucca; - var location = new Point3D(800, 800, 5); - - var clients = new (NetState, Mobile)[1]; - try - { - clients[0] = CreateClientWithMobile(map, location); - - // Test Point3D overload - var found1 = new List(); - foreach (var ns in map.GetClientsAt(location)) - { - found1.Add(ns); - } - - // Test (int, int) overload - should find the same client (Z is ignored) - var found2 = new List(); - foreach (var ns in map.GetClientsAt(location.X, location.Y)) - { - found2.Add(ns); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var ns in map.GetClientsAt(new Point2D(location.X, location.Y))) - { - found3.Add(ns); - } - - Assert.Single(found1); - Assert.Equal(clients[0].Item1, found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(clients); - } - } - [Fact] public void ClientEnumerator_GetClientsInRange() { @@ -383,39 +340,6 @@ public class ClientEnumeratorTests } } - [Fact] - public void ClientEnumerator_DeletedMobilesAreSkipped() - { - var map = Map.Felucca; - var rect = new Rectangle2D(1000, 1000, 16, 16); - - var clients = new (NetState, Mobile)[3]; - try - { - clients[0] = CreateClientWithMobile(map, new Point3D(1005, 1005, 0)); - clients[1] = CreateClientWithMobile(map, new Point3D(1006, 1005, 0)); - clients[2] = CreateClientWithMobile(map, new Point3D(1007, 1005, 0)); - - // Delete the mobile (but not the NetState) - clients[1].Item2.Delete(); - - var found = new List(); - foreach (var ns in map.GetClientsInBounds(rect)) - { - found.Add(ns); - } - - // Should skip the client whose mobile was deleted - Assert.Equal(2, found.Count); - Assert.Contains(clients[0].Item1, found); - Assert.Contains(clients[2].Item1, found); - } - finally - { - DeleteAll(clients); - } - } - private static (NetState, Mobile) CreateClientWithMobile(Map map, Point3D location) { // Create test NetState with real socket and buffers diff --git a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs index fb12d0ab6..3e1ea6ee4 100644 --- a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs @@ -38,100 +38,6 @@ public class ItemByDistanceEnumeratorTests } } - [Fact] - public void ItemByDistanceEnumerator_DeletedItemsAreSkipped() - { - var map = Map.Felucca; - var center = new Point3D(200, 200, 0); - const int range = 5; - - var Items = new TestItem[3]; - try - { - Items[0] = CreateItem(map, new Point3D(202, 202, 0)); - Items[1] = CreateItem(map, new Point3D(203, 202, 0)); - Items[2] = CreateItem(map, new Point3D(204, 202, 0)); - - Items[1].Delete(); - - var found = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center, range)) - { - found.Add(Item); - } - - Assert.Equal(2, found.Count); - Assert.Contains(Items[0], found); - Assert.Contains(Items[2], found); - Assert.DoesNotContain(Items[1], found); - } - finally - { - DeleteAll(Items); - } - } - - [Fact] - public void ItemByDistanceEnumerator_ReturnsMinDistance() - { - var map = Map.Felucca; - var center = new Point3D(300, 300, 0); - const int range = 10; - - var Items = new TestItem[2]; - try - { - Items[0] = CreateItem(map, new Point3D(305, 305, 0)); - Items[1] = CreateItem(map, new Point3D(302, 302, 0)); - - var foundWithDistance = new List<(Item, int)>(); - foreach (var result in map.GetItemsInRangeByDistance(center, range)) - { - foundWithDistance.Add(result); - } - - Assert.Equal(2, foundWithDistance.Count); - // Each Item should have a non-negative min distance - Assert.All(foundWithDistance, item => Assert.True(item.Item2 >= 0)); - } - finally - { - DeleteAll(Items); - } - } - - [Fact] - public void ItemByDistanceEnumerator_OrderedBySector() - { - var map = Map.Felucca; - var center = new Point3D(400, 400, 0); - const int range = Map.SectorSize * 2; - - var Items = new TestItem[3]; - try - { - // Place Items in different sectors - Items[0] = CreateItem(map, new Point3D(center.X + 2, center.Y + 2, 0)); - Items[1] = CreateItem(map, new Point3D(center.X + Map.SectorSize + 2, center.Y + 2, 0)); - Items[2] = CreateItem(map, new Point3D(center.X + 2, center.Y + Map.SectorSize + 2, 0)); - - var found = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center, range)) - { - found.Add(Item); - } - - Assert.Equal(3, found.Count); - Assert.Contains(Items[0], found); - Assert.Contains(Items[1], found); - Assert.Contains(Items[2], found); - } - finally - { - DeleteAll(Items); - } - } - [Fact] public void ItemByDistanceEnumerator_MapNullYieldsEmpty() { @@ -239,50 +145,6 @@ public class ItemByDistanceEnumeratorTests } } - [Fact] - public void ItemByDistanceEnumerator_UsesDifferentPointOverloads() - { - var map = Map.Felucca; - var center = new Point3D(800, 800, 5); - const int range = 5; - - var Items = new TestItem[1]; - try - { - Items[0] = CreateItem(map, new Point3D(802, 802, 0)); - - // Test Point3D overload - var found1 = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center, range)) - { - found1.Add(Item); - } - - // Test (int, int) overload - var found2 = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center.X, center.Y, range)) - { - found2.Add(Item); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(new Point2D(center.X, center.Y), range)) - { - found3.Add(Item); - } - - Assert.Single(found1); - Assert.Equal(Items[0], found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(Items); - } - } - [Fact] public void ItemByDistanceEnumerator_RingTraversal() { @@ -384,58 +246,6 @@ public class ItemByDistanceEnumeratorTests } } - [Fact] - public void ItemByDistanceEnumerator_MultipleRings() - { - var map = Map.Felucca; - var center = new Point3D(1100, 1100, 0); - const int range = Map.SectorSize * 3; - - var Items = new List(); - try - { - // Create a grid of Items across multiple sectors - for (var ringOffset = 0; ringOffset <= 2; ringOffset++) - { - for (var side = 0; side < 4; side++) - { - var offset = ringOffset * Map.SectorSize; - var pos = side switch - { - 0 => new Point3D(center.X + offset, center.Y, 0), - 1 => new Point3D(center.X, center.Y + offset, 0), - 2 => new Point3D(center.X - offset, center.Y, 0), - _ => new Point3D(center.X, center.Y - offset, 0) - }; - Items.Add(CreateItem(map, pos)); - } - } - - var found = new List(); - foreach (var (Item, _) in map.GetItemsInRangeByDistance(center, range)) - { - found.Add(Item); - } - - // Should find all Items within range - Assert.True(found.Count > 0); - Assert.All(found, Item => - { - var dx = Item.X - center.X; - var dy = Item.Y - center.Y; - var distSq = dx * dx + dy * dy; - Assert.True(distSq <= range * range); - }); - } - finally - { - foreach (var Item in Items) - { - Item?.Delete(); - } - } - } - [Fact] public void ItemByDistanceEnumerator_MatchesBoundsEnumeratorAtMapEdge() { @@ -556,33 +366,6 @@ public class ItemByDistanceEnumeratorTests } } - [Fact] - public void ItemByDistanceEnumerator_Bounds_ReturnsMinDistance() - { - var map = Map.Felucca; - var bounds = new Rectangle2D(300, 300, 20, 20); - - var Items = new TestItem[2]; - try - { - Items[0] = CreateItem(map, new Point3D(305, 305, 0)); - Items[1] = CreateItem(map, new Point3D(315, 315, 0)); - - var foundWithDistance = new List<(Item, int)>(); - foreach (var result in map.GetItemsInBoundsByDistance(bounds)) - { - foundWithDistance.Add(result); - } - - Assert.Equal(2, foundWithDistance.Count); - Assert.All(foundWithDistance, item => Assert.True(item.Item2 >= 0)); - } - finally - { - DeleteAll(Items); - } - } - [Fact] public void ItemByDistanceEnumerator_Bounds_OrdersByProximityToCenter() { diff --git a/Projects/Server.Tests/Tests/Maps/ItemEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ItemEnumeratorTests.cs index bc92c3f58..5bed0ea2c 100644 --- a/Projects/Server.Tests/Tests/Maps/ItemEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ItemEnumeratorTests.cs @@ -256,35 +256,6 @@ public class ItemEnumeratorTests } } - [Fact] - public void ItemAtEnumerator_DeletedItemsAreSkipped() - { - var map = Map.Felucca; - var location = new Point3D(650, 650, 0); - - var items = new Item[3]; - try - { - items[0] = CreateItem(map, location); - items[1] = CreateItem(map, location); - items[2] = CreateItem(map, location); - - items[1].Delete(); - - var found = new List(); - foreach (var item in map.GetItemsAt(location)) - { - found.Add(item); - } - - Assert.Equal(new[] { items[0], items[2] }, found); - } - finally - { - DeleteAll(items); - } - } - [Fact] public void ItemAtEnumerator_ItemsWithParentAreSkipped() { @@ -365,49 +336,6 @@ public class ItemEnumeratorTests } } - [Fact] - public void ItemAtEnumerator_UsesDifferentPoint3DOverloads() - { - var map = Map.Felucca; - var location = new Point3D(800, 800, 5); - - var items = new Item[1]; - try - { - items[0] = CreateItem(map, location); - - // Test Point3D overload - var found1 = new List(); - foreach (var item in map.GetItemsAt(location)) - { - found1.Add(item); - } - - // Test (int, int) overload - should find the same item (Z is ignored) - var found2 = new List(); - foreach (var item in map.GetItemsAt(location.X, location.Y)) - { - found2.Add(item); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var item in map.GetItemsAt(new Point2D(location.X, location.Y))) - { - found3.Add(item); - } - - Assert.Single(found1); - Assert.Equal(items[0], found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(items); - } - } - [Fact] public void ItemEnumerator_ZeroRangeReturnsOnlyCenter() { diff --git a/Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs b/Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs index 60c3904b3..81ce7bc08 100644 --- a/Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MapSelectionTests.cs @@ -16,15 +16,6 @@ namespace Server.Tests.Maps Assert.Equal(expected, flags.ToCommaDelimitedString()); } - [Fact] - public void TestFormatFeluccaOnlyMapSelection() - { - const MapSelectionFlags flags = MapSelectionFlags.Felucca; - - // When, Then - Assert.Equal("Felucca", flags.ToCommaDelimitedString()); - } - public class TestMapConfig { [JsonConverter(typeof(FlagsConverter))] diff --git a/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs index 3f78377f9..c5d3afe64 100644 --- a/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MobileByDistanceEnumeratorTests.cs @@ -71,76 +71,6 @@ public class MobileByDistanceEnumeratorTests } } - [Fact] - public void MobileByDistanceEnumerator_ReturnsMinDistance() - { - var map = Map.Felucca; - var center = new Point3D(300, 300, 0); - const int range = 10; - - var mobiles = new TestMobile[2]; - try - { - mobiles[0] = CreateMobile(map, new Point3D(305, 305, 0)); - mobiles[1] = CreateMobile(map, new Point3D(302, 302, 0)); - - var foundWithDistance = new List<(Mobile, int)>(); - foreach (var result in map.GetMobilesInRangeByDistance(center, range)) - { - foundWithDistance.Add(result); - } - - Assert.Equal(2, foundWithDistance.Count); - // Each mobile should have a non-negative min distance - Assert.All(foundWithDistance, item => Assert.True(item.Item2 >= 0)); - } - finally - { - DeleteAll(mobiles); - } - } - - [Fact] - public void MobileByDistanceEnumerator_OrderedBySector() - { - var map = Map.Felucca; - var center = new Point3D(400, 400, 0); - const int range = Map.SectorSize * 2; - - var mobiles = new TestMobile[3]; - try - { - // Place mobiles in different sectors - mobiles[0] = CreateMobile(map, new Point3D(center.X + 2, center.Y + 2, 0)); - mobiles[1] = CreateMobile(map, new Point3D(center.X + Map.SectorSize + 2, center.Y + 2, 0)); - mobiles[2] = CreateMobile(map, new Point3D(center.X + 2, center.Y + Map.SectorSize + 2, 0)); - - var found = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(center, range)) - { - found.Add(mobile); - } - - Assert.Equal(3, found.Count); - Assert.Contains(mobiles[0], found); - Assert.Contains(mobiles[1], found); - Assert.Contains(mobiles[2], found); - } - finally - { - DeleteAll(mobiles); - } - } - - [Fact] - public void MobileByDistanceEnumerator_MapNullYieldsEmpty() - { - var center = new Point2D(0, 0); - var bounds = new Rectangle2D(center.m_X - 10, center.m_Y - 10, 21, 21); - var enumerator = new Map.MobileDistanceEnumerable(null, bounds, center, false).GetEnumerator(); - Assert.False(enumerator.MoveNext()); - } - [Fact] public void MobileByDistanceEnumerator_ThrowsOnVersionChange() { @@ -241,50 +171,6 @@ public class MobileByDistanceEnumeratorTests } } - [Fact] - public void MobileByDistanceEnumerator_UsesDifferentPointOverloads() - { - var map = Map.Felucca; - var center = new Point3D(800, 800, 5); - const int range = 5; - - var mobiles = new TestMobile[1]; - try - { - mobiles[0] = CreateMobile(map, new Point3D(802, 802, 0)); - - // Test Point3D overload - var found1 = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(center, range)) - { - found1.Add(mobile); - } - - // Test (int, int) overload - var found2 = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(center.X, center.Y, range)) - { - found2.Add(mobile); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(new Point2D(center.X, center.Y), range)) - { - found3.Add(mobile); - } - - Assert.Single(found1); - Assert.Equal(mobiles[0], found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(mobiles); - } - } - [Fact] public void MobileByDistanceEnumerator_RingTraversal() { @@ -386,58 +272,6 @@ public class MobileByDistanceEnumeratorTests } } - [Fact] - public void MobileByDistanceEnumerator_MultipleRings() - { - var map = Map.Felucca; - var center = new Point3D(1100, 1100, 0); - const int range = Map.SectorSize * 3; - - var mobiles = new List(); - try - { - // Create a grid of mobiles across multiple sectors - for (var ringOffset = 0; ringOffset <= 2; ringOffset++) - { - for (var side = 0; side < 4; side++) - { - var offset = ringOffset * Map.SectorSize; - var pos = side switch - { - 0 => new Point3D(center.X + offset, center.Y, 0), - 1 => new Point3D(center.X, center.Y + offset, 0), - 2 => new Point3D(center.X - offset, center.Y, 0), - _ => new Point3D(center.X, center.Y - offset, 0) - }; - mobiles.Add(CreateMobile(map, pos)); - } - } - - var found = new List(); - foreach (var (mobile, _) in map.GetMobilesInRangeByDistance(center, range)) - { - found.Add(mobile); - } - - // Should find all mobiles within range - Assert.True(found.Count > 0); - Assert.All(found, mobile => - { - var dx = mobile.X - center.X; - var dy = mobile.Y - center.Y; - var distSq = dx * dx + dy * dy; - Assert.True(distSq <= range * range); - }); - } - finally - { - foreach (var mobile in mobiles) - { - mobile?.Delete(); - } - } - } - private static TestMobile CreateMobile(Map map, Point3D location) { var mobile = new TestMobile(World.NewMobile); @@ -526,33 +360,6 @@ public class MobileByDistanceEnumeratorTests } } - [Fact] - public void MobileByDistanceEnumerator_Bounds_ReturnsMinDistance() - { - var map = Map.Felucca; - var bounds = new Rectangle2D(300, 300, 20, 20); - - var mobiles = new TestMobile[2]; - try - { - mobiles[0] = CreateMobile(map, new Point3D(305, 305, 0)); - mobiles[1] = CreateMobile(map, new Point3D(315, 315, 0)); - - var foundWithDistance = new List<(Mobile, int)>(); - foreach (var result in map.GetMobilesInBoundsByDistance(bounds)) - { - foundWithDistance.Add(result); - } - - Assert.Equal(2, foundWithDistance.Count); - Assert.All(foundWithDistance, item => Assert.True(item.Item2 >= 0)); - } - finally - { - DeleteAll(mobiles); - } - } - [Fact] public void MobileByDistanceEnumerator_Bounds_OrdersByProximityToCenter() { diff --git a/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs index ee932096f..4f9539486 100644 --- a/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MobileEnumeratorTests.cs @@ -87,13 +87,6 @@ public class MobileEnumeratorTests } } - [Fact] - public void MobileEnumerator_MapNullYieldsEmpty() - { - var enumerator = new Map.MobileEnumerator(null, Rectangle2D.Empty, false); - Assert.False(enumerator.MoveNext()); - } - [Fact] public void MobileEnumerator_ThrowsOnVersionChange() { diff --git a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs index c064623bf..c5ed6c517 100644 --- a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs @@ -88,13 +88,6 @@ public class MultiEnumeratorTests } } - [Fact] - public void MultiEnumerator_MapNullYieldsEmpty() - { - var enumerator = new Map.MultiBoundsEnumerable(null, Rectangle2D.Empty, false).GetEnumerator(); - Assert.False(enumerator.MoveNext()); - } - [Fact] public void MultiEnumerator_ThrowsOnVersionChange() { @@ -250,35 +243,6 @@ public class MultiEnumeratorTests } } - [Fact] - public void MultiSectorEnumerator_DeletedMultisAreSkipped() - { - var map = Map.Felucca; - var location = new Point3D(750, 750, 0); - - var multis = new TestMulti[3]; - try - { - multis[0] = CreateMulti(map, location); - multis[1] = CreateMulti(map, new Point3D(location.X + 1, location.Y, 0)); - multis[2] = CreateMulti(map, new Point3D(location.X + 2, location.Y, 0)); - - multis[1].Delete(); - - var found = new List(); - foreach (var multi in map.GetMultisInRange(location, 10)) - { - found.Add(multi); - } - - Assert.Equal(new[] { multis[0], multis[2] }, found); - } - finally - { - DeleteAll(multis); - } - } - [Fact] public void MultiSectorEnumerator_MapNullYieldsEmpty() { @@ -286,49 +250,6 @@ public class MultiEnumeratorTests Assert.False(enumerator.MoveNext()); } - [Fact] - public void MultiSectorEnumerator_UsesDifferentPointOverloads() - { - var map = Map.Felucca; - var location = new Point3D(800, 800, 5); - - var multis = new TestMulti[1]; - try - { - multis[0] = CreateMulti(map, location); - - // Test Point3D overload - var found1 = new List(); - foreach (var multi in map.GetMultisInSector(location)) - { - found1.Add(multi); - } - - // Test (int, int) overload - var found2 = new List(); - foreach (var multi in map.GetMultisInSector(location.X, location.Y)) - { - found2.Add(multi); - } - - // Test Point2D overload - var found3 = new List(); - foreach (var multi in map.GetMultisInSector(new Point2D(location.X, location.Y))) - { - found3.Add(multi); - } - - Assert.Single(found1); - Assert.Equal(multis[0], found1[0]); - Assert.Equal(found1, found2); - Assert.Equal(found1, found3); - } - finally - { - DeleteAll(multis); - } - } - [Fact] public void MultiEnumerator_ZeroRangeReturnsOnlyCenter() { diff --git a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs index edce65c89..07b8e37d3 100644 --- a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs @@ -7,29 +7,6 @@ namespace Server.Tests.Maps; [Collection("Sequential Server Tests")] public class StaticTileEnumeratorTests { - [Fact] - public void StaticTileEnumerator_MapNullYieldsEmpty() - { - var enumerator = new Map.StaticTileEnumerable(null, new Point2D(0, 0)).GetEnumerator(); - Assert.False(enumerator.MoveNext()); - } - - [Fact] - public void StaticTileEnumerator_EmptyLocationYieldsEmpty() - { - var map = Map.Felucca; - var location = new Point2D(100, 100); - - var tiles = new List(); - foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: true, includeMultis: false)) - { - tiles.Add(tile); - } - - // Since we don't have actual map files loaded, this should be empty - Assert.Empty(tiles); - } - [Fact] public void StaticTileEnumerator_IncludeStaticsOnlyWorks() { @@ -144,24 +121,6 @@ public class StaticTileEnumeratorTests } } - [Fact] - public void StaticTileEnumerator_TileMatrixGetStaticTilesWorks() - { - var map = Map.Felucca; - var x = 600; - var y = 600; - - // Test the TileMatrix.GetStaticTiles method - var tiles = new List(); - foreach (var tile in map.Tiles.GetStaticTiles(x, y)) - { - tiles.Add(tile); - } - - // Without map files loaded, should be empty - Assert.Empty(tiles); - } - [Fact] public void StaticTileEnumerator_TileMatrixGetStaticAndMultiTilesWorks() { @@ -252,13 +211,6 @@ public class StaticTileEnumeratorTests } } - [Fact] - public void StaticTileEnumerator_EmptyReturnsCorrectly() - { - var enumerator = Map.StaticTileEnumerable.Empty.GetEnumerator(); - Assert.False(enumerator.MoveNext()); - } - [Fact] public void StaticTileEnumerator_DeletedMultiSkipped() { diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs index ee232b1df..c7a5f9a1e 100644 --- a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs +++ b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs @@ -32,13 +32,4 @@ public class BanConfigurationTests Assert.Equal(original.ReportRateLimitTrips, restored.ReportRateLimitTrips); Assert.Equal(original.AutoBanDuration, restored.AutoBanDuration); // TimeSpan survives } - - [Fact] - public void BanSettings_Defaults_AreReportRateLimitTripsFourHourAutoBan() - { - var settings = new BanSettings(); - - Assert.True(settings.ReportRateLimitTrips); - Assert.Equal(TimeSpan.FromHours(4), settings.AutoBanDuration); - } } diff --git a/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs index dfb8c7be7..c5dcb190c 100644 --- a/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs +++ b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs @@ -10,14 +10,6 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class EventLoopIdleTests { - [Fact] - public void FreshContextIsEmpty() - { - var context = new EventLoopContext(); - - Assert.True(context.IsEmpty); - } - [Fact] public void PostedWorkMakesContextNonEmpty() { diff --git a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs index 63cb9d4a6..8644993ab 100644 --- a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs +++ b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs @@ -74,254 +74,6 @@ public class MovementThrottleLatencyTests } } - /// - /// Scenario: Player experiences a 3-second network lag spike, then all buffered - /// packets arrive at once. This should NOT trigger false positive detection. - /// - /// Timeline: - /// - T=0-1000ms: Normal movement (10 packets at 100ms intervals) - /// - T=1000-4000ms: Network lag (client buffers packets) - /// - T=4000ms: Network recovers, 30 buffered packets arrive simultaneously - /// - [Fact] - public void LagSpike_BurstRecovery_NoFalsePositive() - { - var ns = PacketTestUtilities.CreateTestNetState(); - SetTickCount(0); - - // Phase 1: Normal movement for 1 second - var movements = new (int delayMs, int costMs)[10]; - for (var i = 0; i < 10; i++) - { - movements[i] = (100, 100); // Normal mounted running - } - SimulateMovements(ns, movements); - - // Verify normal rate before lag - var rateBeforeLag = MovementThrottle.CalculateMovementRate(ns, out var samples); - Assert.True(samples >= 8, $"Expected >= 8 samples, got {samples}"); - Assert.True(rateBeforeLag >= 0.95f && rateBeforeLag <= 1.05f, - $"Expected normal rate before lag, got {rateBeforeLag}"); - - // Phase 2: Simulate 3 second lag followed by burst - // The gap (3000ms) is tracked, then burst packets arrive - var currentTime = Core.TickCount; - - // Record the lag gap - SetTickCount(currentTime + 3000); - ns._lastGapDuration = 3000; // Gap > maxChainGap triggers this - - // Burst packets arriving (5 packets with tiny intervals) - for (var i = 0; i < 5; i++) - { - SetTickCount(Core.TickCount + 2); // 2ms between packets (simultaneous arrival) - - if (ns._lastMovementRecordTime > 0) - { - var interval = (int)(Core.TickCount - ns._lastMovementRecordTime); - ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord - { - Interval = (short)interval, - TargetSpeed = 100, - QueueDepth = 0, - Flags = 0 - }; - ns._movementHistoryIndex++; - if (ns._movementHistoryIndex >= 20) - { - ns._movementHistoryIndex = 0; - ns._movementHistoryFull = true; - } - } - ns._lastMovementRecordTime = Core.TickCount; - } - - // Analyze - should apply burst forgiveness due to large gap - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - - // The burst will have high rate due to tiny intervals, but: - // 1. _lastGapDuration > 1000 (maxChainGap/2) triggers burst forgiveness - // 2. Confidence should be reduced by 0.3f multiplier - // 3. Should NOT be Definite unless truly extreme - Assert.True( - verdict != MovementThrottle.DetectionVerdict.Definite, - $"Lag recovery should not be Definite. Rate={rate}, Confidence={confidence}, Verdict={verdict}" - ); - - // Gap duration should be cleared after analysis - Assert.Equal(0, ns._lastGapDuration); - } - - /// - /// Scenario: Player with consistently high latency (400ms RTT) moves normally. - /// Their connection is consistent but slow - should NOT be considered "stable" - /// for the purpose of tightening detection thresholds. - /// - [Fact] - public void HighLatencyConsistent_NotConsideredStable() - { - var ns = PacketTestUtilities.CreateTestNetState(); - - // Set up high but consistent RTT - ns._rttHistory = new long[] { 400, 405, 398, 402, 401, 399, 403, 400 }; - ns._rttSampleCount = 8; - - // Calculate variance (should be low since values are consistent) - long sum = 0, sumSq = 0; - for (var i = 0; i < 8; i++) - { - sum += ns._rttHistory[i]; - sumSq += ns._rttHistory[i] * ns._rttHistory[i]; - } - var mean = sum / 8; - ns._rttVariance = sumSq / 8 - mean * mean; - - // Variance is low (consistent), but should NOT be "stable" due to high latency - Assert.False(ns.HasStableConnection, - $"400ms RTT should not be stable. AvgRTT={ns.AverageRtt}, Variance={ns._rttVariance}"); - - // Now verify detection gives more leniency for high-latency connections - SimulateMovements(ns, - (105, 100), (105, 100), (105, 100), (105, 100), (105, 100), - (105, 100), (105, 100), (105, 100), (105, 100), (105, 100) - ); - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - - // Slightly slow but high-latency player should get benefit of doubt - Assert.True( - verdict == MovementThrottle.DetectionVerdict.Normal || - verdict == MovementThrottle.DetectionVerdict.Possible, - $"High latency player should get leniency. Verdict={verdict}" - ); - } - - /// - /// Scenario: RTT probing and measurement. - /// Verifies that RTT variance is calculated correctly and stability - /// is properly determined. - /// - [Fact] - public void RttMeasurement_VarianceCalculation() - { - var ns = PacketTestUtilities.CreateTestNetState(); - SetTickCount(1000); - - // Simulate stable low-latency RTT measurements - var rttValues = new long[] { 50, 48, 52, 49, 51, 50, 48, 52 }; - - ns._rttHistory = new long[8]; - for (var i = 0; i < rttValues.Length; i++) - { - ns._rttHistory[i] = rttValues[i]; - } - ns._rttSampleCount = 8; - - // Calculate expected variance - long sum = 0, sumSq = 0; - foreach (var rtt in rttValues) - { - sum += rtt; - sumSq += rtt * rtt; - } - var expectedMean = sum / 8; - var expectedVariance = sumSq / 8 - expectedMean * expectedMean; - - // Clamp negative variance (safety check) - if (expectedVariance < 0) - { - expectedVariance = 0; - } - - ns._rttVariance = expectedVariance; - - Assert.True(ns._rttVariance >= 0, "Variance should never be negative"); - Assert.True(ns._rttVariance < 2500, "Low-variance samples should have variance < 2500"); - Assert.True(ns.AverageRtt > 0 && ns.AverageRtt < 200, "Average RTT should be ~50ms"); - Assert.True(ns.HasStableConnection, "Should be considered stable connection"); - } - - /// - /// Scenario: Actual speed hacker with stable connection. - /// Should be detected with high confidence. - /// - [Fact] - public void SpeedHack_StableConnection_DetectedDefinite() - { - var ns = PacketTestUtilities.CreateTestNetState(); - - // Set up stable, low-latency RTT - ns._rttHistory = new long[] { 30, 32, 28, 31, 29, 30, 31, 30 }; - ns._rttSampleCount = 8; - - long sum = 0, sumSq = 0; - for (var i = 0; i < 8; i++) - { - sum += ns._rttHistory[i]; - sumSq += ns._rttHistory[i] * ns._rttHistory[i]; - } - ns._rttVariance = sumSq / 8 - (sum / 8) * (sum / 8); - if (ns._rttVariance < 0) - { - ns._rttVariance = 0; - } - - Assert.True(ns.HasStableConnection, "Should be stable for this test"); - - // Simulate speed hack: 50% faster movement - SimulateMovements(ns, - (67, 100), (67, 100), (67, 100), (67, 100), (67, 100), - (67, 100), (67, 100), (67, 100), (67, 100), (67, 100), - (67, 100), (67, 100), (67, 100), (67, 100), (67, 100) - ); - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - - Assert.True(rate > 1.4f, $"Expected rate > 1.4 (50% speed hack), got {rate}"); - Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict); - Assert.True(confidence > 0.5f, $"Expected high confidence, got {confidence}"); - } - - /// - /// Scenario: Borderline speed hack (7% faster) with unstable connection. - /// Should give benefit of doubt (not Definite). - /// - [Fact] - public void BorderlineSpeed_UnstableConnection_BenefitOfDoubt() - { - var ns = PacketTestUtilities.CreateTestNetState(); - - // Set up unstable connection - ns._rttHistory = new long[] { 50, 300, 80, 250, 100, 400, 60, 350 }; - ns._rttSampleCount = 8; - - long sum = 0, sumSq = 0; - for (var i = 0; i < 8; i++) - { - sum += ns._rttHistory[i]; - sumSq += ns._rttHistory[i] * ns._rttHistory[i]; - } - ns._rttVariance = sumSq / 8 - (sum / 8) * (sum / 8); - - Assert.False(ns.HasStableConnection, "Should be unstable due to high variance"); - - // Simulate borderline speed (7% faster) - SimulateMovements(ns, - (93, 100), (93, 100), (93, 100), (93, 100), (93, 100), - (93, 100), (93, 100), (93, 100), (93, 100), (93, 100) - ); - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - - Assert.True(rate > 1.05f && rate < 1.10f, $"Expected rate ~1.07, got {rate}"); - - // Unstable connection + borderline rate = should NOT be Definite - Assert.True( - verdict != MovementThrottle.DetectionVerdict.Definite, - $"Borderline case with unstable connection should not be Definite. Verdict={verdict}, Confidence={confidence}" - ); - } - /// /// Scenario: Verifies that burst forgiveness triggers correctly and clears gap duration. /// @@ -383,48 +135,4 @@ public class MovementThrottleLatencyTests $"Should not be Definite after burst forgiveness. Verdict={verdict}" ); } - - /// - /// Scenario: Walking vs running speed differences. - /// Walking (200ms interval) vs mounted running (100ms interval) should both - /// calculate correct rates. - /// - [Fact] - public void WalkingVsRunning_CorrectRateCalculation() - { - // Test walking (200ms expected interval) - var nsWalking = PacketTestUtilities.CreateTestNetState(); - SimulateMovements(nsWalking, - (200, 200), (200, 200), (200, 200), (200, 200), (200, 200), - (200, 200), (200, 200), (200, 200), (200, 200), (200, 200) - ); - - var walkingRate = MovementThrottle.CalculateMovementRate(nsWalking, out var walkingSamples); - Assert.True(walkingSamples >= 8, "Should have enough walking samples"); - Assert.True(walkingRate >= 0.95f && walkingRate <= 1.05f, - $"Walking rate should be ~1.0, got {walkingRate}"); - - // Test mounted running (100ms expected interval) - var nsRunning = PacketTestUtilities.CreateTestNetState(); - SimulateMovements(nsRunning, - (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), - (100, 100), (100, 100), (100, 100), (100, 100), (100, 100) - ); - - var runningRate = MovementThrottle.CalculateMovementRate(nsRunning, out var runningSamples); - Assert.True(runningSamples >= 8, "Should have enough running samples"); - Assert.True(runningRate >= 0.95f && runningRate <= 1.05f, - $"Running rate should be ~1.0, got {runningRate}"); - - // Test speed hack while walking - var nsWalkingHack = PacketTestUtilities.CreateTestNetState(); - SimulateMovements(nsWalkingHack, - (100, 200), (100, 200), (100, 200), (100, 200), (100, 200), - (100, 200), (100, 200), (100, 200), (100, 200), (100, 200) - ); - - var walkingHackRate = MovementThrottle.CalculateMovementRate(nsWalkingHack, out _); - Assert.True(walkingHackRate >= 1.9f && walkingHackRate <= 2.1f, - $"Walking at running speed should be rate ~2.0, got {walkingHackRate}"); - } } diff --git a/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs b/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs index eca2ee0bf..808447b56 100644 --- a/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs +++ b/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs @@ -212,30 +212,6 @@ public class MovementThrottleTests Assert.True(rate > 1.25f, $"Expected rate > 1.25, got {rate}"); } - [Fact] - public void AnalyzeMovement_ModerateSpeedHack_ReturnsPossibleOrLikely() - { - // 8% faster than expected - moderate, should be Possible or Likely - var ns = CreateNetStateWithHistory( - (93, 100), (93, 100), (93, 100), (93, 100), (93, 100), - (93, 100), (93, 100), (93, 100), (93, 100), (93, 100) - ); - - // Set up stable, low-latency RTT (required for Likely verdict path) - ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50]; - ns._rttSampleCount = 8; - ns._rttVariance = 4; // Low variance - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); - - Assert.True( - verdict == MovementThrottle.DetectionVerdict.Possible || - verdict == MovementThrottle.DetectionVerdict.Likely, - $"Expected Possible or Likely, got {verdict}. Rate={rate}, Confidence={confidence}" - ); - Assert.True(rate > 1.05f && rate < 1.15f, $"Expected rate ~1.07, got {rate}"); - } - [Fact] public void AnalyzeMovement_BurstAfterLargeGap_ReducesConfidence() { @@ -279,25 +255,6 @@ public class MovementThrottleTests Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict); } - [Fact] - public void AnalyzeMovement_StableConnectionLowLatency_IncreasesConfidence() - { - var ns = CreateNetStateWithHistory( - (95, 100), (95, 100), (95, 100), (95, 100), (95, 100), - (95, 100), (95, 100), (95, 100), (95, 100), (95, 100) - ); - - // Set up stable, low-latency RTT - ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50]; - ns._rttSampleCount = 8; - ns._rttVariance = 4; // Low variance - - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); - - // With stable connection, even small anomalies are more suspicious - Assert.True(confidence > 0.2f, $"Expected higher confidence for stable connection, got {confidence}"); - } - [Fact] public void AnalyzeMovement_UnstableConnection_ReducesConfidence() { diff --git a/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs b/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs index eba603e28..6f5a22d04 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs @@ -390,38 +390,6 @@ public class NetStateSendBufferTests } } - [Fact] - public void Send_WithPartialRemainder_GrowsAndCopiesFullPayload() - { - var ns = CreateAuthenticatedNetState(out var client); - var baseSize = ns._socket.SendBuffer.PhysicalSize; - - try - { - var first = Pattern(baseSize / 2, 200); - var second = Pattern(baseSize / 2 + 1, 201); - var expected = new byte[first.Length + second.Length]; - first.CopyTo(expected, 0); - second.CopyTo(expected, first.Length); - - // Remainder too small for the second send - ns.Send(first); - ns.Send(second); - - Assert.True(ns.Running); - Assert.Equal(string.Empty, ns._disconnectReason); - Assert.True(ns._socket.SendBuffer.PhysicalSize > baseSize); - Assert.True(ns._sendBufferGrown); - - Assert.Equal(expected, ReadAll(client, expected.Length)); - } - finally - { - ns.Dispose(); - client.Close(); - } - } - [Fact] public void Send_WithSendInFlight_GrowsAndStreamStaysIntact() { @@ -628,58 +596,4 @@ public class NetStateSendBufferTests // growth off; budget untouched Assert.Equal(1L, NetState.CoerceSendBufferGrowthBudget(1, sendBufferSize, sendBufferSize)); } - - [Fact] - public void CoerceMaxBufferSlabs_ClampsToOneSlabPerConnection() - { - var maxConnections = NetState.SocketManager.MaxSockets; - - // fewer than one slab is meaningless - Assert.Equal(1, NetState.CoerceMaxBufferSlabs(0)); - Assert.Equal(1, NetState.CoerceMaxBufferSlabs(-4)); - - // more slabs than connections cannot make a slab any smaller - Assert.Equal(maxConnections, NetState.CoerceMaxBufferSlabs(maxConnections * 2)); - - Assert.Equal(128, NetState.CoerceMaxBufferSlabs(128)); - } - - [Fact] - public void CoerceInitialBufferSlabs_ClampsToTheSlabCount() - { - var slabs = NetState.BasePoolSlabCount(128); - Assert.True(slabs > 1); - - Assert.Equal(1, NetState.CoerceInitialBufferSlabs(0, slabs)); - Assert.Equal(1, NetState.CoerceInitialBufferSlabs(-1, slabs)); - - // a pool never holds more slabs than it has - Assert.Equal(slabs, NetState.CoerceInitialBufferSlabs(slabs + 1, slabs)); - - Assert.Equal(4, NetState.CoerceInitialBufferSlabs(4, slabs)); - } - - [Fact] - public void Ring_RegisteredBufferTable_MatchesTheConfiguredSlabCount() - { - var manager = NetState.SocketManager; - var maxBufferSlabs = NetState.CoerceMaxBufferSlabs(ServerConfiguration.GetSetting("network.maxBufferSlabs", NetState.DefaultMaxBufferSlabs)); - - // A table smaller than this throws at manager construction. Connections start on the - // transport minimum until the game server promotes them, so the formula must count those too. - // Mirrors what production passes: the request, not the effective size the manager may coerce - // down to (RequiredRegisteredBuffers treats a non-zero request as a pool either way). - Assert.Equal( - RingSocketManager.RequiredRegisteredBuffers( - manager.MaxSockets, - NetState.SendBufferSize, - manager.MaxSendBufferSize, - manager.SendBufferGrowthBudget, - maxBufferSlabs, - IORingBuffer.MinimumSize, - IORingBuffer.MinimumSize - ), - NetState.Ring.MaxRegisteredBuffers - ); - } } diff --git a/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs index c1bc6a800..549fa8857 100644 --- a/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs @@ -42,20 +42,6 @@ public class NetworkCompressionBoundsTests } } - [Fact] - public void StillCompressesWhenOutputIsLargeEnough() - { - var input = new byte[64]; - Array.Fill(input, (byte)'A'); - - var output = new byte[256]; - - var written = NetworkCompression.Compress(input, output); - - Assert.True(written > 0); - Assert.True(written <= output.Length); - } - [Fact] public void ReportsFailureRatherThanOverrunningATightOutput() { diff --git a/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs deleted file mode 100644 index 5a64a61fe..000000000 --- a/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Net.Sockets; -using Server.Network; -using Xunit; - -namespace Server.Tests.Network; - -[Collection("Sequential Server Tests")] -public class NetworkStatsTests -{ - private static byte[] Pattern(int length, int seed) - { - var data = new byte[length]; - new System.Random(seed).NextBytes(data); - return data; - } - - [Fact] - public void MaintainSendBuffers_RecordsTheRefusalsItResets() - { - var previous = NetState._availableMemoryBytes; - NetState ns = null; - Socket client = null; - - try - { - ns = PacketTestUtilities.CreateTestNetState(out client); - ns._protocolState = NetState.ProtocolState.GameServer_AwaitingGameServerLogin; - ns.Account = new MockAccount(); - var baseSize = ns._socket.SendBuffer.PhysicalSize; - - NetState.MaintainSendBuffers(); // drain whatever earlier tests refused - NetState._availableMemoryBytes = 1; // any working set is above 80% of one byte - - for (var i = 0; i < 6; i++) - { - ns.Send(Pattern(baseSize / 4, i)); - } - - // The sweep refreshes _availableMemoryBytes, so refusals must happen before it - NetState.MaintainSendBuffers(); - var stats = NetState.GetNetworkStats(); - - Assert.True(stats.LastSweep.Ran); - Assert.True(stats.LastSweep.CeilingRefusals >= 1); - - // Counters are per sweep, not cumulative - NetState.MaintainSendBuffers(); - Assert.Equal(0, NetState.GetNetworkStats().LastSweep.CeilingRefusals); - } - finally - { - NetState._availableMemoryBytes = previous; - ns?.Dispose(); - client?.Close(); - } - } - - [Fact] - public void GetNetworkStats_CountsAuthenticatedConnectionsSeparately() - { - var ns = PacketTestUtilities.CreateTestNetState(out var client); - - try - { - var before = NetState.GetNetworkStats(); - ns._protocolState = NetState.ProtocolState.GameServer_AwaitingGameServerLogin; - ns.Account = new MockAccount(); - var after = NetState.GetNetworkStats(); - - Assert.Equal(before.Connected, after.Connected); - Assert.Equal(before.Authenticated + 1, after.Authenticated); - Assert.True(after.Connected >= after.Authenticated); - } - finally - { - ns.Dispose(); - client.Close(); - } - } -} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs index a30e9c9e3..a09454ba6 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs @@ -25,18 +25,6 @@ public class ObjectPropertyListReentrancyTests return value; } - [Fact] - public void InterpolatedAdd_ResetMidHole_DoesNotThrow() - { - var opl = new ObjectPropertyList(null); - - var ex = Record.Exception( - () => opl.Add(1060776, $"{ResettingHole(opl, "Knight")}\t{"Council of Mages"}") - ); - - Assert.Null(ex); - } - [Fact] public void InterpolatedAdd_DisposeMidHole_DoesNotThrow() { @@ -57,19 +45,6 @@ public class ObjectPropertyListReentrancyTests [Collection("Sequential Server Tests")] public class ObjectPropertyListNestedBuildTests { - [Fact] - public void NestedBuild_DoesNotUnguardTheOuterList() - { - var outer = new ObjectPropertyList(null); - var inner = new ObjectPropertyList(null); - - outer.IsBuilding = true; - inner.IsBuilding = true; // another entity starts building, and finishes - inner.IsBuilding = false; - - Assert.True(outer.IsBuilding); - } - [Fact] public void Reset_MidInterpolation_LeavesTheListUsable() { diff --git a/Projects/Server.Tests/Tests/Serialization/DecimalSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/DecimalSerializationTests.cs deleted file mode 100644 index 5a25189d6..000000000 --- a/Projects/Server.Tests/Tests/Serialization/DecimalSerializationTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Buffers.Binary; -using System.Runtime.InteropServices; -using Xunit; - -namespace Server.Tests; - -public class DecimalSerializationTests -{ - private static void Write(byte[] buffer, decimal value) - { - Span bytes = stackalloc int[sizeof(decimal) / 4]; - decimal.GetBits(value, bytes); - - MemoryMarshal.Cast(bytes).CopyTo(buffer.AsSpan()); - } - - private static int ReadInt(ReadOnlySpan buffer) => BinaryPrimitives.ReadInt32LittleEndian(buffer); - - private static decimal ReadDecimal(ReadOnlySpan buffer) => new(stackalloc int[4] { ReadInt(buffer), ReadInt(buffer[4..]), ReadInt(buffer[8..]), ReadInt(buffer[12..]) }); - - public static TheoryData Data => - new() - { - 123.46m, - 0.0256m, - 10000m, - 2m, - 0.0001m, - 0.0000000000000000000000000001m - }; - - [Theory] - [MemberData(nameof(Data))] - public void TestSerializeDecimal(decimal value) - { - // Arrange - var buffer = new byte[sizeof(decimal)]; - - // Act - Write(buffer, value); - - // Assert - Assert.Equal(value, ReadDecimal(buffer)); - } -} diff --git a/Projects/Server.Tests/Tests/Serialization/EnumConversionTests.cs b/Projects/Server.Tests/Tests/Serialization/EnumConversionTests.cs deleted file mode 100644 index 26a6833b6..000000000 --- a/Projects/Server.Tests/Tests/Serialization/EnumConversionTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using Xunit; - -namespace Server.Tests; - -public class EnumConversionTests -{ - [Fact] - public void TestToEnum() - { - var e = ReadEnum(); - - Assert.Equal(TileFlag.Container, e); - } - - private static unsafe T ReadEnum() where T : unmanaged, Enum - { - var num = (long)TileFlag.Container; - return *(T*)# - } -} diff --git a/Projects/Server.Tests/Tests/Utility/ActivatorExtensionsTests.cs b/Projects/Server.Tests/Tests/Utility/ActivatorExtensionsTests.cs index ed331c2ff..059f6fa55 100644 --- a/Projects/Server.Tests/Tests/Utility/ActivatorExtensionsTests.cs +++ b/Projects/Server.Tests/Tests/Utility/ActivatorExtensionsTests.cs @@ -54,15 +54,6 @@ namespace Server.Tests Assert.Equal("Test ModernUO", result.Name); } - [Fact] - public void TestTwoParamsOptionalActivator() - { - var result = typeof(TestTwoOptionalParamsClass).CreateInstance(10, "Prod ModernUO"); - Assert.IsType(result); - Assert.Equal(10, result.Amount); - Assert.Equal("Prod ModernUO", result.Name); - } - private class TestZeroParamsClass { } diff --git a/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs b/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs index e2172a191..edc65d211 100644 --- a/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs +++ b/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs @@ -35,54 +35,6 @@ public class HtmlEscapeTests Assert.Equal(expected, result); } - [Theory(DisplayName = "Multiple instances of same character")] - [InlineData("<><>", "<><>")] - [InlineData("&&&&", "&&&&")] - [InlineData("\"\"\"", """"")] - [InlineData("'''", "'''")] - [InlineData(">>>", ">>>")] - public void EscapeHtml_MultipleSpecialCharacters_EscapesAll(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "Mixed content")] - [InlineData("Hello ", "Hello <world>")] - [InlineData("
Hello
", "<div>Hello</div>")] - [InlineData("Tom & Jerry", "Tom & Jerry")] - [InlineData("He said \"hello\"", "He said "hello"")] - [InlineData("It's a test", "It's a test")] - public void EscapeHtml_MixedContent_EscapesMixedSpecialCharacters(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "Starting with special character")] - [InlineData("World", ">World")] - [InlineData("&Start", "&Start")] - [InlineData("\"Quote", ""Quote")] - [InlineData("'Apostrophe", "'Apostrophe")] - public void EscapeHtml_StartsWithSpecialCharacter_EscapesStart(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "Ending with special character")] - [InlineData("Hello<", "Hello<")] - [InlineData("World>", "World>")] - [InlineData("End&", "End&")] - [InlineData("Quote\"", "Quote"")] - [InlineData("Test'", "Test'")] - public void EscapeHtml_EndsWithSpecialCharacter_EscapesEnd(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - [Theory(DisplayName = "Complex mixed scenarios")] [InlineData("

Hello & goodbye

", "<p>Hello & goodbye</p>")] [InlineData("<already>", "&lt;already&gt;")] @@ -103,59 +55,6 @@ public class HtmlEscapeTests Assert.Empty(result); } - [Fact(DisplayName = "Empty string input")] - public void EscapeHtml_EmptyString_ReturnsEmpty() - { - var result = "".EscapeHtml(); - Assert.Empty(result); - } - - [Fact(DisplayName = "Only special characters")] - public void EscapeHtml_OnlySpecialCharacters_EscapesAll() - { - const string input = "<>&\"'"; - const string expected = "<>&"'"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Fact(DisplayName = "Ampersand must be escaped first")] - public void EscapeHtml_AmpersandFirst_PreventDoubleEscaping() - { - // This is critical: & must be escaped to & - // If we're not careful, we could double-escape already-escaped content - const string input = "<"; - const string expected = "&lt;"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "ReadOnlySpan overload - no special characters")] - [InlineData("Hello World")] - [InlineData("Plain text")] - public void EscapeHtml_ReadOnlySpan_NoSpecialCharacters_ReturnsUnchanged(string input) - { - var result = input.AsSpan().EscapeHtml(); - Assert.Equal(input, result); - } - - [Theory(DisplayName = "ReadOnlySpan overload - with special characters")] - [InlineData("
", "<div>")] - [InlineData("Tom & Jerry", "Tom & Jerry")] - public void EscapeHtml_ReadOnlySpan_WithSpecialCharacters_EscapesCorrectly(string input, string expected) - { - var result = input.AsSpan().EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "ReadOnlySpan overload - empty input")] - [InlineData("")] - public void EscapeHtml_ReadOnlySpan_Empty_ReturnsEmpty(string input) - { - var result = input.AsSpan().EscapeHtml(); - Assert.Empty(result); - } - [Fact(DisplayName = "Consecutive special characters")] public void EscapeHtml_ConsecutiveSpecialCharacters_EscapesAll() { @@ -165,51 +64,6 @@ public class HtmlEscapeTests Assert.Equal(expected, result); } - [Fact(DisplayName = "Special characters with single normal character between")] - public void EscapeHtml_SpecialCharactersWithGaps_EscapesAll() - { - const string input = "b&c\"d'e"; - const string expected = "<a>b&c"d'e"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Fact(DisplayName = "HTML tags")] - public void EscapeHtml_HtmlTags_EscapesTagBrackets() - { - var input = "Hello"; - var expected = "<html><body>Hello</body></html>"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Fact(DisplayName = "HTML attributes with mixed quotes")] - public void EscapeHtml_HtmlAttributesWithQuotes_EscapesCorrectly() - { - var input = ""; - var expected = "<a href="test" data='value'>"; - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Theory(DisplayName = "Whitespace handling")] - [InlineData(" spaces ", " spaces ")] - [InlineData("\ttabs\t", "\ttabs\t")] - [InlineData("\nnewlines\n", "\nnewlines\n")] - public void EscapeHtml_Whitespace_PreservedAsIs(string input, string expected) - { - var result = input.EscapeHtml(); - Assert.Equal(expected, result); - } - - [Fact(DisplayName = "Performance: long string without special characters")] - public void EscapeHtml_LongStringNoSpecialCharacters_ReturnsQuickly() - { - var input = new string('a', 10000); - var result = input.EscapeHtml(); - Assert.Equal(input, result); - } - [Fact(DisplayName = "Performance: long string with special characters")] public void EscapeHtml_LongStringWithSpecialCharacters_HandlesCorrectly() { @@ -229,15 +83,4 @@ public class HtmlEscapeTests var result = input.EscapeHtml(); Assert.Equal(expected, result); } - - [Fact(DisplayName = "String overload matches ReadOnlySpan overload")] - public void EscapeHtml_StringVsReadOnlySpan_ProduceSameResult() - { - const string input = "
Tom & Jerry 'in' \"quotes\"
"; - - var resultString = input.EscapeHtml(); - var resultSpan = input.AsSpan().EscapeHtml(); - - Assert.Equal(resultString, resultSpan); - } } diff --git a/Projects/Server.Tests/Tests/Utility/StringHelperTests.cs b/Projects/Server.Tests/Tests/Utility/StringHelperTests.cs index f428a060a..c7376cc4c 100644 --- a/Projects/Server.Tests/Tests/Utility/StringHelperTests.cs +++ b/Projects/Server.Tests/Tests/Utility/StringHelperTests.cs @@ -7,17 +7,6 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class TestStringHelpers { - [Theory] - [InlineData(null, "default value", "default value")] - [InlineData("", "default value", "default value")] - [InlineData("this is a valid string", "default value", "this is a valid string")] - public void TestIsNullOrDefault(string value, string defaultValue, string expected) - { - var actual = value.DefaultIfNullOrEmpty(defaultValue); - - Assert.Equal(expected, actual); - } - [Theory] [InlineData("this is not capitalized", "This Is Not Capitalized")] [InlineData("", "")] diff --git a/Projects/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/PasswordWorkerTests.cs b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs index 5972e5eb9..df499509e 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs @@ -152,18 +152,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 +205,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/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index a0ef97a56..dc16a7f5b 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -150,13 +150,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..80e664647 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -109,19 +109,4 @@ public class ChainedBindingSortTests : IDisposable } // An unchained binding never had the problem and must keep working untouched. - [Fact] - public void SortingOnAPlainBindingIsUnchanged() - { - var low = Teleporter(null); - low.Hue = 1; - - var high = Teleporter(null); - high.Hue = 2; - - var comparer = Sorter("Hue"); - - Assert.True(comparer.Compare(low, high) < 0); - Assert.True(comparer.Compare(high, low) > 0); - Assert.Equal(0, comparer.Compare(low, low)); - } } diff --git a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs index 997d8b79f..ef10033eb 100644 --- a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs @@ -79,25 +79,4 @@ public class EmitterRobustnessTests Assert.True(Check("Big", ComparisonOperator.Greater, "2147483647")); Assert.True(Check("Huge", ComparisonOperator.Greater, "9223372036854775807")); } - - [Fact] - public void EmittedAssembliesAreCollectible() - { - var prop = new Property("Tiny"); - prop.BindTo(typeof(Subject), PropertyAccess.Read); - - var compiled = ConditionalCompiler.Build( - typeof(Subject), - [TypeCondition.Default, new ComparisonCondition(prop, false, ComparisonOperator.Equal, "5")] - ).Compile(); - - var method = compiled.Method; - - Assert.True( - method.IsCollectible, - "The conditional compiler compiles one delegate per command invocation; code that is " - + $"not collectible ({method.GetType().Name} in {method.Module}) can never be unloaded, " - + "so every [global where would grow the process for good." - ); - } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs index b08a49007..bd3ef178e 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs @@ -253,26 +253,6 @@ public class RecurrencePatternTests } } - [Fact] - public void MonthlyOrdinalRecurrencePattern_DST_FallBack_HandlesAmbiguousTimeCorrectly() - { - // This tests specifically the DST fall back case that was failing - var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); - var pattern = new MonthlyOrdinalRecurrencePattern(OrdinalDayOccurrence.First, DayOfWeek.Sunday); - - // November 3, 2024 at 1:30 AM - First Sunday, falls on DST transition - var beforeDate = new DateTime(2024, 11, 2, 12, 0, 0); - var beforeUtc = beforeDate.LocalToUtc(tz); - var timeOnly = new TimeOnly(1, 30); - - // Act - var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz); - - // Assert - should be November 3, 2024 at 1:30 AM - var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz); - Assert.Equal(new DateTime(2024, 11, 3, 1, 30, 0), resultLocal); - } - [Fact] public void MonthlyRecurrence_MonthWithoutDay_SkipsToNextValidMonth() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs index 2416055b1..4a8fc0458 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs @@ -67,22 +67,6 @@ public class FactionRankTests Assert.True(top.Rank.Rank > bottom.Rank.Rank); } - [Fact] - public void ReadingRank_IsStableAndSideEffectFree() - { - var faction = NewFaction(); - var owner = new List(); - var state = AddMember(faction, owner); - - faction.ZeroRankOffset = 1; - state.RankIndex = 0; - - var first = state.Rank; - var second = state.Rank; - - Assert.Same(first, second); - } - [Fact] public void RankIndexOutOfSyncWithZeroRankOffset_StillResolvesARank() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs index 65ea66386..2a22b74bf 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -1,11 +1,9 @@ using System; -using System.Collections.Generic; using Server; using Server.Engines.Harvest; using Server.Items; using Server.Mobiles; using Server.Random; -using Server.Targeting; using Server.Tests; using Xunit; @@ -14,261 +12,9 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class FishingTests { - private const int Casts = 100_000; - - // On land in Britain, so a cast there never counts as deep water. - private static readonly Point3D _shallow = new(1434, 1699, 0); - // Open sea with no land within 30 tiles: deep water once the client's map is loaded. private static readonly Point3D _deep = new(1840, 1760, -5); - private static readonly Type[] _magicFish = - [typeof(PrizedFish), typeof(WondrousFish), typeof(TrulyRareFish), typeof(PeculiarFish)]; - - private static readonly Type[] _footwear = [typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)]; - - [Theory] - [InlineData(Expansion.None, 50.0, 0.0, 0.0)] - [InlineData(Expansion.T2A, 0.0, 0.0, 0.75)] - [InlineData(Expansion.T2A, 50.0, 0.0, 0.3877)] - [InlineData(Expansion.T2A, 100.0, 0.025, 0.0247)] - [InlineData(Expansion.UOTD, 80.0, 0.02, 0.1669)] - [InlineData(Expansion.LBR, 0.0, 0.0, 0.75)] - [InlineData(Expansion.LBR, 50.0, 0.025, 0.4519)] - [InlineData(Expansion.AOS, 100.0, 0.05, 0.095)] - [InlineData(Expansion.SA, 50.0, 0.025, 0.4519)] - [InlineData(Expansion.HS, 50.0, 0.0, 0.4635)] - [InlineData(Expansion.HS, 120.0, 0.06, 0.047)] - [InlineData(Expansion.EJ, 120.0, 0.06, 0.047)] - public void MutateType_MagicFishAndFootwear_FollowTheEra( - Expansion expansion, double skill, double magicFishRate, double footwearRate - ) - { - var previousExpansion = Core.Expansion; - var previousGenerator = BuiltInRng.Generator; - var fisher = new PlayerMobile(World.NewMobile); - - try - { - Core.Expansion = expansion; - BuiltInRng.Generator = new System.Random(1); - fisher.DefaultMobileInit(); - fisher.Skills.Fishing.Base = skill; - - Assert.Equal(skill, fisher.Skills.Fishing.Value); - Assert.False(SpecialFishingNet.FullValidation(Map.Felucca, _shallow.X, _shallow.Y)); - - var definition = Fishing.System.Definitions[0]; - var magicFish = 0; - var footwear = 0; - - for (var i = 0; i < Casts; i++) - { - var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _shallow, null); - - if (Array.IndexOf(_magicFish, type) >= 0) - { - magicFish++; - } - else if (Array.IndexOf(_footwear, type) >= 0) - { - footwear++; - } - } - - AssertRate(magicFishRate, magicFish); - AssertRate(footwearRate, footwear); - } - finally - { - fisher.Delete(); - BuiltInRng.Generator = previousGenerator; - Core.Expansion = previousExpansion; - } - } - - [Fact] - public void Leviathan_CarriesItsTreasureMapAQuarterOfTheTime() - { - var leviathan = new Leviathan(); - var serpent = new SeaSerpent(); - - try - { - Assert.Equal(0.25, leviathan.TreasureMapChance); - Assert.Equal(TreasureMap.LootChance, serpent.TreasureMapChance); - } - finally - { - leviathan.Delete(); - serpent.Delete(); - } - } - - [Theory] - [InlineData(Expansion.None, 0.0)] - [InlineData(Expansion.T2A, 100.0)] - [InlineData(Expansion.UOR, 50.0)] - [InlineData(Expansion.LBR, 0.0)] - [InlineData(Expansion.EJ, 120.0)] - public void MutateType_NeverComesBackEmpty(Expansion expansion, double skill) - { - var previousExpansion = Core.Expansion; - var previousGenerator = BuiltInRng.Generator; - var fisher = new PlayerMobile(World.NewMobile); - - try - { - Core.Expansion = expansion; - BuiltInRng.Generator = new System.Random(1); - fisher.DefaultMobileInit(); - fisher.Skills.Fishing.Base = skill; - - var definition = Fishing.System.Definitions[0]; - - for (var i = 0; i < Casts; i++) - { - var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _shallow, null); - Assert.NotNull(type); - } - } - finally - { - fisher.Delete(); - BuiltInRng.Generator = previousGenerator; - Core.Expansion = previousExpansion; - } - } - - [SkippableTheory] - [InlineData(Expansion.T2A, 100.0, 0.005, 0.004975)] - [InlineData(Expansion.UOTD, 100.0, 0.00495, 0.004925)] - [InlineData(Expansion.LBR, 95.0, 0.0062, 0.0)] - [InlineData(Expansion.LBR, 100.0, 0.00825, 0.01309)] - [InlineData(Expansion.HS, 120.0, 0.016335, 0.0257)] - [InlineData(Expansion.EJ, 120.0, 0.016335, 0.0257)] - public void MutateType_DeepWaterMapsAndBottles_FollowTheEra( - Expansion expansion, double skill, double mapRate, double bottleRate - ) - { - TileDataRequirement.SkipIfMissing(); - Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); - - var previousExpansion = Core.Expansion; - var previousGenerator = BuiltInRng.Generator; - var fisher = new PlayerMobile(World.NewMobile); - - try - { - Core.Expansion = expansion; - BuiltInRng.Generator = new System.Random(1); - fisher.DefaultMobileInit(); - fisher.Skills.Fishing.Base = skill; - - var definition = Fishing.System.Definitions[0]; - var maps = 0; - var bottles = 0; - - for (var i = 0; i < Casts; i++) - { - var type = Fishing.System.MutateType(typeof(Fish), fisher, null, definition, Map.Felucca, _deep, null); - - if (type == typeof(TreasureMap)) - { - maps++; - } - else if (type == typeof(MessageInABottle)) - { - bottles++; - } - } - - AssertRate(mapRate, maps); - AssertRate(bottleRate, bottles); - } - finally - { - fisher.Delete(); - BuiltInRng.Generator = previousGenerator; - Core.Expansion = previousExpansion; - } - } - - [SkippableTheory] - [InlineData(Expansion.T2A, true, 0.0, 100.0)] - [InlineData(Expansion.UOR, false, 0.0, 100.0)] - [InlineData(Expansion.UOR, true, 65.0, 115.0)] - [InlineData(Expansion.EJ, true, 65.0, 115.0)] - public void MutateResource_DeepWaterIsHarderFromUOR(Expansion expansion, bool deep, double minSkill, double maxSkill) - { - if (deep) - { - TileDataRequirement.SkipIfMissing(); - Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); - } - - var previousExpansion = Core.Expansion; - var fisher = new PlayerMobile(World.NewMobile); - - try - { - Core.Expansion = expansion; - fisher.DefaultMobileInit(); - - var definition = Fishing.System.Definitions[0]; - var vein = definition.Veins[0]; - var resource = Fishing.System.MutateResource( - fisher, null, definition, Map.Felucca, deep ? _deep : _shallow, vein, vein.PrimaryResource, null - ); - - Assert.Equal(minSkill, resource.MinSkill); - Assert.Equal(maxSkill, resource.MaxSkill); - } - finally - { - fisher.Delete(); - Core.Expansion = previousExpansion; - } - } - - [Theory] - [InlineData(Expansion.T2A, 0.0)] - [InlineData(Expansion.UOTD, 0.0)] - [InlineData(Expansion.LBR, 0.1)] - [InlineData(Expansion.EJ, 0.1)] - public void SpecialFishingNet_IsColoredOneTimeInTenFromLBR(Expansion expansion, double coloredRate) - { - var previousExpansion = Core.Expansion; - var previousGenerator = BuiltInRng.Generator; - - try - { - Core.Expansion = expansion; - BuiltInRng.Generator = new System.Random(1); - - var colored = 0; - - for (var i = 0; i < Casts; i++) - { - var net = new SpecialFishingNet(); - - if (net.Hue != 0x8A0) - { - colored++; - } - - net.Delete(); - } - - AssertRate(coloredRate, colored); - } - finally - { - BuiltInRng.Generator = previousGenerator; - Core.Expansion = previousExpansion; - } - } - [SkippableTheory] [InlineData(1840, 1760, true)] // land 115 tiles away [InlineData(1660, 1760, true)] // land 14 tiles away @@ -284,72 +30,6 @@ public class FishingTests Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, x, y)); } - [SkippableFact] - public void SOS_FindLocation_PicksDeepWater() - { - TileDataRequirement.SkipIfMissing(); - - var previousGenerator = BuiltInRng.Generator; - - try - { - BuiltInRng.Generator = new System.Random(1); - - for (var i = 0; i < 100; i++) - { - var location = SOS.FindLocation(Map.Felucca); - - Assert.NotEqual(Point3D.Zero, location); - Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, location.X, location.Y)); - } - } - finally - { - BuiltInRng.Generator = previousGenerator; - } - } - - [Fact] - public void Give_SurfacesASerpentThatAttacksAndIsADeepSeaSerpentOneTimeInTen() - { - const int serpents = 2_000; - - var previousGenerator = BuiltInRng.Generator; - var fisher = new PlayerMobile(); - - try - { - BuiltInRng.Generator = new System.Random(1); - fisher.MoveToWorld(_deep, Map.Felucca); - - var deepSeaSerpents = 0; - - for (var i = 0; i < serpents; i++) - { - var bottle = new MessageInABottle(Map.Felucca); - - Assert.True(Fishing.System.Give(fisher, bottle, false)); - - var serpent = Assert.IsAssignableFrom(bottle.RootParent); - Assert.Same(fisher, serpent.Combatant); - - if (serpent is DeepSeaSerpent) - { - deepSeaSerpents++; - } - - serpent.Delete(); - } - - AssertRate(0.1, deepSeaSerpents, serpents); - } - finally - { - fisher.Delete(); - BuiltInRng.Generator = previousGenerator; - } - } - [Theory] [InlineData(null, false)] [InlineData(2.5, false)] @@ -403,106 +83,39 @@ public class FishingTests } } - [SkippableTheory] - [InlineData(Expansion.None, 100.0, true, 0.0)] - [InlineData(Expansion.T2A, 79.9, true, 0.0)] - [InlineData(Expansion.T2A, 80.0, true, 0.01)] - [InlineData(Expansion.T2A, 100.0, false, 0.0)] - [InlineData(Expansion.EJ, 120.0, true, 0.01)] - public void OnHarvestFinished_SurfacesAnEmptySerpentOneCastInAHundred( - Expansion expansion, double skill, bool deep, double serpentRate - ) + [Fact] + public void Construct_SOSChestCatchConsumesTheSOS() { - TileDataRequirement.SkipIfMissing(); - Assert.True(SpecialFishingNet.FullValidation(Map.Felucca, _deep.X, _deep.Y)); - var previousExpansion = Core.Expansion; var previousGenerator = BuiltInRng.Generator; var fisher = new PlayerMobile(); try { - Core.Expansion = expansion; + Core.Expansion = Expansion.UOR; BuiltInRng.Generator = new System.Random(1); - fisher.Skills.Fishing.Base = skill; - fisher.MoveToWorld(_deep, Map.Felucca); - - var definition = Fishing.System.Definitions[0]; - var target = new LandTarget(deep ? _deep : _shallow, Map.Felucca); - var surfaced = new List(); - var serpents = 0; - - for (var i = 0; i < Casts; i++) - { - Fishing.System.OnHarvestFinished(fisher, null, definition, null, null, null, target); - - foreach (var serpent in Map.Felucca.GetMobilesInRange(fisher.Location, 10)) - { - surfaced.Add(serpent); - } - - serpents += surfaced.Count; - - foreach (var serpent in surfaced) - { - serpent.Delete(); - } - - surfaced.Clear(); - } - - AssertRate(serpentRate, serpents); - } - finally - { - fisher.Delete(); - BuiltInRng.Generator = previousGenerator; - Core.Expansion = previousExpansion; - } - } - - [Theory] - [InlineData(Expansion.T2A, 0.0)] - [InlineData(Expansion.UOR, 65.0)] - [InlineData(Expansion.EJ, 120.0)] - public void Construct_BringsUpTheSOSChestOneCatchInFour(Expansion expansion, double skill) - { - const int casts = 2_000; - - var previousExpansion = Core.Expansion; - var previousGenerator = BuiltInRng.Generator; - var fisher = new PlayerMobile(); - - try - { - Core.Expansion = expansion; - BuiltInRng.Generator = new System.Random(1); - fisher.Skills.Fishing.Base = skill; + fisher.Skills.Fishing.Base = 65.0; fisher.AddItem(new Backpack()); fisher.MoveToWorld(_deep, Map.Felucca); var sos = AddSOS(fisher); - var chests = 0; - for (var i = 0; i < casts; i++) + for (var i = 0; i < 200; i++) { var item = Fishing.System.Construct(typeof(Fish), fisher); + var isChest = item is WoodenChest or MetalGoldenChest; + item.Delete(); - if (item is WoodenChest or MetalGoldenChest) + if (isChest) { - chests++; Assert.True(sos.Deleted); - sos = AddSOS(fisher); - } - else - { - Assert.True(Assert.IsAssignableFrom(item).IsShipwreckedItem); + return; } - item.Delete(); + Assert.False(sos.Deleted); } - AssertRate(0.25, chests, casts); + Assert.Fail("No SOS chest surfaced in 200 casts"); } finally { @@ -512,283 +125,6 @@ public class FishingTests } } - [Theory] - [InlineData(Expansion.T2A, 0.0, true)] - [InlineData(Expansion.UOR, 64.9, false)] - [InlineData(Expansion.UOR, 65.0, true)] - [InlineData(Expansion.EJ, 64.9, false)] - public void SOS_Needs65SkillFromUOR(Expansion expansion, double skill, bool recovers) - { - var previousExpansion = Core.Expansion; - var definition = Fishing.System.Definitions[0]; - var fisher = new PlayerMobile(); - Item item = null; - - try - { - Core.Expansion = expansion; - fisher.Skills.Fishing.Base = skill; - fisher.AddItem(new Backpack()); - fisher.MoveToWorld(_deep, Map.Felucca); - AddSOS(fisher); - - // With no fish left, only an SOS that works lets the cast go on. - var bank = definition.GetBank(Map.Felucca, _deep.X, _deep.Y); - bank.Consume(bank.Current, fisher); - Assert.Equal(recovers, Fishing.System.CheckResources(fisher, null, definition, Map.Felucca, _deep, false)); - - item = Fishing.System.Construct(typeof(Fish), fisher); - Assert.Equal(recovers, item is not Fish); - } - finally - { - item?.Delete(); - fisher.Delete(); - definition.Banks.GetValueOrDefault(Map.Felucca) - ?.Remove(new Point2D(_deep.X / definition.BankWidth, _deep.Y / definition.BankHeight)); - Core.Expansion = previousExpansion; - } - } - - [Fact] - public void Construct_KeepsTheWhitePearlAtAnSOSSpot() - { - var previousExpansion = Core.Expansion; - var fisher = new PlayerMobile(); - - try - { - Core.Expansion = Expansion.ML; - fisher.Skills.Fishing.Base = 100.0; - fisher.AddItem(new Backpack()); - fisher.MoveToWorld(_deep, Map.Felucca); - - var sos = AddSOS(fisher); - - for (var i = 0; i < 100; i++) - { - var item = Fishing.System.Construct(typeof(WhitePearl), fisher); - Assert.IsType(item); - item.Delete(); - } - - Assert.False(sos.Deleted); - } - finally - { - fisher.Delete(); - Core.Expansion = previousExpansion; - } - } - - [Theory] - [InlineData(Expansion.None, false)] - [InlineData(Expansion.T2A, true)] - [InlineData(Expansion.AOS, true)] - [InlineData(Expansion.ML, true)] - public void OnHarvestStartedAndFinished_RevealAHiddenFishermanFromT2A(Expansion expansion, bool reveals) - { - Core._tickCount = 0; - Timer.Init(0); - - var previousExpansion = Core.Expansion; - var previousNow = Core._now; - var fisher = new PlayerMobile(); - - try - { - Core.Expansion = expansion; - fisher.MoveToWorld(_deep, Map.Felucca); - - var definition = Fishing.System.Definitions[0]; - var target = new LandTarget(_deep, Map.Felucca); - - fisher.Hidden = true; - Fishing.System.OnHarvestStarted(fisher, null, definition, target); - Advance(TimeSpan.FromSeconds(2.0)); - Assert.Equal(!reveals, fisher.Hidden); - - fisher.Hidden = true; - Fishing.System.OnHarvestFinished(fisher, null, definition, null, null, null, target); - Assert.Equal(!reveals, fisher.Hidden); - } - finally - { - fisher.Delete(); - Core._now = previousNow; - Core.Expansion = previousExpansion; - } - } - - [Theory] - [InlineData(Expansion.UOR, true)] - [InlineData(Expansion.AOS, true)] - [InlineData(Expansion.SE, false)] - public void BigFish_WeighsAHundredStonesBeforeSE(Expansion expansion, bool weighsAHundred) - { - var previousExpansion = Core.Expansion; - var previousGenerator = BuiltInRng.Generator; - var weights = new HashSet(); - - try - { - Core.Expansion = expansion; - BuiltInRng.Generator = new System.Random(1); - - for (var i = 0; i < 100; i++) - { - var fish = new BigFish(); - weights.Add((int)fish.Weight); - fish.Delete(); - } - } - finally - { - BuiltInRng.Generator = previousGenerator; - Core.Expansion = previousExpansion; - } - - Assert.All(weights, weight => Assert.InRange(weight, 3, 200)); - Assert.Equal(weighsAHundred, weights.SetEquals([100])); - } - - [Theory] - [InlineData(Expansion.AOS, 100, 25)] - [InlineData(Expansion.AOS, 12, 4)] - [InlineData(Expansion.SE, 100, 20)] - [InlineData(Expansion.SE, 200, 40)] - [InlineData(Expansion.SE, 12, 4)] - public void BigFish_GivesASteakPerFiveStonesFromSE(Expansion expansion, int weight, int steaks) - { - var previousExpansion = Core.Expansion; - var fisher = new PlayerMobile(); - - try - { - Core.Expansion = expansion; - fisher.AddItem(new Backpack()); - - var fish = new BigFish { Weight = weight }; - fisher.Backpack.DropItem(fish); - fish.Carve(fisher, null); - - Assert.True(fish.Deleted); - Assert.Equal(steaks, fisher.Backpack.FindItemByType()?.Amount); - } - finally - { - fisher.Delete(); - Core.Expansion = previousExpansion; - } - } - - [Theory] - [InlineData(Expansion.AOS, 100.0, 0.0)] - [InlineData(Expansion.SE, 99.9, 0.0)] - [InlineData(Expansion.SE, 100.0, 0.25)] - public void Leviathan_GivesAGMNetThrowerAnArtifactOneTimeInFourFromSE(Expansion expansion, double skill, double rate) - { - const int leviathans = 400; - - var previousExpansion = Core.Expansion; - var previousGenerator = BuiltInRng.Generator; - var fisher = new PlayerMobile(); - - try - { - Core.Expansion = expansion; - BuiltInRng.Generator = new System.Random(1); - fisher.Skills.Fishing.Base = skill; - fisher.AddItem(new Backpack()); - fisher.MoveToWorld(_deep, Map.Felucca); - - var pack = fisher.Backpack; - var artifacts = 0; - - // Nobody damages it, so only the net thrower's own roll can give an artifact. - for (var i = 0; i < leviathans; i++) - { - var leviathan = new Leviathan(fisher); - leviathan.MoveToWorld(_deep, Map.Felucca); - leviathan.Kill(); - - artifacts += pack.Items.Count; - - for (var j = pack.Items.Count - 1; j >= 0; j--) - { - pack.Items[j].Delete(); - } - - leviathan.Corpse?.Delete(); - leviathan.Delete(); - } - - AssertRate(rate, artifacts, leviathans); - } - finally - { - fisher.Delete(); - BuiltInRng.Generator = previousGenerator; - Core.Expansion = previousExpansion; - } - } - - [Theory] - [InlineData(Expansion.AOS, 5, 60)] - [InlineData(Expansion.SE, 1, 12)] - [InlineData(Expansion.EJ, 1, 12)] - public void MagicFish_GiveAPointFor12SecondsFromSE(Expansion expansion, int bonus, int seconds) - { - Core._tickCount = 0; - Timer.Init(0); - - var previousExpansion = Core.Expansion; - var previousNow = Core._now; - var fisher = new PlayerMobile(); - BaseMagicFish[] fish = [new PrizedFish(), new WondrousFish(), new TrulyRareFish()]; - - try - { - Core.Expansion = expansion; - - foreach (var magicFish in fish) - { - Assert.True(magicFish.Apply(fisher)); - Assert.Equal(bonus, fisher.GetStatOffset(magicFish.Type)); - } - - Advance(TimeSpan.FromSeconds(seconds - 1)); - Assert.All(fish, magicFish => Assert.Equal(bonus, fisher.GetStatOffset(magicFish.Type))); - - Advance(TimeSpan.FromSeconds(1)); - Assert.All(fish, magicFish => Assert.Equal(0, fisher.GetStatOffset(magicFish.Type))); - } - finally - { - foreach (var magicFish in fish) - { - magicFish.Delete(); - } - - fisher.Delete(); - Core._now = previousNow; - Core.Expansion = previousExpansion; - } - } - - // Within five standard deviations of the expected rate; a rate of zero must never happen. - private static void AssertRate(double expected, int count) - { - var tolerance = 5 * Math.Sqrt(expected * (1 - expected) / Casts); - Assert.InRange((double)count / Casts, expected - tolerance, expected + tolerance); - } - - private static void AssertRate(double expected, int count, int trials) - { - var tolerance = 5 * Math.Sqrt(expected * (1 - expected) / trials); - Assert.InRange((double)count / trials, expected - tolerance, expected + tolerance); - } - private static void Advance(TimeSpan time) { Core._now += time; diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs index 694374757..cf63d4858 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs @@ -45,31 +45,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..5284df402 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs @@ -17,28 +17,6 @@ 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] public void RedesignReRegistered_RoutesNewFootprintToLivePath() { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs index 8aa0b8d19..e7e368c38 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs @@ -130,33 +130,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..38a6824ea 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs @@ -39,45 +39,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() { 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/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index b4257f112..9c410efc7 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -42,16 +42,6 @@ public class StepCacheLifecycleTests : IDisposable 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 ---- /// 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/ImportCleanupTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs index a9b7554fd..6b4bba2dd 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/Json/ImportCleanupTests.cs @@ -98,32 +98,6 @@ public class ImportCleanupTests } } - [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; 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/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..1acc29960 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() { @@ -341,54 +285,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 +310,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 +343,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 +400,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..96023870d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs @@ -67,18 +67,6 @@ public class SpawnerEntryOwnershipTests 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() { @@ -159,85 +147,4 @@ public class SpawnerEntryOwnershipTests } // Manual: remove the Skip, run, and read the numbers from the thrown assertion. - [Fact(Skip = "manual benchmark")] - public void Benchmark_SpawnPath_Manual() - { - const int iterations = 100_000; - const int warmup = 1_000; - - var report = new ValueStringBuilder(stackalloc char[512]); - report.Append('\n', 1); - - foreach (var entryCount in new[] { 1, 10, 50 }) - { - var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); - spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); - - // SpawnedMaxCount = 0 keeps every entry full, so Spawn() does selection only. - for (var i = 0; i < entryCount; i++) - { - spawner.AddEntry("Rabbit", 100, 0, false); - } - - for (var i = 0; i < warmup; i++) - { - spawner.Spawn(); - } - - var sw = Stopwatch.StartNew(); - for (var i = 0; i < iterations; i++) - { - spawner.Spawn(); - } - - sw.Stop(); - - var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations; - report.Append( - $"Spawn() entries={entryCount,2}: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n" - ); - - spawner.Delete(); - } - - { - var spawner = new Spawner(1000, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10)); - spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca); - - for (var i = 0; i < 10; i++) - { - spawner.AddEntry("Rabbit", 100, 1, false); - } - - spawner.Spawn(); - Assert.Single(spawner.Spawned); - var rabbit = spawner.Spawned.Keys.First(); - - for (var i = 0; i < warmup; i++) - { - spawner.Remove(rabbit); - } - - var sw = Stopwatch.StartNew(); - for (var i = 0; i < iterations; i++) - { - spawner.Remove(rabbit); - } - - sw.Stop(); - - var nsPerCall = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations; - report.Append( - $"Remove(ISpawnable) entries=10: {nsPerCall,8:F1} ns/call ({iterations} iterations, {sw.ElapsedMilliseconds} ms total)\n" - ); - - (rabbit as Mobile)?.Delete(); - spawner.Delete(); - } - - var summary = report.ToString(); - report.Dispose(); - - throw new Xunit.Sdk.XunitException(summary); - } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs deleted file mode 100644 index d3f48792a..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerFixtureCapture.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using Server.Engines.Spawners; -using Server.Tests; -using Xunit; - -namespace UOContent.Tests.Engines.Spawners; - -[Collection("Sequential UOContent Tests")] -public class SpawnerFixtureCapture -{ - // Freezes the BaseSpawner v12 save layout as test input; refuses to run against newer code. - [SkippableFact] - public void CaptureLegacyBlobs() - { - Skip.If(Environment.GetEnvironmentVariable("MODERNUO_CAPTURE_SPAWNER_FIXTURES") != "1"); - - var version = (int)typeof(BaseSpawner) - .GetField("SerializationVersion", BindingFlags.NonPublic | BindingFlags.Static)! - .GetRawConstantValue()!; - Assert.True(version == 12, $"Fixtures must be captured with BaseSpawner v12; current version is {version}."); - - var dir = Path.Combine(AppContext.BaseDirectory, "Tests", "Engines", "Spawners", "Fixtures"); - Directory.CreateDirectory(dir); - - var spawner = new Spawner(2, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, "Rabbit", "Bird") - { - Name = "FixtureSpawner" - }; - spawner.Entries[1].Properties = "Hue 33"; - File.WriteAllBytes(Path.Combine(dir, "spawner.v12-v1.bin"), SpawnerBlob.Write(spawner)); - - var proximity = new ProximitySpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, default, 5, "boo", true, "Rat", "Bird") - { - Name = "FixtureProximity" - }; - File.WriteAllBytes(Path.Combine(dir, "proximity.v12-v1-v0.bin"), SpawnerBlob.Write(proximity)); - - var region = new RegionSpawner(1, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), 0, "Orc", "Troll") - { - Name = "FixtureRegion" - }; - File.WriteAllBytes(Path.Combine(dir, "region.v12-v1-v0.bin"), SpawnerBlob.Write(region)); - - spawner.Delete(); - proximity.Delete(); - region.Delete(); - } -} diff --git a/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs b/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs index 0f4518289..629d7f16e 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/GridLayoutTests.cs @@ -103,45 +103,6 @@ public class GridLayoutTests Assert.Equal(800, sizes[1]); } - [Fact] - public void TestGridCell_Properties() - { - var cell = new GridCell(10, 20, 100, 50); - - Assert.Equal(10, cell.X); - Assert.Equal(20, cell.Y); - Assert.Equal(100, cell.Width); - Assert.Equal(50, cell.Height); - Assert.Equal(110, cell.Right); - Assert.Equal(70, cell.Bottom); - Assert.Equal(60, cell.CenterX); - Assert.Equal(45, cell.CenterY); - } - - [Fact] - public void TestGridCell_Inset() - { - var cell = new GridCell(10, 20, 100, 50); - var inset = cell.Inset(5); - - Assert.Equal(15, inset.X); - Assert.Equal(25, inset.Y); - Assert.Equal(90, inset.Width); - Assert.Equal(40, inset.Height); - } - - [Fact] - public void TestGridCell_Offset() - { - var cell = new GridCell(10, 20, 100, 50); - var offset = cell.Offset(5, 10); - - Assert.Equal(15, offset.X); - Assert.Equal(30, offset.Y); - Assert.Equal(100, offset.Width); - Assert.Equal(50, offset.Height); - } - [Fact] public void TestListViewLayout_Pagination() { diff --git a/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs b/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs index c4a944cd0..b8dff707b 100644 --- a/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Jewels/T2AJewelGemCraftTests.cs @@ -73,34 +73,6 @@ public class T2AJewelGemCraftTests } } - [Fact] - public void OnCraft_WithUnsetGemContext_LeavesPlainPiece() - { - var map = Map.Felucca; - var player = CreatePlayerMobile(map, new Point3D(4120, 500, 0)); - var ring = new GoldRing(); - - try - { - player.Backpack.AddItem(new IronIngot(10)); - - var system = GetOrInitTinkeringSystem(); - var context = system.GetContext(player); - context.PendingGemType = GemType.None; // no gem targeted - context.PendingGemCount = 0; - - ring.OnCraft(1, false, player, system, typeof(IronIngot), null, MakeRingRecipe(), 0); - - Assert.Equal(GemType.None, ring.GemType); - Assert.Equal(0, ring.GemCount); - } - finally - { - ring.Delete(); - player.Delete(); - } - } - [Fact] public void OnCraft_WhenGemsUnavailableAtCraftTime_CraftsPlainPiece() { diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/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/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/Misc/NameVerificationTests.cs b/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs index a9e4f5c27..60b450b0d 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")] @@ -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/FamiliarAITests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs index 7d39e2963..692ff8152 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs @@ -139,18 +139,6 @@ public class FamiliarAITests : IDisposable 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] public void Follows_WithoutBacktracking() { diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index 6765b07f9..785278507 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -50,48 +50,4 @@ public class GuardFollowTests 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; - } - } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs deleted file mode 100644 index 3ced177b5..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// A meer mage fights FightMode.Evil and its enraged creatures carry -1000 karma, so the karma rules alone -// make each an enemy of the other. -[Collection("Sequential UOContent Tests")] -public class MeerEnragedTests : IDisposable -{ - private readonly List _created = []; - - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } - - private T Track(T m) where T : Mobile - { - _created.Add(m); - return m; - } - - private static void Place(BaseCreature bc, int x) - { - var map = Map.Felucca; - bc.MoveToWorld(new Point3D(x, 1600, map.GetAverageZ(x, 1600)), map); // flat, clear line of sight both ways - bc.AIObject.AITimer.Stop(); - } - - private static bool Acquire(BaseCreature creature) - { - creature.NextReacquireTime = Core.TickCount - 1; - return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true); - } - - [Fact] - public void MeerAndItsEnragedCreature_AreNotEnemies() - { - var meer = Track(new MeerMage()); - var enraged = Track(new EnragedRabbit(meer)); - - Assert.False(enraged.IsEnemy(meer)); - Assert.False(meer.IsEnemy(enraged)); - } - - [Fact] - public void AnotherMeersEnragedCreature_StaysAnEnemy() - { - var meer = Track(new MeerMage()); - var other = Track(new MeerMage()); - var enraged = Track(new EnragedRabbit(other)); - - Assert.True(enraged.IsEnemy(meer)); - Assert.True(meer.IsEnemy(enraged)); - } - - [SkippableFact] - public void MeerAndItsEnragedCreature_NeverAcquireEachOther() - { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); - - var meer = Track(new MeerMage()); - var enraged = Track(new EnragedBlackBear(meer)); - Place(meer, 1600); - Place(enraged, 1601); - - Assert.False(Acquire(enraged)); - Assert.False(Acquire(meer)); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index 7d12ed744..2789cd735 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -265,21 +265,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 +360,6 @@ public class PetOrderTests : IDisposable Assert.Same(master, pet.ControlTarget); Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder); } - - [Fact] - public void Stop_WhileFollowing_CancelsToIdle_NonML() - { - var previous = Core.Expansion; - try - { - Core.Expansion = Expansion.SE; // pre-ML: Core.ML is false - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0)); - pet.ControlOrder = OrderType.Follow; - - pet.ControlOrder = OrderType.Stop; - - // Stop/idle resolution is era-independent. - Assert.Equal(OrderType.None, pet.ControlOrder); - Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder); - Assert.Equal(pet.Location, pet.Home); - } - finally - { - Core.Expansion = previous; - } - } // The parameterless ctor fully initializes a player; the Serial ctor leaves that to Deserialize. private PlayerMobile SpawnPlayer(Point3D loc) { @@ -991,19 +953,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 +970,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..1064fb317 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -99,29 +99,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; @@ -204,35 +181,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; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs index 28b311186..d80dbd5bc 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs @@ -36,21 +36,6 @@ public class SummonAcquireRulesTests : IDisposable return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true); } - [SkippableTheory] - [InlineData(true)] - [InlineData(false)] - public void PreAosFeluccaVortexTurnsOnItsCaster(bool bladeSpirits) - { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); - Core.Expansion = Expansion.UOR; - - var caster = Player(Map.Felucca, 1500, 100); - var summon = Summon(bladeSpirits ? new BladeSpirits() : new EnergyVortex(), caster, 1499); - - Assert.True(Acquire(summon)); - Assert.Same(caster, summon.FocusMob); - } - [SkippableFact] public void PreAosFeluccaVortexPrefersAnyoneOverItsCaster() { 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/LordOaksTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/LordOaksTests.cs deleted file mode 100644 index dae82a332..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/LordOaksTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles; - -// Lord Oaks' abilities and spell shield depend on his queen, so the queen must spawn from combat, -// never from a query such as GetMonsterAbilities or a damage calculation. -[Collection("Sequential UOContent Tests")] -public class LordOaksTests : IDisposable -{ - private readonly List _created = []; - - public void Dispose() - { - foreach (var m in World.Mobiles.Values.OfType().ToList()) - { - m.Delete(); - } - - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } - - private LordOaks NewOaks(Map map) - { - var oaks = new LordOaks(); - _created.Add(oaks); - oaks.MoveToWorld(new Point3D(1000, 1000, 0), map); - return oaks; - } - - private Mobile NewAttacker() - { - var attacker = new Mongbat(); - _created.Add(attacker); - return attacker; - } - - private static int SilvaniCount() => World.Mobiles.Values.Count(m => m is Silvani); - - [Fact] - public void QueryingOrDeletingAnUnengagedOaks_SpawnsNoQueen() - { - var oaks = NewOaks(Map.Felucca); - - var scalar = 1.0; - oaks.AlterDamageScalarFrom(null, ref scalar); - oaks.GetMonsterAbilities(); - oaks.Delete(); - - Assert.Equal(1.0, scalar); - Assert.Equal(0, SilvaniCount()); - } - - [Fact] - public void Aggression_SpawnsTheQueenOnce_AndShieldsOaksWhileSheLives() - { - var oaks = NewOaks(Map.Felucca); - var attacker = NewAttacker(); - - oaks.AggressiveAction(attacker, false); - oaks.AggressiveAction(attacker, false); - - var queen = World.Mobiles.Values.OfType().Single(); - - var shielded = 1.0; - oaks.AlterDamageScalarFrom(attacker, ref shielded); - Assert.Equal(0.1, shielded, 6); - Assert.True(oaks.CanUseAbility(MonsterAbilities.SummonPixiesCounter)); - - queen.Delete(); - - var exposed = 1.0; - oaks.AlterDamageScalarFrom(attacker, ref exposed); - oaks.AggressiveAction(attacker, false); - - Assert.Equal(1.0, exposed); - Assert.False(oaks.CanUseAbility(MonsterAbilities.SummonPixiesCounter)); - Assert.Equal(0, SilvaniCount()); - } - - [Fact] - public void AnOaksOnTheInternalMap_NeverSpawnsTheQueen() - { - var oaks = new LordOaks(); - _created.Add(oaks); - - oaks.AggressiveAction(NewAttacker(), false); - - Assert.Equal(0, SilvaniCount()); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs index b38364373..b3a6d291f 100644 --- a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs @@ -74,15 +74,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..1e1c0fedf 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs @@ -72,14 +72,4 @@ public class BlocklistFileTests 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/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/Firewall/FirewallTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs index fabf6d4ba..26e289f0c 100644 --- a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs @@ -76,14 +76,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/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..2fb0f73cb 100644 --- a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs @@ -255,17 +255,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] @@ -371,12 +360,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/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..5cf7dbeea 100644 --- a/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs +++ b/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs @@ -25,18 +25,6 @@ public class ArchiveJournalTests : IDisposable } } - [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 7103a65c5..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() { From 47f972ec9d27a8199fb6bf30114f0e862475ebd6 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:10:19 -0700 Subject: [PATCH 17/26] fix: ClearAllTimers stops every timer in a slot, not just the first (#2723) ## Summary `Timer.ClearAllTimers` walked each wheel slot with a `do/while` that read `node._nextTimer` but never advanced `node`. `Stop()` unlinks the head from its slot, so the second pass read a null successor from the same, now-detached node and exited. Only the first timer in every slot was stopped; the rest kept running. The loop now reads the successor, stops the node, and advances. Nothing in the repo calls `ClearAllTimers` today, so no running server is affected. It is public API for shards and tests. ## Tests - `TimerTests.ClearAllTimers_StopsEveryTimerInASlot` puts three timers in the same slot, clears them, asserts none is `Running`, and slices past their due time. It fails without the fix. - Server.Tests: all pass. --- .../Server.Tests/Tests/Timer/TimerTests.cs | 24 +++++++++++++++++++ Projects/Server/Timer/Timer.TimerWheel.cs | 11 +++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/Projects/Server.Tests/Tests/Timer/TimerTests.cs b/Projects/Server.Tests/Tests/Timer/TimerTests.cs index 9f610de50..e7a9b6241 100644 --- a/Projects/Server.Tests/Tests/Timer/TimerTests.cs +++ b/Projects/Server.Tests/Tests/Timer/TimerTests.cs @@ -85,6 +85,30 @@ public class TimerTests Assert.Equal(2, timerTicks.ExecutedCount); } + [Fact] + public void ClearAllTimers_StopsEveryTimerInASlot() + { + Timer.Init(0); + + var timers = new Timer[3]; + for (var i = 0; i < timers.Length; i++) + { + timers[i] = Timer.DelayCall(TimeSpan.FromMilliseconds(80), () => Assert.Fail("cleared timer fired")); + } + + Timer.ClearAllTimers(0); + + for (var i = 0; i < timers.Length; i++) + { + Assert.False(timers[i].Running); + } + + for (var tick = 8; tick <= 160; tick += 8) + { + Timer.Slice(tick); + } + } + private class TimerTicks { public long ExpectedTicks; diff --git a/Projects/Server/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; + } } } } From 79f736aa8308e142b3337491f306493389af8c81 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:10:39 -0700 Subject: [PATCH 18/26] fix: keep the fallback localization table registered when emptied (#2724) ## Summary `Localization.Remove` drops a language's dictionary from `_localizations` once its last entry is removed. For the fallback language (`enu`), `_fallbackEntries` still pointed at the dropped dictionary. A later `Add("enu", ...)` then created a new dictionary, and because `_fallbackEntries` was not null, `Add`'s `??=` left it on the old one. Every fallback lookup missed the new entries. The fallback table now stays registered when it empties. Later adds land in the dictionary lookups read, and removed entries stay removed. Nulling `_fallbackEntries` instead would have made the next lookup reload `cliloc.enu` from disk and bring back the removed entries. Nothing in production calls `Remove` today. It is public API for shards and tests; #2722 adds a test cleanup that removes entries. ## Tests - `LocalizationEntryTests.RemovingTheLastFallbackEntry_KeepsLaterAddsVisible` clears the tables, adds then removes one `enu` entry, adds another, and checks that the fallback lookup finds the new entry and not the removed one. It clears again afterwards so later tests reload the real tables. It fails without the fix. - Server.Tests: all pass. --- .../Localization/LocalizationEntryTests.cs | 23 +++++++++++++++++++ Projects/Server/Localization/Localization.cs | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs b/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs index 154cfe7a6..a240a0576 100644 --- a/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs +++ b/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs @@ -17,4 +17,27 @@ public class LocalizationEntryTests Assert.Equal("This tests clilocs as parameters.", numericFormatter); Assert.Equal("This tests clilocs as parameters.", stringParam); } + + [Fact] + public void RemovingTheLastFallbackEntry_KeepsLaterAddsVisible() + { + Localization.Clear(); + + try + { + Localization.Add("enu", 500004, "first"); + Assert.True(Localization.Remove("enu", 500004)); + + Localization.Add("enu", 500005, "second"); + + Assert.False(Localization.TryGetLocalization(500004, out _)); + Assert.True(Localization.TryGetLocalization(500005, out var entry)); + Assert.Equal("second", entry.Text); + } + finally + { + // Lets the next lookup reload the real tables from disk. + Localization.Clear(); + } + } } diff --git a/Projects/Server/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); } From 00134cac336922253c2df0c64a2605346938ef67 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:11:33 -0700 Subject: [PATCH 19/26] test: fix shared state, CI-vacuous tests and harness duplication (#2722) Follow-up to #2721. It fixes how the remaining tests behave and changes no production code. ## CI - Both `dotnet test` steps pass `--filter "Category!=Manual"`. Before this, Manual-tagged tests ran on CI anyway. - The one surviving movement-throttle latency test is the only coverage of burst forgiveness over a full, wrapped history. It loses its Manual tag, so it runs on CI. ## Tests that passed on CI without testing anything - **Corpse and throwing tests.** They relied on tiledata for the weapon `Layer`, so on CI the equip silently failed and they asserted against empty lists. They now set `Layer` explicitly and assert the equip took effect. `TestThrown` also pins its own strength and range values, so a shard retuning SoulGlaive no longer breaks them. - **Healthbar poison rows.** Server.Tests registers no poisons, so every row tested `null`. They now use real poison levels. - **Account packet tests.** The mock accounts reported `Length` 0, so the character-list tests only ever encoded empty slots. They now encode the real characters. - **Pathing and AI tests that only mean something on real Trammel/Felucca terrain** are now guarded with `TileDataRequirement.SkipIfMissing()`: Britain desk and clutter scenes, house footprints (no MultiData on CI) and fingerprints. Tests that are valid on flat blank terrain stay unguarded. (A synthetic tiledata fixture to run these on CI is a planned follow-up.) - **Assertions that couldn't fail now check their claim.** Affected tests include the `DefaultWalker` cache use, cache on/off parity requiring a path, the recorder capability flags, the idle-pet gate, the monthly DST recurrence, the AdvancedSearch `Record.Exception` wrappers, `CanFitItem` with no branch-guarded assert, the Firewall callback, and `Import_NoMap`. The Geometry Parse asserts also now use the same input as their TryParse. ## Shared state Every test that changes process-wide state now restores it, even when an assert fails: - **The clock.** A new `TimerWheelScope` saves and restores `Core._tickCount`, `Core._now` and the timer wheel. `SimulatedClockScope` covers decay tests. - **StepCache settings and caches**, via `StepCacheStateScope`. - **Network state.** The ManualAllowlist, LoginAllowlist, firewall and BanChannel reporters are restored. Global packet handlers are now registered per test and removed afterwards, so they no longer stay registered for later tests. - **Data and config tables.** `ArchiveJournal` statics, `TileData.ItemTable`, `ObjectPropertyList.Enabled`, `Core.Expansion` and the localization table are restored. One `MobileStatusVersion` restore was resetting a different expansion than the one the test changed. - **Entities.** Tests with fixed serials now delete what they create. Fixed mobile serials moved to `World.NewMobile`, because deleting a hard-coded serial could unregister a live entity. - **Collection race.** `SpiralScanTests` no longer clears the spawn cache from a separate collection that could run in parallel with the cache-manager tests. - **Shared statics.** Classes reading shared static allowlists joined the sequential collection. ## Duplication New shared helpers replace copy-pasted harness code: - **Server.Tests:** `PacketAssert.Sends`, used by about 130 packet tests; `TestClientHarness`, `PersistencePipelineScope`, `NetTestHelpers`, `EntityCleanup` and one `TestMulti`. - **UOContent.Tests:** `TimerWheelScope`, which also holds one wraparound-safe `RunFor`/`RunUntil`; `AiTestStubs`, which merges six walker copies and five identical creature stubs; `MobileTestHarness`, `CommandTestHarness` and `TempDirectory`. Dead oracle packet classes are removed. ## Cost | Test | Before | After | |---|---|---| | `VirtualSerialTests` | 1.57s | 2ms (starts near the wrap via reflection instead of drawing ~17.9M serials) | | Event scheduler recurrence loop | 473ms | 1ms | | CrowdSec drain | 517ms | 16ms (event-driven instead of a fixed 500ms wait) | | AuthId tests | 248ms | 99ms (SHA2 except the two GameLogin tests) | | Container cap tests | 5,000 items | 3,449 / 3,277 (one past each protocol cap) | | Bulk removal | 100k children | 20k children | | OPL hash distribution | 60k builds | 10k builds | ## Shard-tuning decoupling - AI assertions read `ReacquireDelay` and `FollowMoveSpeed` instead of hard-coding production tuning. - The guard friendly-fire test uses an `AI_Mage` stub instead of WhiteWyrm. - Argon2 assertions pin the `argon2id` variant and `v=19` but read the cost parameters from the configured hasher. ## Tests | Run | Server.Tests | UOContent.Tests | |---|---|---| | With client files | 812 pass | 1,123 pass | | Without client files (CI simulation) | 800 pass, 12 skip | 1,021 pass, 102 skip | - The UOContent fixture reads `MODERNUO_TEST_DATA_DIR`; Server.Tests reads `MODERNUO_CLIENT_PATH`. - The 15 new UOContent skips are the map-dependent guards above. - Total run time with client files is about 5s, down from about 8s. --- .github/workflows/build-test.yml | 4 +- .../Server.Tests/Helpers/EntityCleanup.cs | 33 ++ Projects/Server.Tests/Helpers/MockAccount.cs | 18 +- .../Server.Tests/Helpers/NetTestHelpers.cs | 152 ++++++++ Projects/Server.Tests/Helpers/PacketAssert.cs | 31 ++ .../Helpers/PersistencePipelineScope.cs | 130 +++++++ .../Helpers/SimulatedClockScope.cs | 42 ++ .../Server.Tests/Helpers/TestClientHarness.cs | 82 ++++ Projects/Server.Tests/Helpers/TestMulti.cs | 17 + .../Tests/Client/ClientVersionTests.cs | 3 +- .../Tests/Console/ConsoleInputPumpTests.cs | 12 +- .../Tests/Geometry/Point3DTests.cs | 2 +- .../Tests/Geometry/Rectangle3DTests.cs | 2 +- .../Tests/Geometry/WorldLocationTests.cs | 6 +- .../Tests/Items/ContainerBulkRemovalTests.cs | 78 ++-- .../Tests/Items/ContainerChildRemovalTests.cs | 54 +-- .../Tests/Items/DecayRegistrationTests.cs | 299 ++++++-------- .../Tests/Items/LiftRejectResyncTests.cs | 49 +-- .../Items/LockedDownContainerDecayTests.cs | 48 +-- .../Items/PrivateChildPropertiesTests.cs | 60 +-- .../Localization/LocalizationEntryTests.cs | 20 +- .../Tests/Maps/CanFitItemTests.cs | 32 +- .../Tests/Maps/CanSpawnMobileTests.cs | 16 +- .../Tests/Maps/CanSpawnMobileTileDataTests.cs | 29 +- .../Tests/Maps/MultiEnumeratorTests.cs | 34 +- .../Tests/Maps/StaticTileEnumeratorTests.cs | 73 ++-- .../Tests/Network/Bans/BanChannelTests.cs | 27 +- .../Network/MovementThrottleLatencyTests.cs | 93 +---- .../Tests/Network/NetStateDisconnectTests.cs | 62 +-- .../Tests/Network/NetStateRecvGuardTests.cs | 98 ++--- .../Tests/Network/NetStateSendBufferTests.cs | 142 ++----- .../Packets/Outgoing/AccountPacketTests.cs | 203 +++------- .../Packets/Outgoing/CombatPacketTests.cs | 24 +- .../Packets/Outgoing/ContainerPacketTests.cs | 286 +++++--------- .../Packets/Outgoing/DamagePacketTests.cs | 22 +- .../Packets/Outgoing/EffectPacketTests.cs | 23 +- .../Network/Packets/Outgoing/EffectPackets.cs | 151 -------- .../Packets/Outgoing/EquipmentPacketTests.cs | 29 +- .../Packets/Outgoing/GumpPacketTests.cs | 41 +- .../Packets/Outgoing/ItemPacketTests.cs | 91 ++--- .../Network/Packets/Outgoing/ItemPackets.cs | 18 - .../Packets/Outgoing/LightPacketTests.cs | 17 +- .../Packets/Outgoing/MapPacketTests.cs | 24 +- .../Packets/Outgoing/MenuPacketTests.cs | 33 +- .../Packets/Outgoing/MessagePacketTests.cs | 196 ++-------- .../Packets/Outgoing/MobilePacketTests.cs | 365 +++++------------- .../Packets/Outgoing/MovementPacketTests.cs | 34 +- .../Packets/Outgoing/PlayerPacketTests.cs | 224 +++-------- .../Outgoing/SecureTradePacketTests.cs | 61 +-- .../Packets/Outgoing/TargetPacketsTests.cs | 31 +- .../Packets/Outgoing/VendorBuyPacketTests.cs | 44 +-- .../Packets/Outgoing/VendorSellPacketTests.cs | 19 +- .../Outgoing/VirtualHairPacketTests.cs | 19 +- .../Packets/Outgoing/VirtualHairPackets.cs | 25 -- .../ObjectPropertyListHashTests.cs | 35 +- .../Tests/Serialization/AnchoredTimeTests.cs | 103 ++--- .../GenericEntityPersistenceRoundTripTests.cs | 293 +++++--------- .../SerializationChunkSourceTests.cs | 163 ++++---- .../Tests/World/VirtualSerialTests.cs | 41 +- .../UOContent.Tests/Helpers/AiTestStubs.cs | 42 ++ .../Helpers/CommandTestHarness.cs | 40 ++ .../Helpers/MobileTestHarness.cs | 114 ++++++ .../Helpers/StepCacheStateScope.cs | 45 +++ .../UOContent.Tests/Helpers/TempDirectory.cs | 44 +++ .../Helpers/TimerWheelScope.cs | 140 +++++++ .../Tests/Accounting/AccountPasswordTests.cs | 5 +- .../Tests/Accounting/PasswordWorkerTests.cs | 26 +- .../Security/Argon2TestParameters.cs | 55 +++ .../Security/PasswordProtectionTest.cs | 64 ++- .../Tests/Commands/ChainedBindingSortTests.cs | 29 +- .../Commands/ConditionalCompilerEdgeTests.cs | 35 +- .../Tests/Commands/DistinctComparerTests.cs | 19 +- .../Tests/Commands/EmitterRobustnessTests.cs | 6 +- .../Tests/Commands/ObjectConditionalTests.cs | 24 +- .../AdvancedSearchConditionsTests.cs | 6 +- .../AdvancedSearchTypesTests.cs | 11 +- .../Engines/Events/EventSchedulerTests.cs | 58 +-- .../Engines/Events/RecurrencePatternTests.cs | 26 +- .../Tests/Engines/Harvest/FishingTests.cs | 18 +- .../Tests/Engines/Party/PartyPacketTests.cs | 65 ++-- .../Pathing/BitmapAStarAlgorithmTests.cs | 81 ++-- .../Pathing/Multi/HousePathRoutingTests.cs | 13 +- .../Pathing/Multi/MultiCacheUsedTests.cs | 13 +- .../Pathing/Multi/MultiMaskCacheTests.cs | 15 +- .../Pathing/Multi/MultiPathInvariantTests.cs | 44 +-- .../Pathing/Multi/MultiSplitRoutingTests.cs | 28 +- .../Engines/Pathing/Multi/MultiTestSupport.cs | 6 - .../Pathing/Multi/MultiWalkabilityTests.cs | 5 - .../Engines/Pathing/PathfindRecorderTests.cs | 19 +- .../Engines/Pathing/PathingTestSupport.cs | 17 +- .../Engines/Pathing/StepCacheFileTests.cs | 29 +- .../Pathing/StepCacheFingerprintTests.cs | 5 +- .../Pathing/StepCacheLifecycleTests.cs | 37 +- .../Engines/Pathing/StepCacheParityTests.cs | 19 +- .../Spawners/Json/AllSpawnFilesLoadTests.cs | 8 +- .../Spawners/Json/ImportCleanupTests.cs | 79 ++-- .../Spawners/Json/SpawnerDtoEntryTests.cs | 33 +- .../Engines/Spawners/SectorSpawnCacheTests.cs | 6 - .../Spawners/SpawnerEntryOwnershipTests.cs | 62 +-- .../Misc/Corpses/CorpseChildPublicTests.cs | 167 ++++---- .../Items/Misc/Corpses/CorpseLootTests.cs | 27 +- .../Items/Misc/Corpses/CorpsePacketTests.cs | 53 ++- .../Tests/Items/VendorBuybackPackTests.cs | 12 +- .../Items/Weapons/Throwing/ThrowingTests.cs | 36 +- .../Tests/Misc/CorpseNotorietyTests.cs | 17 +- .../UOContent.Tests/Tests/Misc/EmailTests.cs | 19 +- .../Tests/Misc/GuildSerializePurityTests.cs | 57 ++- .../Tests/Misc/NameVerificationTests.cs | 2 +- .../Tests/Mobiles/AI/AcquisitionTests.cs | 22 +- .../Tests/Mobiles/AI/ApproachOutcomeTests.cs | 98 ++--- .../Tests/Mobiles/AI/ApproachTargetTests.cs | 124 +++--- .../Mobiles/AI/DetectHiddenTimerTests.cs | 29 +- .../Tests/Mobiles/AI/FamiliarAITests.cs | 40 +- .../Tests/Mobiles/AI/GuardFollowTests.cs | 46 ++- .../Mobiles/AI/GuardFriendlyFireTests.cs | 44 ++- .../Tests/Mobiles/AI/GuardOrderTests.cs | 22 +- .../Tests/Mobiles/AI/HerdingTests.cs | 7 +- .../Tests/Mobiles/AI/PetOrderTests.cs | 16 +- .../Tests/Mobiles/AI/PetPacingTests.cs | 50 +-- .../Tests/Mobiles/AI/PetRetaliationTests.cs | 10 +- .../Tests/Mobiles/AI/PetTestStub.cs | 12 +- .../Mobiles/BaseCreatureSerializationTests.cs | 110 +----- .../Tests/Mobiles/DeletedMasterTests.cs | 37 +- .../Tests/Mobiles/MasterViewsTests.cs | 37 +- .../Tests/Mobiles/VendorBuybackTests.cs | 129 ++----- .../Tests/Multis/Houses/HousePackets.cs | 6 +- .../Tests/Network/BanExemptionsTests.cs | 16 +- .../Bans/Blocklist/BlocklistFileTests.cs | 17 +- .../Bans/Blocklist/BlocklistFilterTests.cs | 6 +- .../Network/Bans/CrowdSecReporterTests.cs | 34 +- .../Firewall/FirewallPersistenceTests.cs | 5 +- .../Tests/Network/Firewall/FirewallTests.cs | 5 +- .../LoginAllowlist/LoginAllowlistTests.cs | 63 ++- .../Tests/Network/Packets/AuthIdTests.cs | 38 +- .../Tests/WorldSaves/ArchiveJournalTests.cs | 17 + 135 files changed, 3164 insertions(+), 4020 deletions(-) create mode 100644 Projects/Server.Tests/Helpers/EntityCleanup.cs create mode 100644 Projects/Server.Tests/Helpers/NetTestHelpers.cs create mode 100644 Projects/Server.Tests/Helpers/PacketAssert.cs create mode 100644 Projects/Server.Tests/Helpers/PersistencePipelineScope.cs create mode 100644 Projects/Server.Tests/Helpers/SimulatedClockScope.cs create mode 100644 Projects/Server.Tests/Helpers/TestClientHarness.cs create mode 100644 Projects/Server.Tests/Helpers/TestMulti.cs create mode 100644 Projects/UOContent.Tests/Helpers/AiTestStubs.cs create mode 100644 Projects/UOContent.Tests/Helpers/CommandTestHarness.cs create mode 100644 Projects/UOContent.Tests/Helpers/MobileTestHarness.cs create mode 100644 Projects/UOContent.Tests/Helpers/StepCacheStateScope.cs create mode 100644 Projects/UOContent.Tests/Helpers/TempDirectory.cs create mode 100644 Projects/UOContent.Tests/Helpers/TimerWheelScope.cs create mode 100644 Projects/UOContent.Tests/Tests/Accounting/Security/Argon2TestParameters.cs diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index eb74c39a4..d9219a87f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -57,7 +57,7 @@ jobs: # blame-hang kills a stuck test host after 10 minutes and reports the in-flight # tests plus a process dump instead of hanging until the job timeout. run: | - dotnet test --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full + dotnet test --filter "Category!=Manual" --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full if [ -z "$(find ./TestResults -name '*.trx' 2>/dev/null)" ]; then echo "::error::No test result files were produced - no test projects ran. Failing to avoid masking failures." exit 1 @@ -167,7 +167,7 @@ jobs: # blame-hang kills a stuck test host after 10 minutes and reports the in-flight # tests plus a process dump instead of hanging until the job timeout. run: | - dotnet test --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full + dotnet test --filter "Category!=Manual" --logger trx --results-directory ./TestResults --blame-hang --blame-hang-timeout 10m --blame-hang-dump-type full if [ -z "$(find ./TestResults -name '*.trx' 2>/dev/null)" ]; then echo "::error::No test result files were produced - no test projects ran. Failing to avoid masking failures." exit 1 diff --git a/Projects/Server.Tests/Helpers/EntityCleanup.cs b/Projects/Server.Tests/Helpers/EntityCleanup.cs new file mode 100644 index 000000000..61917b61e --- /dev/null +++ b/Projects/Server.Tests/Helpers/EntityCleanup.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace Server.Tests; + +/// +/// Deletes tracked entities on dispose, so a test that fails mid-way does not leave them in the shared World. +/// Null and already-deleted entities are skipped. +/// +internal sealed class EntityCleanup : IDisposable +{ + private readonly List _entities = []; + + public T Track(T entity) where T : IEntity + { + _entities.Add(entity); + return entity; + } + + public void Dispose() + { + // Reverse order deletes children before the containers or owners that hold them. + for (var i = _entities.Count - 1; i >= 0; i--) + { + if (_entities[i] is { Deleted: false } entity) + { + entity.Delete(); + } + } + + _entities.Clear(); + } +} diff --git a/Projects/Server.Tests/Helpers/MockAccount.cs b/Projects/Server.Tests/Helpers/MockAccount.cs index 8d8973d86..2725d3438 100644 --- a/Projects/Server.Tests/Helpers/MockAccount.cs +++ b/Projects/Server.Tests/Helpers/MockAccount.cs @@ -20,15 +20,27 @@ public class MockAccount : IAccount public string Username { get; } public string Email { get; set; } public AccessLevel AccessLevel { get; set; } - public int Length { get; } + public int Length { get; private set; } public int Limit { get; set; } = 6; // Default to 6 character slots - public int Count { get; } + public int Count { get; private set; } private readonly Dictionary _mobiles = new(); public Mobile this[int index] { get => _mobiles.GetValueOrDefault(index); - set => _mobiles[index] = value; + set + { + var old = _mobiles.GetValueOrDefault(index); + _mobiles[index] = value; + + Length = Math.Max(Length, index + 1); + Count += (value != null ? 1 : 0) - (old != null ? 1 : 0); + + if (value != null) + { + value.Account = this; + } + } } public DateTime Created { get; set; } diff --git a/Projects/Server.Tests/Helpers/NetTestHelpers.cs b/Projects/Server.Tests/Helpers/NetTestHelpers.cs new file mode 100644 index 000000000..3f3cef3fc --- /dev/null +++ b/Projects/Server.Tests/Helpers/NetTestHelpers.cs @@ -0,0 +1,152 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Net.Sockets; +using System.Network; +using System.Runtime.CompilerServices; +using System.Threading; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Shared plumbing for tests that drive a real over loopback. +/// +public static class NetTestHelpers +{ + public const byte PingPacketId = 0x73; + + /// The payload byte of the last ping delivered to the handler from . + public static byte LastPing { get; private set; } + + /// + /// Restores the handler that was in a packet slot before a test replaced it. The handler table is + /// process-global, so a handler left behind would silently answer for that id in every later test. + /// + public readonly struct HandlerRegistration : IDisposable + { + private readonly int _packetId; + private readonly PacketHandler _previous; + + internal HandlerRegistration(int packetId, PacketHandler previous) + { + _packetId = packetId; + _previous = previous; + } + + public void Dispose() => IncomingPackets.Handlers[_packetId] = _previous; + } + + public static unsafe HandlerRegistration RegisterHandler( + int packetId, int length, delegate* onReceive + ) + { + var previous = IncomingPackets.GetHandler(packetId); + IncomingPackets.Register(packetId, length, false, onReceive); + return new HandlerRegistration(packetId, previous); + } + + /// Registers a two-byte ping whose payload byte is recorded in . + public static unsafe HandlerRegistration RegisterPing() + { + LastPing = 0; + return RegisterHandler(PingPacketId, 2, &RecordPing); + } + + private static void RecordPing(NetState state, SpanReader reader) => LastPing = reader.ReadByte(); + + public static byte[] Pattern(int length, int seed) + { + var data = new byte[length]; + new System.Random(seed).NextBytes(data); + return data; + } + + /// + /// Pre-auth buffers are off only where the platform floor leaves no room below the base size + /// (the Windows Server 2012/2016 mapping path, or a 64 KiB-page kernel). Anywhere else a zero + /// initial size is a configuration regression and fails rather than skips. + /// + public static void RequirePreAuthBuffers() + { + var floor = IORingBuffer.MinimumSize; + Skip.If( + floor >= NetState.RecvBufferSize || floor >= NetState.SendBufferSize, + $"Pre-auth buffers are off on this platform: the {floor}-byte mapping floor leaves no room below the base buffers (recv {NetState.RecvBufferSize}, send {NetState.SendBufferSize})" + ); + + Assert.True( + NetState.InitialRecvBufferSize > 0 && NetState.InitialSendBufferSize > 0, + $"Pre-auth buffers are off although the {floor}-byte platform floor allows them (initial recv {NetState.InitialRecvBufferSize}, send {NetState.InitialSendBufferSize})" + ); + } + + /// Runs the network slice until holds, failing with the condition that timed out. + public static void SliceUntil( + Func done, int timeoutMs = 5000, [CallerArgumentExpression(nameof(done))] string condition = null + ) + { + var deadline = Stopwatch.StartNew(); + while (!done()) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms waiting for {condition}"); + } + + NetState.Slice(); + Thread.Sleep(5); + } + } + + /// Reads exactly bytes from the peer, slicing the server while it waits. + public static byte[] ReadAll(Socket client, int length, int timeoutMs = 10000) + { + var received = new byte[length]; + var total = 0; + var deadline = Stopwatch.StartNew(); + while (total < length) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms with {total} of {length} bytes received by the peer"); + } + + NetState.Slice(); + + // Poll takes microseconds, not milliseconds + if (client.Poll(50_000, SelectMode.SelectRead)) + { + var read = client.Receive(received, total, length - total, SocketFlags.None); + Assert.True(read != 0, $"Peer saw EOF after {total} of {length} bytes"); + total += read; + } + } + + return received; + } + + /// Waits for the server's FIN to reach the peer, failing if any data arrives first. + public static void WaitForEof(Socket client, int timeoutMs = 10000) + { + var scratch = new byte[64]; + var deadline = Stopwatch.StartNew(); + while (true) + { + if (deadline.ElapsedMilliseconds >= timeoutMs) + { + Assert.Fail($"Timed out after {timeoutMs} ms waiting for the peer to see EOF"); + } + + NetState.Slice(); + + if (client.Poll(50_000, SelectMode.SelectRead)) + { + var read = client.Receive(scratch); + Assert.True(read == 0, $"Peer received {read} unexpected bytes while waiting for EOF"); + return; + } + } + } +} diff --git a/Projects/Server.Tests/Helpers/PacketAssert.cs b/Projects/Server.Tests/Helpers/PacketAssert.cs new file mode 100644 index 000000000..ca8efb5ee --- /dev/null +++ b/Projects/Server.Tests/Helpers/PacketAssert.cs @@ -0,0 +1,31 @@ +using System; +using Server.Network; + +namespace Server.Tests.Network; + +public static class PacketAssert +{ + /// + /// Asserts that writes exactly the bytes of the reference + /// packet to a fresh test NetState negotiated with . + /// + public static void Sends(Packet expected, Action send, ProtocolChanges changes = ProtocolChanges.None) => + Sends(_ => expected, send, changes); + + /// + /// As , for a reference packet that depends on + /// the negotiated NetState. It is built after is applied and before the send. + /// + public static void Sends( + Func expected, Action send, ProtocolChanges changes = ProtocolChanges.None + ) + { + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.ProtocolChanges = changes; + + var expectedBytes = expected(ns).Compile(); + send(ns); + + AssertThat.Equal(ns.SendBuffer.GetReadSpan(), expectedBytes); + } +} diff --git a/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs b/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs new file mode 100644 index 000000000..dbdb05524 --- /dev/null +++ b/Projects/Server.Tests/Helpers/PersistencePipelineScope.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace Server.Tests; + +/// +/// A private save pipeline: a chunk source with its own serialization workers, optionally installed as +/// World's workers, optionally a scratch directory and an extra assembly visible to the type loader. +/// Dispose unregisters every tracked persistence, exits the workers and restores every static it swapped. +/// +internal sealed class PersistencePipelineScope : IDisposable +{ + private readonly List _persistences = []; + private readonly SerializationThreadWorker[] _previousWorkers; + private readonly Assembly[] _previousAssemblies; + private readonly DateTime _previousSaveStart; + private readonly bool _installedWorkers; + private readonly bool _swappedAssemblies; + + /// Workers to create, each with an allocated heap. + /// Install the workers as World's, which WriteSnapshot reads segments from. + /// An assembly the loader must resolve types from by hash. + /// Creates a unique scratch directory under the temp path when set. + public PersistencePipelineScope( + int workerCount, + bool installWorkers = false, + Assembly visibleAssembly = null, + string directoryPrefix = null + ) + { + _previousSaveStart = World.SaveStartTime; + + Source = new SerializationChunkSource(); + Workers = new SerializationThreadWorker[workerCount]; + for (var i = 0; i < Workers.Length; i++) + { + Workers[i] = new SerializationThreadWorker(i, Source); + Workers[i].AllocateHeap(); + } + + if (installWorkers) + { + _installedWorkers = true; + _previousWorkers = World._threadWorkers; + World._threadWorkers = Workers; + } + + if (visibleAssembly != null) + { + _swappedAssemblies = true; + _previousAssemblies = AssemblyHandler.Assemblies; + AssemblyHandler.Assemblies = [.. _previousAssemblies ?? [], visibleAssembly]; + } + + if (directoryPrefix != null) + { + Directory = Path.Combine(Path.GetTempPath(), $"{directoryPrefix}-{Guid.NewGuid():N}"); + System.IO.Directory.CreateDirectory(Directory); + } + } + + public SerializationChunkSource Source { get; } + + public SerializationThreadWorker[] Workers { get; } + + /// The scratch directory, or null when none was requested. + public string Directory { get; } + + /// Tracks a persistence so it leaves the static registry on dispose. + public T Track(T persistence) where T : Persistence + { + _persistences.Add(persistence); + return persistence; + } + + public void WakeWorkers() + { + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Wake(); + } + } + + public void SleepWorkers() + { + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Sleep(); + } + } + + /// Mirrors World.PauseSerializationThreads: publish the partial chunk, then wait for the drain. + public void FlushAndSleepWorkers() + { + Source.Flush(); + SleepWorkers(); + } + + public void Dispose() + { + World.SaveStartTime = _previousSaveStart; + + for (var i = 0; i < _persistences.Count; i++) + { + _persistences[i]?.Unregister(); + } + + for (var i = 0; i < Workers.Length; i++) + { + Workers[i].Exit(); + } + + if (_installedWorkers) + { + World._threadWorkers = _previousWorkers; + } + + if (_swappedAssemblies) + { + AssemblyHandler.Assemblies = _previousAssemblies; + } + + if (Directory != null) + { + System.IO.Directory.Delete(Directory, true); + } + } +} diff --git a/Projects/Server.Tests/Helpers/SimulatedClockScope.cs b/Projects/Server.Tests/Helpers/SimulatedClockScope.cs new file mode 100644 index 000000000..cb4b5acfd --- /dev/null +++ b/Projects/Server.Tests/Helpers/SimulatedClockScope.cs @@ -0,0 +1,42 @@ +using System; +using Server.Items; + +namespace Server.Tests; + +/// +/// Lets a test move the game clock () and restores it on dispose, so a failed +/// assert cannot leave every later test running in the future. +/// +internal sealed class SimulatedClockScope : IDisposable +{ + private static readonly TimeSpan _decayTickStep = TimeSpan.FromMilliseconds(256); + + public SimulatedClockScope() => Start = Core._now; + + public DateTime Start { get; } + + public DateTime Now + { + get => Core._now; + set => Core._now = value; + } + + /// + /// Steps the clock from through , driving the decay + /// scheduler each step without the shared timer wheel, and stops early once + /// is deleted. + /// + public void AdvanceDecay(TimeSpan duration, Item item) + { + var start = Core._now; + var deadline = start + duration; + + for (var now = start; now <= deadline && !item.Deleted; now += _decayTickStep) + { + Core._now = now; + DecayScheduler.ProcessTick(now); + } + } + + public void Dispose() => Core._now = Start; +} diff --git a/Projects/Server.Tests/Helpers/TestClientHarness.cs b/Projects/Server.Tests/Helpers/TestClientHarness.cs new file mode 100644 index 000000000..aa9dde5d0 --- /dev/null +++ b/Projects/Server.Tests/Helpers/TestClientHarness.cs @@ -0,0 +1,82 @@ +using System; +using System.Buffers; +using Server.Network; +using Server.Tests.Network; + +namespace Server.Tests; + +/// +/// A logged-in test client (NetState + Mobile) and byte-pattern probes over what it was sent. +/// The probes search the raw send buffer, so they only see packets queued since the NetState was created. +/// +internal static class TestClientHarness +{ + public static (NetState, Mobile) CreateClient(Point3D location) + { + var ns = PacketTestUtilities.CreateTestNetState(); + ns.Account = new MockAccount(); + + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + ns.Mobile = mobile; + mobile.NetState = ns; + mobile.MoveToWorld(location, Map.Felucca); + return (ns, mobile); + } + + public static void DisposeClient(NetState ns, Mobile mobile) + { + ns.Mobile = null; + ns.Dispose(); + mobile.Delete(); + } + + public static bool ReceivedRemove(NetState ns, Serial serial) => CountRemoves(ns, serial) > 0; + + public static int CountRemoves(NetState ns, Serial serial) + { + Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; + OutgoingEntityPackets.CreateRemoveEntity(expected, serial); + + var span = ns.SendBuffer.GetReadSpan(); + var count = 0; + + while (true) + { + var index = span.IndexOf(expected); + + if (index < 0) + { + break; + } + + count++; + span = span[(index + expected.Length)..]; + } + + return count; + } + + public static bool ReceivedWorldItem(NetState ns, Item item) + { + Span expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength]; + var length = OutgoingItemPackets.CreateWorldItem(expected, item); + return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0; + } + + public static bool ReceivedContentUpdate(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0x25, serial); + + public static bool ReceivedEquipUpdate(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0x2E, serial); + + public static bool ReceivedOplInfo(NetState ns, Serial serial) => ReceivedPacketForSerial(ns, 0xDC, serial); + + // Each of these packets is fixed-length with the serial immediately after the packet ID. + private static bool ReceivedPacketForSerial(NetState ns, byte packetId, Serial serial) + { + Span expected = stackalloc byte[5]; + var writer = new SpanWriter(expected); + writer.Write(packetId); + writer.Write(serial); + return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; + } +} diff --git a/Projects/Server.Tests/Helpers/TestMulti.cs b/Projects/Server.Tests/Helpers/TestMulti.cs new file mode 100644 index 000000000..b7363c3f7 --- /dev/null +++ b/Projects/Server.Tests/Helpers/TestMulti.cs @@ -0,0 +1,17 @@ +using Server.Items; + +namespace Server.Tests; + +/// +/// A multi with caller-supplied components. The test host never loads MultiData, so a multi built +/// from client data would have no components; without a list this one is empty the same way. +/// +internal sealed class TestMulti : BaseMulti +{ + private readonly MultiComponentList _components; + + public TestMulti(MultiComponentList components = null) : base(0x1) => + _components = components ?? MultiComponentList.Empty; + + public override MultiComponentList Components => _components; +} diff --git a/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs b/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs index 471a5584c..39a73ad7b 100644 --- a/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs +++ b/Projects/Server.Tests/Tests/Client/ClientVersionTests.cs @@ -2,13 +2,13 @@ using Xunit; namespace Server.Tests.Network; -[Collection("Sequential Server Tests")] public class ClientVersionTests { [Theory] [InlineData("7.0.33.1", "67.0.33", false, 0)] [InlineData("7.0.45.65", "67.0.33", false, 1)] [InlineData("6.0.0.0", "67.0.33", false, -1)] + [InlineData("7.0.33.1", "7.0.33.1", true, 0)] public void TestCCAndECStringCtor(string ccVersion, string ecVersion, bool equal, int comparison) { var cc = new ClientVersion(ccVersion); @@ -22,6 +22,7 @@ public class ClientVersionTests [InlineData(7, 0, 33, 1, 67, 0, 33, 0, false, 0)] [InlineData(7, 0, 45, 65, 67, 0, 33, 0, false, 1)] [InlineData(6, 0, 0, 0, 67, 0, 33, 0, false, -1)] + [InlineData(7, 0, 33, 1, 7, 0, 33, 1, true, 0)] public void TestCCAndECCtor( int ccMaj, int ccMin, int ccRev, int ccPatch, int ecMaj, int ecMin, int ecRev, int ecPatch, bool equal, int comparison diff --git a/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs index 50d015555..cdf084fb7 100644 --- a/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs +++ b/Projects/Server.Tests/Tests/Console/ConsoleInputPumpTests.cs @@ -70,10 +70,11 @@ public class ConsoleInputPumpTests cmd => cmd == "ping" ? onPing : null ); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); Assert.True(invokedWith.Task.Wait(Timeout), "command not dispatched"); Assert.Equal("hello", invokedWith.Task.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); } [Fact] @@ -81,7 +82,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => null); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); // Register the prompt, then wait until it is actually pending before feeding a // line — otherwise the line could be consumed as a command before registration. @@ -93,6 +94,7 @@ public class ConsoleInputPumpTests Assert.Equal("the answer", prompt.Result); reader.Complete(); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); } [Fact] @@ -100,7 +102,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => null); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); var prompt = Task.Run(pump.ReadLine); WaitUntil(() => pump.HasPendingPrompt, "prompt did not register"); @@ -108,6 +110,7 @@ public class ConsoleInputPumpTests Assert.True(prompt.Wait(Timeout), "prompt hung on EOF"); Assert.Null(prompt.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); Assert.False(pump.Running); } @@ -116,7 +119,7 @@ public class ConsoleInputPumpTests { var reader = new BlockingTextReader(); var pump = new ConsoleInputPump(reader, _ => throw new InvalidOperationException("boom")); - Task.Run(pump.Run); + var run = Task.Run(pump.Run); // Wait until the reader loop is blocked on its first read, then feed a bad command. WaitUntil(() => reader.ReadCallCount >= 1, "reader did not start"); @@ -133,6 +136,7 @@ public class ConsoleInputPumpTests Assert.True(prompt.Wait(Timeout), "prompt hung after throwing lookup"); Assert.Null(prompt.Result); + Assert.True(run.Wait(Timeout), "Run() did not return on EOF"); Assert.False(pump.Running); } } diff --git a/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs b/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs index 7fae1f3bf..cdad2512b 100644 --- a/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Point3DTests.cs @@ -68,7 +68,7 @@ public class Point3DTests Assert.Equal(new Point3D(101, 23, 55), Point3D.Parse(" (101,23 ,55) ", null)); // No parenthesis - Assert.False(Point3D.TryParse("101, 23 55)", null, out p)); + Assert.False(Point3D.TryParse("101, 23, 55)", null, out p)); Assert.Equal(default, p); Assert.Throws(() => Point3D.Parse("101, 23, 55)", null)); diff --git a/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs b/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs index d225b625f..9f1ffdb20 100644 --- a/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/Rectangle3DTests.cs @@ -104,7 +104,7 @@ public class Rectangle3DTests Assert.False(Rectangle3D.TryParse("(101,,10)+(55, 89 1)", null, out p)); Assert.Equal(default, p); - Assert.Throws(() => Rectangle3D.Parse("(,23,)+(,89)", null)); + Assert.Throws(() => Rectangle3D.Parse("(101,,10)+(55, 89 1)", null)); Assert.False(Rectangle3D.TryParse("(,,)+(55,89, 1)", null, out p)); Assert.Equal(default, p); diff --git a/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs b/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs index 561ece1f9..a913eb9a9 100644 --- a/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs +++ b/Projects/Server.Tests/Tests/Geometry/WorldLocationTests.cs @@ -90,16 +90,16 @@ public sealed class WorldLocationTests Assert.Equal(new WorldLocation(101, 23, 55, null), WorldLocation.Parse(" (101,23 ,55) [(-null-)]", null)); // No Brackets - Assert.False(WorldLocation.TryParse("(101, 23 55) Felucca]", null, out p)); + Assert.False(WorldLocation.TryParse("(101, 23, 55) Felucca]", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("(101, 23, 55) Felucca]", null)); - Assert.False(WorldLocation.TryParse("(101, 23 55) [Felucca", null, out p)); + Assert.False(WorldLocation.TryParse("(101, 23, 55) [Felucca", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("(101, 23, 55) [Felucca", null)); // No parenthesis - Assert.False(WorldLocation.TryParse("101, 23 55) [Felucca]", null, out p)); + Assert.False(WorldLocation.TryParse("101, 23, 55) [Felucca]", null, out p)); Assert.Equal(default, p); Assert.Throws(() => WorldLocation.Parse("101, 23, 55) [Felucca]", null)); diff --git a/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs index c529f8a12..33740ec79 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs @@ -6,32 +6,43 @@ namespace Server.Tests; [Collection("Sequential Server Tests")] public class ContainerBulkRemovalTests { + // Large enough that a quadratic child removal (a front-to-back IndexOf per child) costs + // roughly 2 * 10^8 comparisons and shows up in the test's run time. + private const int ChildCount = 20_000; + [Fact] public void DeletingLargeContainer_DeletesAllChildren_AndTotalsStayConsistent() { var outer = new Container(0xE75); - var pack = new Container(0xE75); - outer.DropItem(pack); - var children = new Item[100_000]; - for (var i = 0; i < children.Length; i++) + try { - children[i] = new Item(0x1234); - pack.DropItem(children[i]); + var pack = new Container(0xE75); + outer.DropItem(pack); + + var children = new Item[ChildCount]; + for (var i = 0; i < children.Length; i++) + { + children[i] = new Item(0x1234); + pack.DropItem(children[i]); + } + + Assert.Equal(children.Length + 1, outer.TotalItems); + + pack.Delete(); + + for (var i = 0; i < children.Length; i++) + { + Assert.True(children[i].Deleted); + } + + Assert.Empty(outer.Items); + Assert.Equal(0, outer.TotalItems); } - - Assert.Equal(children.Length + 1, outer.TotalItems); - - pack.Delete(); - - for (var i = 0; i < children.Length; i++) + finally { - Assert.True(children[i].Deleted); + outer.Delete(); } - - Assert.Empty(outer.Items); - Assert.Equal(0, outer.TotalItems); - outer.Delete(); } // The tail fast path must not change which entry is removed when the item is elsewhere. @@ -39,20 +50,27 @@ public class ContainerBulkRemovalTests public void RemovingFromFrontMiddleAndEnd_RemovesExactlyThatItem() { var pack = new Container(0xE75); - var items = new Item[5]; - for (var i = 0; i < items.Length; i++) + + try { - items[i] = new Item(0x1234); - pack.DropItem(items[i]); + var items = new Item[5]; + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.DropItem(items[i]); + } + + items[0].Delete(); + items[2].Delete(); + items[4].Delete(); + + Assert.Equal(2, pack.Items.Count); + Assert.Same(items[1], pack.Items[0]); + Assert.Same(items[3], pack.Items[1]); + } + finally + { + pack.Delete(); } - - items[0].Delete(); - items[2].Delete(); - items[4].Delete(); - - Assert.Equal(2, pack.Items.Count); - Assert.Same(items[1], pack.Items[0]); - Assert.Same(items[3], pack.Items[1]); - pack.Delete(); } } diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs index 695838185..587753250 100644 --- a/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs +++ b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs @@ -1,8 +1,7 @@ -using System; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; @@ -44,19 +43,6 @@ public class ContainerChildRemovalTests private static readonly Point3D _ownerLoc = new(1500, 1500, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - private static Mobile CreateOwnerWith(Container pack) { var owner = new Mobile(World.NewMobile); @@ -68,44 +54,6 @@ public class ContainerChildRemovalTests return owner; } - private static bool ReceivedRemove(NetState ns, Serial serial) - { - Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; - OutgoingEntityPackets.CreateRemoveEntity(expected, serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - - private static int CountRemoves(NetState ns, Serial serial) - { - Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; - OutgoingEntityPackets.CreateRemoveEntity(expected, serial); - - var span = ns.SendBuffer.GetReadSpan(); - var count = 0; - - while (true) - { - var index = span.IndexOf(expected); - - if (index < 0) - { - break; - } - - count++; - span = span[(index + expected.Length)..]; - } - - return count; - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - private static void Cleanup(NetState ns, Mobile client, Mobile owner) { DisposeClient(ns, client); diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 0f97eaa28..85ffd3c84 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -5,11 +5,15 @@ using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class DecayRegistrationTests +public class DecayRegistrationTests : IDisposable { + private readonly EntityCleanup _cleanup = new(); + // The wheel is static and these tests move the clock, so start each from a known schedule. public DecayRegistrationTests() => DecayScheduler.ResetForTests(); + public void Dispose() => _cleanup.Dispose(); + // Stands in for a region that refuses decay, e.g. HouseRegion over a locked down item. private class RefusesDecayItem : Item { @@ -20,61 +24,34 @@ public class DecayRegistrationTests public override bool OnDecay() => false; } - // The item is already dequeued, so a refusal that does not reschedule strands it forever. - // Drives the scheduler and the game clock without depending on the shared timer wheel. - private static void AdvanceDecay(DateTime start, TimeSpan duration, Item item) - { - var deadline = start + duration; - - for (var now = start; now <= deadline && !item.Deleted; now += TimeSpan.FromMilliseconds(256)) - { - Core._now = now; - DecayScheduler.ProcessTick(now); - } - } - [Fact] public void ItemOnGround_ActuallyDecaysAfterDecayTime() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(100, 100, 0), Map.Felucca); - Assert.True(DecayScheduler.IsRegistered(item)); + Assert.True(DecayScheduler.IsRegistered(item)); - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime + TimeSpan.FromMinutes(2), item); - Assert.True(item.Deleted, "Item on the ground must decay once DecayTime elapses."); - } - finally - { - Core._now = start; - } + Assert.True(item.Deleted, "Item on the ground must decay once DecayTime elapses."); } [Fact] public void ItemOnGround_DoesNotDecayBeforeDecayTime() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(102, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(102, 100, 0), Map.Felucca); - AdvanceDecay(start, item.DecayTime - TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "Item must not decay before DecayTime elapses."); + Assert.False(item.Deleted, "Item must not decay before DecayTime elapses."); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // The real client path: Mobile.Lift internalizes, then Mobile.Drop calls MoveToWorld. @@ -84,7 +61,7 @@ public class DecayRegistrationTests [Fact] public void DecaySlot_AgreesWithStructures_AcrossLifecycle() { - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); item.MoveToWorld(new Point3D(103, 100, 0), Map.Felucca); Assert.NotEqual(DecayScheduler.SlotNone, item.DecaySlot); @@ -108,10 +85,10 @@ public class DecayRegistrationTests [Fact] public void ContainerDestroy_ContentsDroppedToGroundAreTracked() { - var pack = new Container(0xE75); + var pack = _cleanup.Track(new Container(0xE75)); pack.MoveToWorld(new Point3D(105, 100, 0), Map.Felucca); - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); pack.AddItem(item); Assert.False(DecayScheduler.IsRegistered(item)); @@ -128,163 +105,127 @@ public class DecayRegistrationTests [Fact] public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); - item.Movable = false; - Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); + item.Movable = false; + Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); - Core._now = start + TimeSpan.FromDays(30); - var flipped = Core._now; + clock.Now = clock.Start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = true; - Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); + Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); - AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); - AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); - Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); - } - finally - { - Core._now = start; - } + clock.AdvanceDecay(TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); } // Same transition through the Visible setter: unhiding a long-hidden item. [Fact] public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); - item.Visible = false; - Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); + item.Visible = false; + Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); - Core._now = start + TimeSpan.FromDays(30); - var flipped = Core._now; + clock.Now = clock.Start + TimeSpan.FromDays(30); - item.Visible = true; + item.Visible = true; - Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); + Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); - AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); + clock.AdvanceDecay(item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); - AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); - Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); - } - finally - { - Core._now = start; - } + clock.AdvanceDecay(TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); } - // A refusal restarts the countdown without rewriting LastMoved. + // A refusal restarts the countdown without rewriting LastMoved. The item is already dequeued + // when OnDecay refuses, so a refusal that does not reschedule strands it forever. [Fact] public void RefusedDecay_DoesNotRewriteLastMoved() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new RefusesDecayItem(); - item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); - var lastMoved = item.LastMoved; + var item = _cleanup.Track(new RefusesDecayItem()); + item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); + var lastMoved = item.LastMoved; - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + clock.AdvanceDecay(item.DecayTime + TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "A refused decay must not delete the item."); - Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); - Assert.Equal(lastMoved, item.LastMoved); + Assert.False(item.Deleted, "A refused decay must not delete the item."); + Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); + Assert.Equal(lastMoved, item.LastMoved); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // The fresh window must survive a save/load cycle, or a restart mid-window deletes the item. [Fact] public void FreshDecayWindow_SurvivesSerialization() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - var expected = item.ScheduledDecayTime; + var expected = item.ScheduledDecayTime; - var writer = new BufferWriter(new byte[512], true); - item.Serialize(writer); + var writer = new BufferWriter(new byte[512], true); + item.Serialize(writer); - var copy = new Item(item.Serial); - copy.Deserialize(new BufferReader(writer.Buffer)); + var copy = new Item(item.Serial); + copy.Deserialize(new BufferReader(writer.Buffer)); - // The stamp is stored as a delta, so it ages only by the real time between - // write and read - milliseconds here, the downtime in production. - Assert.True( - (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), - "The restarted decay window must survive a save/load cycle." - ); + // The stamp is stored as a delta, so it ages only by the real time between + // write and read - milliseconds here, the downtime in production. + Assert.True( + (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), + "The restarted decay window must survive a save/load cycle." + ); - item.Delete(); - copy.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); + copy.Delete(); } // A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse. [Fact] public void MovingAnItem_ClearsASupersededDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - Core._now += TimeSpan.FromMinutes(1); - item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); + clock.Now += TimeSpan.FromMinutes(1); + item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); - Assert.Equal(default, item.DecayResetTime); - Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); - Assert.True(DecayScheduler.IsRegistered(item)); + Assert.Equal(default, item.DecayResetTime); + Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); + Assert.True(DecayScheduler.IsRegistered(item)); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // Losing decay eligibility makes the stamp meaningless; it must be dropped so the @@ -292,29 +233,22 @@ public class DecayRegistrationTests [Fact] public void ItemBecomingIneligible_DropsTheDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - item.Movable = false; + item.Movable = false; - Assert.Equal(default, item.DecayResetTime); + Assert.Equal(default, item.DecayResetTime); - item.Delete(); - } - finally - { - Core._now = start; - } + item.Delete(); } // Moving a stamped item into a container programmatically (no drop, no SetLastMoved) @@ -322,32 +256,25 @@ public class DecayRegistrationTests [Fact] public void StampedItemAddedToContainer_DropsTheDecayResetStamp() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var pack = new Container(0xE75); - pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); + var pack = _cleanup.Track(new Container(0xE75)); + pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); + var item = _cleanup.Track(new Item(0x1234)); + item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; + item.Movable = false; + clock.Now = clock.Start + TimeSpan.FromDays(30); + item.Movable = true; - Assert.NotEqual(default, item.DecayResetTime); + Assert.NotEqual(default, item.DecayResetTime); - pack.AddItem(item); + pack.AddItem(item); - Assert.Equal(default, item.DecayResetTime); + Assert.Equal(default, item.DecayResetTime); - pack.Delete(); - } - finally - { - Core._now = start; - } + pack.Delete(); } // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must @@ -355,7 +282,7 @@ public class DecayRegistrationTests [Fact] public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay() { - var item = new Item(0x1234); + var item = _cleanup.Track(new Item(0x1234)); Assert.False(DecayScheduler.IsRegistered(item)); item.Map = Map.Felucca; @@ -371,10 +298,10 @@ public class DecayRegistrationTests [Fact] public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay() { - var stack = new Item(0x1234) { Stackable = true, Amount = 10 }; + var stack = _cleanup.Track(new Item(0x1234) { Stackable = true, Amount = 10 }); stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca); - var remainder = Mobile.LiftItemDupe(stack, 3); + var remainder = _cleanup.Track(Mobile.LiftItemDupe(stack, 3)); Assert.NotNull(remainder); Assert.Equal(7, remainder.Amount); @@ -388,6 +315,4 @@ public class DecayRegistrationTests stack.Delete(); remainder.Delete(); } - - // Dropping into a container must untrack; taking it back out to the ground must re-track. } diff --git a/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs index 97cffbba9..03b372e14 100644 --- a/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs +++ b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs @@ -1,9 +1,7 @@ -using System; -using System.Buffers; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; @@ -12,51 +10,6 @@ public class LiftRejectResyncTests { private static readonly Point3D _baseLoc = new(2200, 2200, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - - private static bool ReceivedContentUpdate(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0x25); // ContainerContentUpdate packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - - private static bool ReceivedWorldItem(NetState ns, Item item) - { - Span expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength]; - var length = OutgoingItemPackets.CreateWorldItem(expected, item); - return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0; - } - - private static bool ReceivedEquipUpdate(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0x2E); // EquipUpdate packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - [Fact] public void PrivateNestedItem_RejectedLift_DoesNotResyncBystander() { diff --git a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs index b8b4fd419..e7d790b7b 100644 --- a/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs +++ b/Projects/Server.Tests/Tests/Items/LockedDownContainerDecayTests.cs @@ -5,8 +5,10 @@ using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class LockedDownContainerDecayTests +public class LockedDownContainerDecayTests : IDisposable { + private readonly EntityCleanup _cleanup = new(); + public LockedDownContainerDecayTests() { DecayScheduler.ResetForTests(); @@ -19,6 +21,8 @@ public class LockedDownContainerDecayTests } } + public void Dispose() => _cleanup.Dispose(); + private class TestChest : Container { public TestChest() : base(0xE43) @@ -35,11 +39,11 @@ public class LockedDownContainerDecayTests public override bool ContentsDecay => false; } - private static Item NewLoot() => new Item(0x1F03) { Movable = true, Visible = true }; + private Item NewLoot() => _cleanup.Track(new Item(0x1F03) { Movable = true, Visible = true }); - private static TestChest PlaceLockedDownChest(int x) + private TestChest PlaceLockedDownChest(int x) { - var chest = new TestChest { Movable = false }; + var chest = _cleanup.Track(new TestChest { Movable = false }); chest.MoveToWorld(new Point3D(x, 100, 0), Map.Felucca); chest.IsLockedDown = true; return chest; @@ -62,7 +66,7 @@ public class LockedDownContainerDecayTests [Fact] public void ItemInOrdinaryContainer_IsNotTracked() { - var chest = new TestChest(); + var chest = _cleanup.Track(new TestChest()); chest.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); var loot = NewLoot(); @@ -77,7 +81,7 @@ public class LockedDownContainerDecayTests [Fact] public void LockingAndReleasingContainer_RegistersAndUnregistersContents() { - var chest = new TestChest { Movable = false }; + var chest = _cleanup.Track(new TestChest { Movable = false }); chest.MoveToWorld(new Point3D(122, 100, 0), Map.Felucca); var loot = NewLoot(); chest.AddItem(loot); @@ -102,7 +106,7 @@ public class LockedDownContainerDecayTests [Fact] public void ContainerThatKeepsContents_DoesNotTrackThem() { - var board = new KeepsContentsChest { Movable = false }; + var board = _cleanup.Track(new KeepsContentsChest { Movable = false }); board.MoveToWorld(new Point3D(123, 100, 0), Map.Felucca); board.IsLockedDown = true; var piece = NewLoot(); @@ -118,7 +122,7 @@ public class LockedDownContainerDecayTests public void NestedContainerContents_AreNotTracked() { var chest = PlaceLockedDownChest(124); - var bag = new TestChest(); + var bag = _cleanup.Track(new TestChest()); chest.AddItem(bag); var loot = NewLoot(); bag.AddItem(loot); @@ -145,29 +149,17 @@ public class LockedDownContainerDecayTests [Fact] public void TrackedContents_DecayOnSchedule() { - var start = Core._now; + using var clock = new SimulatedClockScope(); - try - { - var chest = PlaceLockedDownChest(126); - var loot = NewLoot(); - chest.AddItem(loot); + var chest = PlaceLockedDownChest(126); + var loot = NewLoot(); + chest.AddItem(loot); - var deadline = start + loot.DecayTime + TimeSpan.FromMinutes(2); - for (var now = start; now <= deadline && !loot.Deleted; now += TimeSpan.FromMilliseconds(256)) - { - Core._now = now; - DecayScheduler.ProcessTick(now); - } + clock.AdvanceDecay(loot.DecayTime + TimeSpan.FromMinutes(2), loot); - Assert.True(loot.Deleted, "Contents of a locked-down container must decay."); - Assert.False(chest.Deleted, "The locked-down container itself must not decay."); + Assert.True(loot.Deleted, "Contents of a locked-down container must decay."); + Assert.False(chest.Deleted, "The locked-down container itself must not decay."); - chest.Delete(); - } - finally - { - Core._now = start; - } + chest.Delete(); } } diff --git a/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs index 4481b08b2..97b67613f 100644 --- a/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs +++ b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs @@ -1,15 +1,24 @@ using System; -using System.Buffers; using Server.Items; using Server.Network; -using Server.Tests.Network; using Xunit; +using static Server.Tests.TestClientHarness; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class PrivateChildPropertiesTests +public class PrivateChildPropertiesTests : IDisposable { + private readonly bool _oplWasEnabled; + + public PrivateChildPropertiesTests() + { + _oplWasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + } + + public void Dispose() => ObjectPropertyList.Enabled = _oplWasEnabled; + private class PublicContainer : Container { public PublicContainer() : base(0xE75) @@ -21,35 +30,6 @@ public class PrivateChildPropertiesTests private static readonly Point3D _ownerLoc = new(1600, 1600, 0); - private static (NetState, Mobile) CreateClient(Point3D location) - { - var ns = PacketTestUtilities.CreateTestNetState(); - ns.Account = new MockAccount(); - - var mobile = new Mobile(World.NewMobile); - mobile.DefaultMobileInit(); - ns.Mobile = mobile; - mobile.NetState = ns; - mobile.MoveToWorld(location, Map.Felucca); - return (ns, mobile); - } - - private static void DisposeClient(NetState ns, Mobile mobile) - { - ns.Mobile = null; - ns.Dispose(); - mobile.Delete(); - } - - private static bool ReceivedOplInfo(NetState ns, Serial serial) - { - Span expected = stackalloc byte[5]; - var writer = new SpanWriter(expected); - writer.Write((byte)0xDC); // OPL info packet ID - writer.Write(serial); - return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; - } - // AddItem/MoveToWorld already queue their own Update (+Properties) delta as a side effect of the // Map setter. Draining it first isolates the properties-only delta the test actually exercises; // otherwise the leftover Update flag routes through the Update branch and the test would pass or @@ -68,9 +48,6 @@ public class PrivateChildPropertiesTests [Fact] public void PrivateChildInOwnerBackpack_PropertiesOnlyUpdate_ReachesOwnerNotBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (ownerNs, owner) = CreateClient(_ownerLoc); var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); @@ -93,16 +70,12 @@ public class PrivateChildPropertiesTests child.Delete(); DisposeClient(ownerNs, owner); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void PrivateGroundContainer_PropertiesOnlyUpdate_ReachesOpenerNotNonOpener() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (openerNs, opener) = CreateClient(_ownerLoc); var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 2, _ownerLoc.Y, 0)); @@ -126,16 +99,12 @@ public class PrivateChildPropertiesTests container.Delete(); DisposeClient(openerNs, opener); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void PublicContainerChild_PropertiesOnlyUpdate_StillReachesBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (bystanderNs, bystander) = CreateClient(_ownerLoc); var pack = new PublicContainer(); @@ -155,16 +124,12 @@ public class PrivateChildPropertiesTests { pack.Delete(); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } [Fact] public void GroundItem_PropertiesOnlyUpdate_StillReachesBystander() { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - var (bystanderNs, bystander) = CreateClient(_ownerLoc); var item = new Item(0x1234); @@ -181,7 +146,6 @@ public class PrivateChildPropertiesTests { item.Delete(); DisposeClient(bystanderNs, bystander); - ObjectPropertyList.Enabled = wasEnabled; } } } diff --git a/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs b/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs index a240a0576..c4c48b41b 100644 --- a/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs +++ b/Projects/Server.Tests/Tests/Localization/LocalizationEntryTests.cs @@ -1,18 +1,28 @@ +using System; using Xunit; namespace Server.Tests; [Collection("Sequential Server Tests")] -public class LocalizationEntryTests +public class LocalizationEntryTests : IDisposable { + private const int FormatNumber = 500002; + private const int ParameterNumber = 500003; + + public void Dispose() + { + Localization.Remove("enu", FormatNumber); + Localization.Remove("enu", ParameterNumber); + } + [Fact] public void TestClilocAsParameter() { - Localization.Add("enu", 500002, "This tests ~1_NUMBER~ as parameters."); - Localization.Add("enu", 500003, "clilocs"); + Localization.Add("enu", FormatNumber, "This tests ~1_NUMBER~ as parameters."); + Localization.Add("enu", ParameterNumber, "clilocs"); - string numericFormatter = Localization.Format(500002, "enu", $"{500003:#}"); - string stringParam = Localization.Format(500002, "enu", $"{"#500003"}"); + string numericFormatter = Localization.Format(FormatNumber, "enu", $"{ParameterNumber:#}"); + string stringParam = Localization.Format(FormatNumber, "enu", $"{"#500003"}"); Assert.Equal("This tests clilocs as parameters.", numericFormatter); Assert.Equal("This tests clilocs as parameters.", stringParam); diff --git a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs index 684c77743..5531e0530 100644 --- a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs @@ -33,6 +33,8 @@ public class CanFitItemTests Assert.False(map.CanFitItem(100, map.Height + 1, 0, 1)); } + // Plain walkable land in the real Felucca map. Without map files every cell is the default land + // tile at Z 0 with no flags, which these tests treat the same way: a surface with nothing above it. private const int TestLandX = 1500; private const int TestLandY = 1600; @@ -170,23 +172,19 @@ public class CanFitItemTests Item movableItem = null; try { - // Create a movable surface item + // Raised well clear of the land so the movable item's top is the only candidate surface there + var itemZ = map.GetAverageZ(x, y) + 20; + movableItem = new Item(ServerFixture.SurfaceTileId) { Movable = true }; - movableItem.MoveToWorld(new Point3D(x, y, 10), map); + movableItem.MoveToWorld(new Point3D(x, y, itemZ), map); - var itemTop = 10 + movableItem.ItemData.CalcHeight; - // Try placing at item top - should fail unless there's land there too - var avgZ = map.GetAverageZ(x, y); + var itemTop = itemZ + movableItem.ItemData.CalcHeight; + var result = map.CanFitItem(x, y, itemTop, 1); - // If avgZ != itemTop, there's no other surface, so it should fail - if (avgZ != itemTop) - { - var result = map.CanFitItem(x, y, itemTop, 1); - Assert.False(result, "Movable item should not count as valid surface"); - } + Assert.False(result, "Movable item should not count as valid surface"); } finally { @@ -248,16 +246,4 @@ public class CanFitItemTests multi.MoveToWorld(location, map); return multi; } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(MultiComponentList components) : base(0x1) - { - _components = components ?? MultiComponentList.Empty; - } - - public override MultiComponentList Components => _components; - } } diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs index c90cce9ea..23bc9d173 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs @@ -29,7 +29,8 @@ public class CanSpawnMobileTests Assert.False(map.CanSpawnMobile(100, map.Height + 1, -128, 127, false, false, out _)); } - // Use coordinates near Britain which should be walkable land in both test and real data + // Plain walkable land near Britain in the real Felucca map. Without map files every cell is the + // default land tile at Z 0 with no flags, which CanSpawnMobile treats the same way: walkable, not water. private const int TestLandX = 1500; private const int TestLandY = 1600; @@ -37,10 +38,9 @@ public class CanSpawnMobileTests public void CanSpawnMobile_EmptyLocationWithLandReturnsTrue() { var map = Map.Felucca; - map.GetAverageZ(TestLandX, TestLandY, out _, out var landZ, out _); // Test with full Z range to find land - var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, false, out _); // Should find a valid spawn point (land surface) Assert.True(result, $"Expected to find valid spawn point on land at ({TestLandX}, {TestLandY})"); @@ -53,7 +53,7 @@ public class CanSpawnMobileTests map.GetAverageZ(TestLandX, TestLandY, out _, out var avgZ, out _); // Use a Z range that excludes the land surface (50+ units above land) - var result = map.CanSpawnMobile(TestLandX, TestLandY, avgZ + 50, avgZ + 100, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, avgZ + 50, avgZ + 100, false, false, out _); Assert.False(result, $"Expected no spawn point above land at Z={avgZ}"); } @@ -63,7 +63,7 @@ public class CanSpawnMobileTests { var map = Map.Felucca; // cantWalk=true means the mob can only swim, so land shouldn't be valid - var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, true, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, -128, 127, false, true, out _); Assert.False(result, "cantWalk=true should not find land as valid surface"); } @@ -83,7 +83,7 @@ public class CanSpawnMobileTests blockingMobile.MoveToWorld(new Point3D(TestLandX, TestLandY, landZ), map); // The mobile should block spawning at its location and Z - var result = map.CanSpawnMobile(TestLandX, TestLandY, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(TestLandX, TestLandY, landZ - 16, landZ + 16, false, false, out _); // Land surface is blocked by mobile at the same Z Assert.False(result, "Mobile should block spawn at same location"); @@ -115,7 +115,7 @@ public class CanSpawnMobileTests gm.MoveToWorld(new Point3D(x, y, landZ), map); // Hidden GM should not block spawning - var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out _); Assert.True(result, "Hidden GM should not block spawn"); } @@ -142,7 +142,7 @@ public class CanSpawnMobileTests blockingMobile.MoveToWorld(new Point3D(x, y, landZ + 50), map); // Land should still be valid (mobile above doesn't block ground level) - var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, landZ - 16, landZ + 16, false, false, out _); Assert.True(result, "Mobile at different Z should not block"); } diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs index ea12926f1..38d757463 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs @@ -11,13 +11,6 @@ namespace Server.Tests.Maps; [Collection("Sequential Server Tests")] public class CanSpawnMobileTileDataTests { - private readonly ServerFixture _fixture; - - public CanSpawnMobileTileDataTests(ServerFixture fixture) - { - _fixture = fixture; - } - private void SkipIfNoTileData() { Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); @@ -96,7 +89,7 @@ public class CanSpawnMobileTileDataTests multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 28); // The low ceiling should block spawning (need 16 units clearance for mobiles) - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.False(result); } @@ -121,7 +114,7 @@ public class CanSpawnMobileTileDataTests // Create a multi with floor at Z=20 and ceiling at Z=50 (30 units clearance) multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 50); - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.True(result); } @@ -147,7 +140,7 @@ public class CanSpawnMobileTileDataTests multi = CreateFloorWithCeilingMulti(map, new Point3D(x, y, 0), floorZ: 20, ceilingZ: 100); // The blocker at Z=100 shouldn't affect spawning in range 15-35 - var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 15, 35, false, false, out _); Assert.True(result); } @@ -173,7 +166,7 @@ public class CanSpawnMobileTileDataTests multi = CreateWaterMulti(map, new Point3D(x, y, 0), waterZ: 0); // canSwim=true should find the water surface - var result = map.CanSpawnMobile(x, y, -10, 10, true, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, -10, 10, true, false, out _); Assert.True(result); } @@ -202,7 +195,7 @@ public class CanSpawnMobileTileDataTests }; surfaceItem.MoveToWorld(new Point3D(x, y, 10), map); - var result = map.CanSpawnMobile(x, y, 5, 30, false, false, out var spawnZ); + var result = map.CanSpawnMobile(x, y, 5, 30, false, false, out _); // Should find either land at Z=0 or the item surface, whichever is lowest Assert.True(result); @@ -274,16 +267,4 @@ public class CanSpawnMobileTileDataTests new MultiTileEntry(ServerFixture.WetTileId, 0, 0, (short)waterZ, TileFlag.Wet) ]); } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(MultiComponentList components) : base(0x1) - { - _components = components ?? MultiComponentList.Empty; - } - - public override MultiComponentList Components => _components; - } } diff --git a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs index c5ed6c517..718fadefc 100644 --- a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs @@ -313,7 +313,7 @@ public class MultiEnumeratorTests var map = Map.Felucca; // Components reach two tiles either side, so at x = 1024 the multi spans sectors 63 and 64 - var spanning = new SpanningMulti(); + var spanning = new TestMulti(SpanningComponents); try { spanning.MoveToWorld(new Point3D(1024, 1000, 0), map); @@ -351,29 +351,11 @@ public class MultiEnumeratorTests } } - // Test implementation of BaseMulti - private class TestMulti : BaseMulti - { - public TestMulti() : base(0x1) - { - } - } - - private class SpanningMulti : BaseMulti - { - // Entries after the first need non-zero flags or MultiComponentList drops them - private static readonly MultiComponentList _components = new( - [ - new MultiTileEntry(0x1, -2, 0, 0, TileFlag.Background), - new MultiTileEntry(0x1, 2, 0, 0, TileFlag.Background) - ] - ); - - public SpanningMulti() : base(0x1) - { - } - - public override MultiComponentList Components => _components; - } + // Entries after the first need non-zero flags or MultiComponentList drops them + private static readonly MultiComponentList SpanningComponents = new( + [ + new MultiTileEntry(0x1, -2, 0, 0, TileFlag.Background), + new MultiTileEntry(0x1, 2, 0, 0, TileFlag.Background) + ] + ); } - diff --git a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs index 07b8e37d3..9c6e01a36 100644 --- a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs @@ -13,21 +13,17 @@ public class StaticTileEnumeratorTests var map = Map.Felucca; var location = new Point2D(200, 200); + // Real map files may have statics here; without them there are none. Either way the multi adds nothing. + var mapStatics = CollectTiles(map, location, includeStatics: true, includeMultis: false); + TestMulti multi = null; try { - // Create a multi at the location multi = CreateMultiWithComponents(map, new Point3D(200, 200, 0)); - // Get tiles with statics only (no multis) - var tiles = new List(); - foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: true, includeMultis: false)) - { - tiles.Add(tile); - } + var tiles = CollectTiles(map, location, includeStatics: true, includeMultis: false); - // Should not include multi tiles - Assert.Empty(tiles); + Assert.Equal(mapStatics, tiles); } finally { @@ -69,21 +65,20 @@ public class StaticTileEnumeratorTests var map = Map.Felucca; var location = new Point2D(400, 400); + var mapStatics = CollectTiles(map, location, includeStatics: true, includeMultis: false); + TestMulti multi = null; try { - // Create a multi at the location multi = CreateMultiWithComponents(map, new Point3D(400, 400, 0)); - // Get all tiles (statics and multis) - var tiles = new List(); - foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: true, includeMultis: true)) - { - tiles.Add(tile); - } + var multiTiles = CollectTiles(map, location, includeStatics: false, includeMultis: true); + var tiles = CollectTiles(map, location, includeStatics: true, includeMultis: true); - // Should include multi tiles (statics would be empty without map files) - Assert.NotEmpty(tiles); + Assert.NotEmpty(multiTiles); + Assert.Equal(mapStatics.Count + multiTiles.Count, tiles.Count); + Assert.All(mapStatics, tile => Assert.Contains(tile, tiles)); + Assert.All(multiTiles, tile => Assert.Contains(tile, tiles)); } finally { @@ -257,10 +252,10 @@ public class StaticTileEnumeratorTests { // Outer's bounds cover the probe cell but it has no tile there; placing it first puts it // ahead of inner in the sector's multi list - outer = new TestMulti(World.NewItem, GappedComponents); + outer = new TestMulti(GappedComponents); outer.MoveToWorld(new Point3D(1100, 1100, 0), map); - inner = new TestMulti(World.NewItem, SingleTileComponents); + inner = new TestMulti(SingleTileComponents); inner.MoveToWorld(new Point3D(location.X, location.Y, 20), map); var tiles = new List(); @@ -294,28 +289,28 @@ public class StaticTileEnumeratorTests ] ); + private static readonly MultiComponentList DefaultComponents = new( + [ + new MultiTileEntry(0x1, 0, 0, 0, 0x0), + new MultiTileEntry(0x2, 1, 0, 0, 0x0) + ] + ); + + private static List CollectTiles(Map map, Point2D location, bool includeStatics, bool includeMultis) + { + var tiles = new List(); + foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics, includeMultis)) + { + tiles.Add(tile); + } + + return tiles; + } + private static TestMulti CreateMultiWithComponents(Map map, Point3D location) { - var multi = new TestMulti(World.NewItem); + var multi = new TestMulti(DefaultComponents); multi.MoveToWorld(location, map); return multi; } - - private class TestMulti : BaseMulti - { - private readonly MultiComponentList _components; - - public TestMulti(Serial serial, MultiComponentList components = null) : base(serial) => - _components = components ?? DefaultComponents; - - public override MultiComponentList Components => _components; - - private static readonly MultiComponentList DefaultComponents = new( - [ - new MultiTileEntry(0x1, 0, 0, 0, 0x0), - new MultiTileEntry(0x2, 1, 0, 0, 0x0) - ] - ); - } } - diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs index 5eb1895d8..931947a55 100644 --- a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs +++ b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs @@ -7,8 +7,18 @@ using Xunit; namespace Server.Tests.Network.Bans; -public class BanChannelTests +[Collection("Sequential Server Tests")] +public class BanChannelTests : IDisposable { + private readonly IBanReporter[] _previousReporters = [..BanChannel.Reporters]; + private readonly Func _previousIsExempt = BanChannel.IsExempt; + + public void Dispose() + { + BanChannel.ConfigureForTesting(_previousReporters); + BanChannel.IsExempt = _previousIsExempt; + } + private sealed class FakeReporter : IBanReporter { public readonly List<(IPAddress ip, TimeSpan ttl, string reason)> Reports = []; @@ -69,18 +79,11 @@ public class BanChannelTests var exempt = IPAddress.Parse("203.0.113.7"); BanChannel.IsExempt = (ip, _) => ip.Equals(exempt); - try - { - BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); - Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted + BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); + Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted - BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); - Assert.Single(reporter.Reports); // everyone else is still contributed - } - finally - { - BanChannel.IsExempt = null; - } + BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); + Assert.Single(reporter.Reports); // everyone else is still contributed } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs index 8644993ab..4e9c82ff5 100644 --- a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs +++ b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs @@ -4,89 +4,20 @@ using Xunit; namespace Server.Tests.Network; /// -/// Manual tests for MovementThrottle latency scenarios. -/// These tests simulate specific timing scenarios and should NOT run in CI/CD. -/// -/// To run these tests manually: -/// dotnet test --filter "Category=Manual" -/// -/// To exclude from CI/CD, add to your test command: -/// dotnet test --filter "Category!=Manual" +/// MovementThrottle burst forgiveness against a full, wrapped movement history. /// [Collection("Sequential Server Tests")] -[Trait("Category", "Manual")] public class MovementThrottleLatencyTests { - /// - /// Sets the simulated tick count for testing. - /// - private static void SetTickCount(long ticks) - { - Core._tickCount = ticks; - } - - /// - /// Simulates recording movements at specific intervals. - /// - private static void SimulateMovements(NetState ns, params (int delayMs, int costMs)[] movements) - { - ns._movementHistory ??= new NetState.MovementRecord[20]; - ns._movementHistoryIndex = 0; - ns._movementHistoryFull = false; - ns._lastMovementRecordTime = 0; - - var currentTime = 0L; - - foreach (var (delayMs, costMs) in movements) - { - currentTime += delayMs; - SetTickCount(currentTime); - - if (ns._lastMovementRecordTime > 0) - { - var interval = (int)(currentTime - ns._lastMovementRecordTime); - - // Skip chain-breaking gaps (> 2000ms) like the real code does - if (interval > 2000) - { - ns._lastGapDuration = interval; - ns._lastMovementRecordTime = currentTime; - continue; - } - - ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord - { - Interval = (short)interval, - TargetSpeed = (ushort)costMs, - QueueDepth = 0, - Flags = 0 - }; - - ns._movementHistoryIndex++; - if (ns._movementHistoryIndex >= 20) - { - ns._movementHistoryIndex = 0; - ns._movementHistoryFull = true; - } - } - - ns._lastMovementRecordTime = currentTime; - } - } - - /// - /// Scenario: Verifies that burst forgiveness triggers correctly and clears gap duration. - /// [Fact] - public void MultipleLagSpikes_NoAccumulatedSuspicion() + public void AnalyzeMovement_BurstAfterGap_WrappedFullHistory_ForgivesAndClearsGap() { var ns = PacketTestUtilities.CreateTestNetState(); - SetTickCount(0); - // Set up history buffer with mostly normal movements and a burst at the end + // The production recorder is private and only reachable through a full movement request, so + // the history is laid out directly: 17 normal steps, then a 3-step burst ending at the wrap ns._movementHistory = new NetState.MovementRecord[20]; - // Fill with 17 normal movements (100ms intervals) for (var i = 0; i < 17; i++) { ns._movementHistory[i] = new NetState.MovementRecord @@ -98,7 +29,6 @@ public class MovementThrottleLatencyTests }; } - // Add 3 burst movements at the end (10ms intervals = burst) for (var i = 17; i < 20; i++) { ns._movementHistory[i] = new NetState.MovementRecord @@ -110,26 +40,19 @@ public class MovementThrottleLatencyTests }; } - ns._movementHistoryIndex = 0; // Next write position (wrapped) - ns._movementHistoryFull = true; // Buffer is full + ns._movementHistoryIndex = 0; + ns._movementHistoryFull = true; - // Set gap duration (simulating a lag spike before the burst) + // A lag spike longer than the chain gap preceded the burst ns._lastGapDuration = 2500; - // Analyze - burst forgiveness should trigger - var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var samples, out var confidence); + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); - // Key assertions: - // 1. Gap duration should be cleared (burst forgiveness triggered) Assert.Equal(0, ns._lastGapDuration); - - // 2. Confidence should be reduced by 0.3x multiplier Assert.True( confidence < 0.5f, $"Burst after gap should have low confidence. Confidence={confidence}, Rate={rate}" ); - - // 3. Should NOT be Definite despite the burst (forgiveness applied) Assert.True( verdict != MovementThrottle.DetectionVerdict.Definite, $"Should not be Definite after burst forgiveness. Verdict={verdict}" diff --git a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs index d1f273db3..fafb15d8a 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateDisconnectTests.cs @@ -1,6 +1,4 @@ using System; -using System.Diagnostics; -using System.Net.Sockets; using Server.Network; using Xunit; @@ -96,35 +94,10 @@ public class NetStateDisconnectTests Assert.True(ns._socket.DisconnectPending); - var received = new byte[payload.Length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - - while (total < payload.Length && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, payload.Length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(payload, received); + Assert.Equal(payload, NetTestHelpers.ReadAll(client, payload.Length, 5000)); // FIN follows once the send completes - var eof = -1; - while (eof != 0 && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - eof = client.Receive(received); - } - } - - Assert.Equal(0, eof); + NetTestHelpers.WaitForEof(client, 5000); } finally { @@ -156,35 +129,8 @@ public class NetStateDisconnectTests Assert.True(ns._socket.DisconnectPending); - var received = new byte[payload.Length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - - while (total < payload.Length && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, payload.Length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(payload.Length, total); - Assert.Equal(payload, received); - - var eof = -1; - while (eof != 0 && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - if (client.Poll(1000, SelectMode.SelectRead)) - { - eof = client.Receive(received); - } - } - - Assert.Equal(0, eof); + Assert.Equal(payload, NetTestHelpers.ReadAll(client, payload.Length)); + NetTestHelpers.WaitForEof(client); } finally { diff --git a/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs b/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs index 548406ec4..8d596d31b 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateRecvGuardTests.cs @@ -1,8 +1,4 @@ -using System; using System.Buffers; -using System.Diagnostics; -using System.Network; -using System.Threading; using Server.Network; using Xunit; @@ -12,71 +8,33 @@ namespace Server.Tests.Network; public class NetStateRecvGuardTests { private const byte TestPacketId = 0xB1; + private const byte AttachAccountPacketId = 0x71; - private static unsafe void RegisterVariableLength() - { - if (IncomingPackets.GetHandler(TestPacketId) == null) - { - IncomingPackets.Register(TestPacketId, 0, false, &NoOp); - } - } + private static unsafe NetTestHelpers.HandlerRegistration RegisterVariableLength() => + NetTestHelpers.RegisterHandler(TestPacketId, 0, &NoOp); private static void NoOp(NetState state, SpanReader reader) { } - // Not 0x73: NetStateSendBufferTests registers its own no-op there first-come-wins, since the - // handler table is process-global, and this test needs its own handler's side effect to fire. - private const byte TestPingPacketId = 0x72; - - private static byte _lastPing; - - private static unsafe void RegisterNoOpPing() - { - if (IncomingPackets.GetHandler(TestPingPacketId) == null) - { - IncomingPackets.Register(TestPingPacketId, 2, false, &RecordPing); - } - } - - private static void RecordPing(NetState state, SpanReader reader) => _lastPing = reader.ReadByte(); - - private const byte AttachAccountPacketId = 0x71; - - private static unsafe void RegisterAttachAccount() - { - if (IncomingPackets.GetHandler(AttachAccountPacketId) == null) - { - IncomingPackets.Register(AttachAccountPacketId, 3, false, &AttachAccount); - } - } + private static unsafe NetTestHelpers.HandlerRegistration RegisterAttachAccount() => + NetTestHelpers.RegisterHandler(AttachAccountPacketId, 3, &AttachAccount); // Stands in for the 0x91 handler: the account attaches mid-parse, so the promotion is pending // with a receive armed when the next packet in the same completion reaches the guard private static void AttachAccount(NetState state, SpanReader reader) => state.Account = new MockAccount(); - private static void SliceUntil(Func done) - { - var deadline = Stopwatch.StartNew(); - while (!done() && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - Thread.Sleep(5); - } - - Assert.True(done()); - } - [SkippableFact] public void HandleReceive_PacketLongerThanTheRecvBuffer_IsAnError() { - RegisterVariableLength(); + // A 16-bit length cannot exceed the 64 KiB base buffer, so the guard is only reachable + // through a pre-auth buffer + NetTestHelpers.RequirePreAuthBuffers(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { - // A 16-bit length cannot exceed a 64 KiB buffer, so the guard is unreachable there and - // this only ever hit the `< 3` check on a buffer that size. - Skip.If(ns._socket.RecvBuffer.PhysicalSize > ushort.MaxValue); + Assert.True(ns._socket.RecvBuffer.PhysicalSize <= ushort.MaxValue); ns._protocolState = NetState.ProtocolState.GameServer_LoggedIn; @@ -84,7 +42,7 @@ public class NetStateRecvGuardTests // park the connection with nothing to arm a recv into var declared = ns._socket.RecvBuffer.PhysicalSize; // one more than the buffer can ever hold client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._protocolState == NetState.ProtocolState.Error); + NetTestHelpers.SliceUntil(() => ns._protocolState == NetState.ProtocolState.Error); Assert.Contains("bad state", ns._disconnectReason); } @@ -98,7 +56,7 @@ public class NetStateRecvGuardTests [Fact] public void HandleReceive_PacketThatFits_WaitsForTheRest() { - RegisterVariableLength(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -106,7 +64,7 @@ public class NetStateRecvGuardTests var declared = ns._socket.RecvBuffer.PhysicalSize / 2; client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); Assert.True(ns.Running); @@ -122,8 +80,8 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_OversizePacket_WithAnAccount_RetriesPromotionInsteadOfErroring() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterVariableLength(); + NetTestHelpers.RequirePreAuthBuffers(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -135,7 +93,7 @@ public class NetStateRecvGuardTests var declared = NetState.InitialRecvBufferSize; // as much as the initial buffer can ever hold client.Send(new byte[] { TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 3); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); Assert.True(ns.Running); @@ -143,7 +101,7 @@ public class NetStateRecvGuardTests // The deferred promotion applies at this next completion; the header is then delivered // whole to the 0xB1 no-op handler client.Send(new byte[declared - 3]); - SliceUntil( + NetTestHelpers.SliceUntil( () => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize ); @@ -161,8 +119,8 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_AfterABurstFillsTheInitialBuffer_KeepsReceiving() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterNoOpPing(); + NetTestHelpers.RequirePreAuthBuffers(); + using var ping = NetTestHelpers.RegisterPing(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -174,17 +132,17 @@ public class NetStateRecvGuardTests var pings = new byte[capacity + 1]; for (var i = 0; i < pings.Length; i += 2) { - pings[i] = TestPingPacketId; + pings[i] = NetTestHelpers.PingPacketId; pings[i + 1] = (byte)(i / 2); } client.Send(pings); - SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._receivedData); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._receivedData); Assert.True(ns.Running); // Without a re-arm this ping never arrives - client.Send(new byte[] { TestPingPacketId, 0xEE }); - SliceUntil(() => _lastPing == 0xEE); + client.Send(new byte[] { NetTestHelpers.PingPacketId, 0xEE }); + NetTestHelpers.SliceUntil(() => NetTestHelpers.LastPing == 0xEE); Assert.True(ns.Running); } finally @@ -197,9 +155,9 @@ public class NetStateRecvGuardTests [SkippableFact] public void HandleReceive_AccountAttachedMidParse_ThenOversizeHeader_WaitsForThePendingPromotion() { - Skip.If(NetState.InitialRecvBufferSize == 0); - RegisterAttachAccount(); - RegisterVariableLength(); + NetTestHelpers.RequirePreAuthBuffers(); + using var attach = RegisterAttachAccount(); + using var handler = RegisterVariableLength(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -212,7 +170,7 @@ public class NetStateRecvGuardTests // header then hits the guard after the promotion already applied and simply waits on the // 64 KiB buffer, so the test cannot flake, it just exercises the weaker path on that run. client.Send(new byte[] { AttachAccountPacketId, 0x00, 0x00, TestPacketId, (byte)(declared >> 8), (byte)declared }); - SliceUntil(() => ns.Account != null); + NetTestHelpers.SliceUntil(() => ns.Account != null); Assert.True(ns.Running); Assert.Equal(NetState.ProtocolState.GameServer_LoggedIn, ns._protocolState); @@ -220,7 +178,7 @@ public class NetStateRecvGuardTests // The next completion applies the promotion and the whole packet is delivered client.Send(new byte[declared - 3]); - SliceUntil( + NetTestHelpers.SliceUntil( () => ns._socket.RecvBuffer.ReadableBytes == 0 && ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize ); diff --git a/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs b/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs index 6f5a22d04..c92260070 100644 --- a/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs +++ b/Projects/Server.Tests/Tests/Network/NetStateSendBufferTests.cs @@ -1,10 +1,8 @@ using System; using System.Buffers; using System.Collections.Generic; -using System.Diagnostics; using System.Net.Sockets; using System.Network; -using System.Threading; using Server.Network; using Xunit; @@ -22,42 +20,11 @@ public class NetStateSendBufferTests return ns; } - private static byte[] Pattern(int length, int seed) - { - var data = new byte[length]; - new System.Random(seed).NextBytes(data); - return data; - } - - private static unsafe void RegisterNoOpPing() - { - if (IncomingPackets.GetHandler(0x73) == null) - { - IncomingPackets.Register(0x73, 2, false, &NoOp); - } - } - - private static void NoOp(NetState state, SpanReader reader) - { - } - - private static void SliceUntil(Func done) - { - var deadline = Stopwatch.StartNew(); - while (!done() && deadline.ElapsedMilliseconds < 5000) - { - NetState.Slice(); - Thread.Sleep(5); - } - - Assert.True(done()); - } - [SkippableFact] public void Account_InTheGameServerState_PromotesBothBuffers() { - Skip.If(NetState.InitialSendBufferSize == 0); - RegisterNoOpPing(); + NetTestHelpers.RequirePreAuthBuffers(); + using var ping = NetTestHelpers.RegisterPing(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -73,8 +40,8 @@ public class NetStateSendBufferTests // Recv promotes at the next completion ns._protocolState = NetState.ProtocolState.GameServer_LoggedIn; - client.Send(new byte[] { 0x73, 0x01 }); - SliceUntil(() => ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize); + client.Send(new byte[] { NetTestHelpers.PingPacketId, 0x01 }); + NetTestHelpers.SliceUntil(() => ns._socket.RecvBuffer.PhysicalSize == NetState.RecvBufferSize); Assert.True(ns.Running); } finally @@ -87,7 +54,7 @@ public class NetStateSendBufferTests [SkippableFact] public void Account_OnTheLoginServer_KeepsTheInitialBuffers() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -108,11 +75,11 @@ public class NetStateSendBufferTests public void Send_BeyondTheInitialBuffer_BeforeCredentials_IsExhausted() { // Nothing promotes before credentials verify: the 4 KiB ring is the whole pre-auth budget - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { - var data = Pattern(NetState.InitialSendBufferSize + 512, 7); + var data = NetTestHelpers.Pattern(NetState.InitialSendBufferSize + 512, 7); ns.Send(data); Assert.Contains("exhausted", ns._disconnectReason, StringComparison.OrdinalIgnoreCase); @@ -123,7 +90,7 @@ public class NetStateSendBufferTests // Once it completes the NetState is disposed and _socket goes null, so nothing below // this point may touch it. client.Close(); - SliceUntil(() => !ns.Running); + NetTestHelpers.SliceUntil(() => !ns.Running); } finally { @@ -137,19 +104,19 @@ public class NetStateSendBufferTests { // The late-verdict path from Account_AssignedAfterTheStateFlipped_StillPromotes: an account // attached while the setter's own gate does not fire must still let the send path promote - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { ns.Account = new MockAccount(); // _protocolState is still AwaitingSeed: the setter does not promote - var data = Pattern(NetState.InitialSendBufferSize + 512, 7); + var data = NetTestHelpers.Pattern(NetState.InitialSendBufferSize + 512, 7); ns.Send(data); Assert.True(ns.Running); Assert.Equal(string.Empty, ns._disconnectReason); Assert.Equal(NetState.SendBufferSize, ns._socket.SendBuffer.PhysicalSize); Assert.False(ns._sendBufferGrown); // promotion is not growth: nothing to shrink later - Assert.Equal(data, ReadAll(client, data.Length)); + Assert.Equal(data, NetTestHelpers.ReadAll(client, data.Length)); } finally { @@ -161,12 +128,12 @@ public class NetStateSendBufferTests [SkippableFact] public void Send_BacklogBeforeCredentials_IsExhaustedInsteadOfPromoted() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { // No Slice() between sends: nothing drains, so a second write finds a non-empty initial buffer - var half = Pattern(NetState.InitialSendBufferSize / 2, 11); + var half = NetTestHelpers.Pattern(NetState.InitialSendBufferSize / 2, 11); ns.Send(half); ns.Send(half); ns.Send(half); // cannot fit, buffer not empty, no account: refused @@ -179,7 +146,7 @@ public class NetStateSendBufferTests // Once it completes the NetState is disposed and _socket goes null, so nothing below // this point may touch it. client.Close(); - SliceUntil(() => !ns.Running); + NetTestHelpers.SliceUntil(() => !ns.Running); } finally { @@ -191,7 +158,7 @@ public class NetStateSendBufferTests [SkippableFact] public void Account_AssignedAfterTheStateFlipped_StillPromotes() { - Skip.If(NetState.InitialSendBufferSize == 0); + NetTestHelpers.RequirePreAuthBuffers(); var ns = PacketTestUtilities.CreateTestNetState(out var client); try { @@ -211,7 +178,7 @@ public class NetStateSendBufferTests { var randomLength = compressedLength * 5 / 8; var data = new byte[compressedLength * 8]; - Pattern(randomLength, seed).CopyTo(data, 0); + NetTestHelpers.Pattern(randomLength, seed).CopyTo(data, 0); var scratch = new byte[compressedLength * 2 + 16]; for (var length = randomLength; length < data.Length; length++) @@ -228,47 +195,12 @@ public class NetStateSendBufferTests throw new InvalidOperationException($"No chunk compresses to exactly {compressedLength} bytes"); } - private static byte[] ReadAll(Socket client, int length) - { - var received = new byte[length]; - var total = 0; - var deadline = Stopwatch.StartNew(); - while (total < length && deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - - // Poll takes microseconds, not milliseconds - if (client.Poll(50_000, SelectMode.SelectRead)) - { - var read = client.Receive(received, total, length - total, SocketFlags.None); - Assert.NotEqual(0, read); - total += read; - } - } - - Assert.Equal(length, total); - return received; - } - // Peer receipt does not free the buffer; the completion must land first - private static void WaitForDrain(NetState ns) - { - var deadline = Stopwatch.StartNew(); - while (deadline.ElapsedMilliseconds < 10000) - { - NetState.Slice(); - - if (ns._socket.SendBuffer.ReadableBytes == 0 && ns._socket.SendBuffer.InFlightBytes == 0) - { - break; - } - - Thread.Sleep(5); - } - - Assert.Equal(0, ns._socket.SendBuffer.ReadableBytes); - Assert.Equal(0, ns._socket.SendBuffer.InFlightBytes); - } + private static void WaitForDrain(NetState ns) => + NetTestHelpers.SliceUntil( + () => ns._socket.SendBuffer.ReadableBytes == 0 && ns._socket.SendBuffer.InFlightBytes == 0, + 10000 + ); [Fact] public void Send_GrowsInsteadOfDisconnecting_AndStreamStaysIntact() @@ -283,7 +215,7 @@ public class NetStateSendBufferTests var expected = new byte[chunk * 6]; for (var i = 0; i < 6; i++) { - var data = Pattern(chunk, i); + var data = NetTestHelpers.Pattern(chunk, i); data.CopyTo(expected, i * chunk); ns.Send(data); } @@ -293,7 +225,7 @@ public class NetStateSendBufferTests Assert.True(ns._socket.SendBuffer.PhysicalSize > baseSize); Assert.True(ns._sendBufferGrown); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -318,7 +250,7 @@ public class NetStateSendBufferTests var expectedLength = 0; for (var i = 0; i < 6; i++) { - var data = Pattern(chunkLength, i + 100); + var data = NetTestHelpers.Pattern(chunkLength, i + 100); var compressedLength = NetworkCompression.Compress(data, scratch); Assert.True(compressedLength > 0); scratch.AsSpan(0, compressedLength).CopyTo(expected.AsSpan(expectedLength)); @@ -332,7 +264,7 @@ public class NetStateSendBufferTests Assert.True(ns._sendBufferGrown); Array.Resize(ref expected, expectedLength); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -365,7 +297,7 @@ public class NetStateSendBufferTests while (ns._socket.SendBuffer.WritableBytes > 4096) { var writable = ns._socket.SendBuffer.WritableBytes; - SendAndRecord(Pattern(Math.Min(baseSize / 8, (writable - 2048) * 8 / 11), seed++)); + SendAndRecord(NetTestHelpers.Pattern(Math.Min(baseSize / 8, (writable - 2048) * 8 / 11), seed++)); } // Full; no span to compress into @@ -381,7 +313,7 @@ public class NetStateSendBufferTests Assert.Equal(baseSize * 2, ns._socket.SendBuffer.PhysicalSize); Assert.True(ns._sendBufferGrown); - Assert.Equal(expected.ToArray(), ReadAll(client, expected.Count)); + Assert.Equal(expected.ToArray(), NetTestHelpers.ReadAll(client, expected.Count)); } finally { @@ -401,8 +333,8 @@ public class NetStateSendBufferTests // A small peer buffer keeps the first send in flight through the growth client.ReceiveBufferSize = 4096; - var first = Pattern(baseSize / 2, 300); - var second = Pattern(baseSize / 2 + 1, 301); + var first = NetTestHelpers.Pattern(baseSize / 2, 300); + var second = NetTestHelpers.Pattern(baseSize / 2 + 1, 301); ns.Send(first); NetState.Slice(); // posts the first send @@ -419,7 +351,7 @@ public class NetStateSendBufferTests first.CopyTo(expected, 0); second.CopyTo(expected, first.Length); - Assert.Equal(expected, ReadAll(client, expected.Length)); + Assert.Equal(expected, NetTestHelpers.ReadAll(client, expected.Length)); } finally { @@ -439,11 +371,11 @@ public class NetStateSendBufferTests var chunk = baseSize / 4; for (var i = 0; i < 6; i++) { - ns.Send(Pattern(chunk, i)); + ns.Send(NetTestHelpers.Pattern(chunk, i)); } Assert.True(ns._sendBufferGrown); - ReadAll(client, chunk * 6); + NetTestHelpers.ReadAll(client, chunk * 6); WaitForDrain(ns); var grewAt = ns._sendBufferGrewAt; @@ -471,11 +403,11 @@ public class NetStateSendBufferTests var chunk = baseSize / 4; for (var i = 0; i < 6; i++) { - ns.Send(Pattern(chunk, i + 400)); + ns.Send(NetTestHelpers.Pattern(chunk, i + 400)); } Assert.True(ns._sendBufferGrown); - ReadAll(client, chunk * 6); + NetTestHelpers.ReadAll(client, chunk * 6); WaitForDrain(ns); // The sweep shrinks; nothing here calls TryShrinkSendBuffer @@ -504,7 +436,7 @@ public class NetStateSendBufferTests var writes = NetState.MaxSendBufferSize / chunk + 1; for (var i = 0; i < writes; i++) { - ns.Send(Pattern(chunk, i)); + ns.Send(NetTestHelpers.Pattern(chunk, i)); } Assert.Contains("Send buffer exhausted", ns._disconnectReason); @@ -532,7 +464,7 @@ public class NetStateSendBufferTests for (var i = 0; i < 6; i++) { - ns.Send(Pattern(baseSize / 4, i)); + ns.Send(NetTestHelpers.Pattern(baseSize / 4, i)); } Assert.Equal(baseSize, ns._socket.SendBuffer.PhysicalSize); @@ -558,7 +490,7 @@ public class NetStateSendBufferTests NetState.Slice(); // handoff // CannotSendPackets short-circuits before any growth - ns.Send(Pattern(baseSize * 2, 500)); + ns.Send(NetTestHelpers.Pattern(baseSize * 2, 500)); Assert.Equal(baseSize, ns._socket.SendBuffer.PhysicalSize); Assert.False(ns._sendBufferGrown); diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs index 6d8bd9fd9..c6a8b43a4 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/AccountPacketTests.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.Net; -using Server.Accounting; using Server.Network; using Xunit; @@ -10,60 +8,6 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class AccountPacketTests { - private class MockedAccount : IAccount - { - public int TotalGold { get; } - public int TotalPlat { get; } - public bool DepositGold(int amount) => throw new NotImplementedException(); - - public bool DepositPlat(int amount) => throw new NotImplementedException(); - - public bool WithdrawGold(int amount) => throw new NotImplementedException(); - - public bool WithdrawPlat(int amount) => throw new NotImplementedException(); - - public long GetTotalGold() => throw new NotImplementedException(); - - public int CompareTo(IAccount other) => throw new NotImplementedException(); - - public string Username { get; } - public string Email { get; set; } - public AccessLevel AccessLevel { get; set; } - public int Length { get; } - public int Limit { get; } - public int Count { get; } - - private Dictionary _mobiles = new(); - - public Mobile this[int index] - { - get => _mobiles[index]; - set - { - _mobiles[index] = value; - if (value != null) - { - value.Account = this; - } - } - } - - public DateTime Created { get; set; } - public Serial Serial { get; } - public void Deserialize(IGenericReader reader) => throw new NotImplementedException(); - - public void Serialize(IGenericWriter writer) => throw new NotImplementedException(); - - public bool Deleted { get; } - - public void Delete() => throw new NotImplementedException(); - public bool TrySetUsername(string username) => throw new NotImplementedException(); - - public void SetPassword(string password) => throw new NotImplementedException(); - - public bool CheckPassword(string password) => throw new NotImplementedException(); - } - [Fact] public void TestChangeCharacter() { @@ -75,58 +19,38 @@ public class AccountPacketTests secondMobile.DefaultMobileInit(); secondMobile.RawName = null; - var account = new MockedAccount + var account = new MockAccount { [0] = firstMobile, [1] = null, [2] = secondMobile }; - // var account = new MockAccount(new[] { firstMobile, null, secondMobile }); - var expected = new ChangeCharacter(account).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeCharacter(account); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeCharacter(account), ns => ns.SendChangeCharacter(account)); } [Fact] public void TestClientVersionReq() { - var expected = new ClientVersionReq().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - - ns.SendClientVersionRequest(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ClientVersionReq(), ns => ns.SendClientVersionRequest()); } [Fact] public void TestDeleteResult() { - var expected = new DeleteResult(DeleteResultType.BadRequest).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCharacterDeleteResult(DeleteResultType.BadRequest); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DeleteResult(DeleteResultType.BadRequest), + ns => ns.SendCharacterDeleteResult(DeleteResultType.BadRequest) + ); } [Fact] public void TestPopupMessage() { - var expected = new PopupMessage(PMMessage.LoginSyncError).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPopupMessage(PMMessage.LoginSyncError); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new PopupMessage(PMMessage.LoginSyncError), + ns => ns.SendPopupMessage(PMMessage.LoginSyncError) + ); } [Theory, InlineData(ProtocolChanges.Version70610), InlineData(ProtocolChanges.Version6000)] @@ -136,7 +60,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = firstMobile, [1] = null, @@ -168,25 +92,13 @@ public class AccountPacketTests m.Direction = Direction.Down; m.LogoutMap = Map.Felucca; - var expected = new LoginConfirm(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLoginConfirmation(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LoginConfirm(m), ns => ns.SendLoginConfirmation(m)); } [Fact] public void TestLoginComplete() { - var expected = new LoginComplete().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLoginComplete(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LoginComplete(), ns => ns.SendLoginComplete()); } [Fact] @@ -196,7 +108,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.RawName = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -205,13 +117,7 @@ public class AccountPacketTests [4] = null }; - var expected = new CharacterListUpdate(account).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCharacterListUpdate(account); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CharacterListUpdate(account), ns => ns.SendCharacterListUpdate(account)); } [Fact] @@ -221,7 +127,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -235,17 +141,16 @@ public class AccountPacketTests new CityInfo("Test City", "Test Building", 50, 100, 10, -10) }; - var expected = new CharacterList(account, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.CityInfo = info; - ns.Account = account; - ns.ProtocolChanges = ProtocolChanges.Version70130; - - ns.SendCharacterList(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new CharacterList(account, info), + ns => + { + ns.CityInfo = info; + ns.Account = account; + ns.SendCharacterList(); + }, + ProtocolChanges.Version70130 + ); } [Fact] @@ -255,7 +160,7 @@ public class AccountPacketTests firstMobile.DefaultMobileInit(); firstMobile.Name = "Test Mobile"; - var account = new MockedAccount + var account = new MockAccount { [0] = null, [1] = firstMobile, @@ -269,29 +174,23 @@ public class AccountPacketTests new CityInfo("Test City", "Test Building", 50, 100, 10, -10) }; - var expected = new CharacterListOld(account, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.CityInfo = info; - ns.Account = account; - - ns.SendCharacterList(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new CharacterListOld(account, info), + ns => + { + ns.CityInfo = info; + ns.Account = account; + ns.SendCharacterList(); + } + ); } [Fact] public void TestAccountLoginRej() { var reason = ALRReason.BadComm; - var expected = new AccountLoginRej(reason).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendAccountLoginRejected(reason); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new AccountLoginRej(reason), ns => ns.SendAccountLoginRejected(reason)); } [Fact] @@ -302,15 +201,14 @@ public class AccountPacketTests new ServerInfo("Test Server", 0, TimeZoneInfo.Local, IPEndPoint.Parse("127.0.0.1")) }; - var expected = new AccountLoginAck(info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ServerInfo = info; - - ns.SendAccountLoginAck(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new AccountLoginAck(info), + ns => + { + ns.ServerInfo = info; + ns.SendAccountLoginAck(); + } + ); } [Fact] @@ -319,13 +217,6 @@ public class AccountPacketTests var si = new ServerInfo("Test Server", 0, TimeZoneInfo.Local, IPEndPoint.Parse("127.0.0.1")); const int authId = 0x123456; - var expected = new PlayServerAck(si, authId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - - ns.SendPlayServerAck(si, authId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlayServerAck(si, authId), ns => ns.SendPlayServerAck(si, authId)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs index a885cae9e..de2401a0c 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/CombatPacketTests.cs @@ -12,25 +12,13 @@ public class CombatPacketTests var attacker = (Serial)0x1024; var defender = (Serial)0x2048; - var expected = new Swing(attacker, defender).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSwing(attacker, defender); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Swing(attacker, defender), ns => ns.SendSwing(attacker, defender)); } [Theory, InlineData(true), InlineData(false)] public void TestSetWarMode(bool warmode) { - var expected = new SetWarMode(warmode).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSetWarMode(warmode); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SetWarMode(warmode), ns => ns.SendSetWarMode(warmode)); } [Fact] @@ -38,12 +26,6 @@ public class CombatPacketTests { var serial = (Serial)0x1024; - var expected = new ChangeCombatant(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeCombatant(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeCombatant(serial), ns => ns.SendChangeCombatant(serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs index 00dfc4054..98229c6fe 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs @@ -14,13 +14,7 @@ public class ContainerPacketTests var serial = (Serial)0x1024; ushort gumpId = 100; - var expected = new ContainerDisplay(serial, gumpId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayContainer(serial, gumpId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerDisplay(serial, gumpId), ns => ns.SendDisplayContainer(serial, gumpId)); } [Fact] @@ -29,14 +23,11 @@ public class ContainerPacketTests var serial = (Serial)0x1024; ushort gumpId = 100; - var expected = new ContainerDisplayHS(serial, gumpId).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas; - ns.SendDisplayContainer(serial, gumpId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new ContainerDisplayHS(serial, gumpId), + ns => ns.SendDisplayContainer(serial, gumpId), + ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas + ); } [Fact] @@ -44,13 +35,7 @@ public class ContainerPacketTests { var serial = (Serial)0x1024; - var expected = new DisplaySpellbook(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySpellbook(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplaySpellbook(serial), ns => ns.SendDisplaySpellbook(serial)); } [Fact] @@ -58,14 +43,11 @@ public class ContainerPacketTests { var serial = (Serial)0x1024; - var expected = new DisplaySpellbookHS(serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas; - ns.SendDisplaySpellbook(serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySpellbookHS(serial), + ns => ns.SendDisplaySpellbook(serial), + ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas + ); } [Fact] @@ -76,17 +58,20 @@ public class ContainerPacketTests ushort offset = 10; ulong content = 0x123456789ABCDEF0; var opl = ObjectPropertyList.Enabled; - - var expected = new NewSpellbookContent(serial, graphic, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.NewSpellbook; ObjectPropertyList.Enabled = true; - ns.SendSpellbookContent(serial, graphic, offset, content); - ObjectPropertyList.Enabled = opl; - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + try + { + PacketAssert.Sends( + new NewSpellbookContent(serial, graphic, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content), + ProtocolChanges.ContainerGridLines | ProtocolChanges.NewSpellbook + ); + } + finally + { + ObjectPropertyList.Enabled = opl; + } } [Fact] @@ -97,13 +82,10 @@ public class ContainerPacketTests ushort graphic = 100; ulong content = 0x123456789ABCDEF0; - var expected = new SpellbookContent(serial, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSpellbookContent(serial, graphic, offset, content); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpellbookContent(serial, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content) + ); } [Fact] @@ -114,14 +96,11 @@ public class ContainerPacketTests ushort graphic = 100; ulong content = 0x123456789ABCDEF0; - var expected = new SpellbookContent6017(serial, offset, content).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendSpellbookContent(serial, graphic, offset, content); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpellbookContent6017(serial, offset, content), + ns => ns.SendSpellbookContent(serial, graphic, offset, content), + ProtocolChanges.ContainerGridLines + ); } [Fact] @@ -130,13 +109,7 @@ public class ContainerPacketTests var serial = (Serial)0x1024; var item = new Item(serial); - var expected = new ContainerContentUpdate(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContentUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerContentUpdate(item), ns => ns.SendContainerContentUpdate(item)); } [Fact] @@ -145,66 +118,17 @@ public class ContainerPacketTests var serial = (Serial)0x1024; var item = new Item(serial); - var expected = new ContainerContentUpdate6017(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendContainerContentUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new ContainerContentUpdate6017(item), + ns => ns.SendContainerContentUpdate(item), + ProtocolChanges.ContainerGridLines + ); } - [Fact] - public void TestContainerContent() + // An administrator beholder on the container's map, so every item is visible and sent. + private static (Container Container, Mobile Beholder) CreateViewedContainer(int itemCount) { var cont = new Container(World.NewItem); - cont.AddItem(new Item(World.NewItem)); - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - var expected = new ContainerContent(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } - - [Fact] - public void TestContainerContent6017() - { - var cont = new Container(World.NewItem); - cont.AddItem(new Item(World.NewItem)); - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - var expected = new ContainerContent6017(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } - - [Fact] - public void TestContainerContent_CapsAtProtocolLimit() - { - var cont = new Container(World.NewItem); - - // Past the 3448-entry cap (19-byte entries) so truncation engages. - const int itemCount = 5000; for (var i = 0; i < itemCount; i++) { cont.AddItem(new Item(World.NewItem)); @@ -217,9 +141,62 @@ public class ContainerPacketTests m.AccessLevel = AccessLevel.Administrator; m.Map = Map.Felucca; + return (cont, m); + } + + [Fact] + public void TestContainerContent() + { + var (cont, m) = CreateViewedContainer(1); + + try + { + PacketAssert.Sends(new ContainerContent(m, cont), ns => ns.SendContainerContent(m, cont)); + } + finally + { + cont.Delete(); + m.Delete(); + } + } + + [Fact] + public void TestContainerContent6017() + { + var (cont, m) = CreateViewedContainer(1); + + try + { + PacketAssert.Sends( + new ContainerContent6017(m, cont), + ns => ns.SendContainerContent(m, cont), + ProtocolChanges.ContainerGridLines + ); + } + finally + { + cont.Delete(); + m.Delete(); + } + } + + // 0x3C's length and count fields are 16-bit: 19-byte entries cap at 3448, 20-byte grid-line entries at 3276. + [Theory] + [InlineData(false, 3448, 65517)] + [InlineData(true, 3276, 65525)] + public void TestContainerContent_CapsAtProtocolLimit(bool gridLines, int maxEntries, int maxLength) + { + // One item past the cap is enough to make truncation engage. + var (cont, m) = CreateViewedContainer(maxEntries + 1); + try { using var ns = PacketTestUtilities.CreateTestNetState(); + if (gridLines) + { + ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; + } + // A ~64KB packet exceeds the 4KB pre-auth send buffer; an account lets Send() promote it // on demand instead of exhausting the buffer and disconnecting. ns.Account = new MockAccount(); @@ -233,57 +210,9 @@ public class ContainerPacketTests var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]); var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]); - const int entrySize = 19; // no grid lines negotiated on this NetState - var expectedMaxEntries = (ushort.MaxValue - 5) / entrySize; - - Assert.Equal(65517, length); + Assert.Equal(maxLength, length); Assert.Equal(length, span.Length); - Assert.Equal(expectedMaxEntries, count); - } - finally - { - cont.Delete(); - m.Delete(); - } - } - - [Fact] - public void TestContainerContent_CapsAtProtocolLimit_WithGridLines() - { - var cont = new Container(World.NewItem); - - // Past the 3276-entry cap (20-byte entries with grid lines) so truncation engages. - const int itemCount = 5000; - for (var i = 0; i < itemCount; i++) - { - cont.AddItem(new Item(World.NewItem)); - } - - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; - - try - { - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - ns.Account = new MockAccount(); - - var ex = Record.Exception(() => ns.SendContainerContent(m, cont)); - Assert.Null(ex); - - var span = ns.SendBuffer.GetReadSpan(); - Assert.Equal((byte)0x3C, span[0]); - - var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]); - var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]); - - Assert.Equal(65525, length); - Assert.Equal(length, span.Length); - Assert.Equal(3276, count); + Assert.Equal(maxEntries, count); } finally { @@ -295,32 +224,13 @@ public class ContainerPacketTests [Fact] public void TestContainerContent_RentedBuffer_MatchesReferencePacket() { - var cont = new Container(World.NewItem); - // Below the entry cap but past the 1024-byte stackalloc threshold, so SendContainerContent // rents its buffer instead of using the stack. - const int itemCount = 100; - for (var i = 0; i < itemCount; i++) - { - cont.AddItem(new Item(World.NewItem)); - } - - cont.Map = Map.Felucca; - - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.AccessLevel = AccessLevel.Administrator; - m.Map = Map.Felucca; + var (cont, m) = CreateViewedContainer(100); try { - var expected = new ContainerContent(m, cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendContainerContent(m, cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ContainerContent(m, cont), ns => ns.SendContainerContent(m, cont)); } finally { diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs index 5f31a49d4..bda7ad009 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/DamagePacketTests.cs @@ -11,13 +11,7 @@ public class DamagePacketTests { var serial = (Serial)0x1024; - var expected = new DamagePacketOld(serial, inputAmount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDamage(serial, inputAmount); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DamagePacketOld(serial, inputAmount), ns => ns.SendDamage(serial, inputAmount)); } [Theory, InlineData(10), InlineData(-5), InlineData(1024), InlineData(100000)] @@ -25,14 +19,10 @@ public class DamagePacketTests { var serial = (Serial)0x1024; - var expected = new DamagePacket(serial, inputAmount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.DamagePacket; - - ns.SendDamage(serial, inputAmount); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DamagePacket(serial, inputAmount), + ns => ns.SendDamage(serial, inputAmount), + ProtocolChanges.DamagePacket + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs index 7ade294f6..e9395948d 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPacketTests.cs @@ -7,18 +7,13 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class EffectPackets { - [Theory, InlineData(10, 1000, 10, 5)] - public void TestSoundEffect(ushort soundID, int x, int y, int z) + [Fact] + public void TestSoundEffect() { - var p = new Point3D(x, y, z); + const ushort soundID = 10; + var p = new Point3D(1000, 10, 5); - var expected = new PlaySound(soundID, p).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSoundEffect(soundID, p); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlaySound(soundID, p), ns => ns.SendSoundEffect(soundID, p)); } [Fact] @@ -94,13 +89,7 @@ public class EffectPackets [Theory, InlineData(ScreenEffectType.DarkFlash), InlineData(ScreenEffectType.FadeInOut)] public void TestScreenEffect(ScreenEffectType screenType) { - var expected = new ScreenEffect(screenType).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendScreenEffect(screenType); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ScreenEffect(screenType), ns => ns.SendScreenEffect(screenType)); } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs index e18256383..2899569f8 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EffectPackets.cs @@ -76,157 +76,6 @@ public class HuedEffect : Packet } } -public sealed class TargetParticleEffect : ParticleEffect -{ - public TargetParticleEffect( - IEntity e, int itemID, int speed, int duration, int hue, int renderMode, int effect, - int layer, int unknown - ) : base( - EffectType.FixedFrom, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode, - effect, - 1, - 0, - e.Serial, - layer, - unknown - ) - { - } -} - -public sealed class TargetEffect : HuedEffect -{ - public TargetEffect(IEntity e, int itemID, int speed, int duration, int hue, int renderMode) : base( - EffectType.FixedFrom, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode - ) - { - } -} - -public sealed class LocationParticleEffect : ParticleEffect -{ - public LocationParticleEffect( - IEntity e, int itemID, int speed, int duration, int hue, int renderMode, int effect, - int unknown - ) : base( - EffectType.FixedXYZ, - e.Serial, - Serial.Zero, - itemID, - e.Location, - e.Location, - speed, - duration, - true, - false, - hue, - renderMode, - effect, - 1, - 0, - e.Serial, - 255, - unknown - ) - { - } -} - -public sealed class LocationEffect : HuedEffect -{ - public LocationEffect(IPoint3D p, int itemID, int speed, int duration, int hue, int renderMode) : base( - EffectType.FixedXYZ, - Serial.Zero, - Serial.Zero, - itemID, - p, - p, - speed, - duration, - true, - false, - hue, - renderMode - ) - { - } -} - -public sealed class MovingParticleEffect : ParticleEffect -{ - public MovingParticleEffect( - IEntity from, IEntity to, int itemID, int speed, int duration, bool fixedDirection, - bool explodes, int hue, int renderMode, int effect, int explodeEffect, int explodeSound, EffectLayer layer, - int unknown - ) : base( - EffectType.Moving, - from.Serial, - to.Serial, - itemID, - from.Location, - to.Location, - speed, - duration, - fixedDirection, - explodes, - hue, - renderMode, - effect, - explodeEffect, - explodeSound, - Serial.Zero, - (int)layer, - unknown - ) - { - } -} - -public sealed class MovingEffect : HuedEffect -{ - public MovingEffect( - IEntity from, IEntity to, int itemID, int speed, int duration, bool fixedDirection, - bool explodes, int hue, int renderMode - ) : base( - EffectType.Moving, - from.Serial, - to.Serial, - itemID, - from.Location, - to.Location, - speed, - duration, - fixedDirection, - explodes, - hue, - renderMode - ) - { - } -} - public class ScreenEffect : Packet { public ScreenEffect(ScreenEffectType type) diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs index 8dc535e03..21933bc91 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/EquipmentPacketTests.cs @@ -31,19 +31,16 @@ public class EquipmentPacketTests } ); - var expected = new DisplayEquipmentInfo(item, info).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayEquipmentInfo( - item.Serial, - info.Number, - info.Crafter?.RawName, - info.Unidentified, - new List(info.Attributes) + PacketAssert.Sends( + new DisplayEquipmentInfo(item, info), + ns => ns.SendDisplayEquipmentInfo( + item.Serial, + info.Number, + info.Crafter?.RawName, + info.Unidentified, + new List(info.Attributes) + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -54,12 +51,6 @@ public class EquipmentPacketTests var item = new Item(World.NewItem) { Parent = m }; - var expected = new EquipUpdate(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEquipUpdate(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EquipUpdate(item), ns => ns.SendEquipUpdate(item)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs index edbe6b668..febac5288 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/GumpPacketTests.cs @@ -6,17 +6,13 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class GumpPacketTests { - [Theory] - [InlineData(100, 10)] - public void TestCloseGump(int typeId, int buttonId) + [Fact] + public void TestCloseGump() { - var expected = new CloseGump(typeId, buttonId).Compile(); + const int typeId = 100; + const int buttonId = 10; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCloseGump(typeId, buttonId); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CloseGump(typeId, buttonId), ns => ns.SendCloseGump(typeId, buttonId)); } [Fact] @@ -27,13 +23,10 @@ public class GumpPacketTests const string unknownString = "This is an unknown string"; const string caption = "This is a caption"; - var expected = new DisplaySignGump(gumpSerial, gumpId, unknownString, caption).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySignGump(gumpSerial, gumpId, unknownString, caption); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySignGump(gumpSerial, gumpId, unknownString, caption), + ns => ns.SendDisplaySignGump(gumpSerial, gumpId, unknownString, caption) + ); } [Fact] @@ -41,13 +34,7 @@ public class GumpPacketTests { var gump = new NameChangeDeedGump(); - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = gump.Compile(ns).Compile(); - ns.SendGump(gump); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => gump.Compile(ns), ns => ns.SendGump(gump)); } [Fact] @@ -60,13 +47,7 @@ public class GumpPacketTests var gump = new AdminGump(m, AdminGumpPage.Clients); - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = gump.Compile(ns).Compile(); - ns.SendGump(gump); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => gump.Compile(ns), ns => ns.SendGump(gump)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs index 27bad8aaf..f9d5cb948 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs @@ -1,116 +1,75 @@ +using System; using Server.Network; using Xunit; namespace Server.Tests.Network; [Collection("Sequential Server Tests")] -public class ItemPacketTests +public class ItemPacketTests : IDisposable { + private const int TestItemId = 1; + + // TileData is global and holds real client data when client files are loaded, so each test restores it. + private readonly ItemData _originalItemData; + + public ItemPacketTests() + { + _originalItemData = TileData.ItemTable[TestItemId]; + TileData.ItemTable[TestItemId] = new ItemData("Test Item Data", TileFlag.Generic, 1, 1, 1, 1, 1, 1); + } + + public void Dispose() => TileData.ItemTable[TestItemId] = _originalItemData; + [Fact] public void TestWorldItemPacket() { var serial = (Serial)0x1024; - var itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10), Direction = Direction.Left }; - var expected = new WorldItem(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new WorldItem(item), ns => ns.SendWorldItem(item)); } [Fact] public void TestWorldItemSAPacket() { var serial = (Serial)0x1024; - ushort itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10) }; - var expected = new WorldItemSA(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.StygianAbyss; - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new WorldItemSA(item), ns => ns.SendWorldItem(item), ProtocolChanges.StygianAbyss); } [Fact] public void TestWorldItemHSPacket() { var serial = (Serial)0x1024; - var itemId = 1; - - // Move to fixture - TileData.ItemTable[itemId] = new ItemData( - "Test Item Data", - TileFlag.Generic, - 1, - 1, - 1, - 1, - 1, - 1 - ); var item = new Item(serial) { - ItemID = itemId, + ItemID = TestItemId, Hue = 0x1024, Amount = 10, Location = new Point3D(1000, 100, -10) }; - var expected = new WorldItemHS(item).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = ProtocolChanges.StygianAbyss | ProtocolChanges.HighSeas; - ns.SendWorldItem(item); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new WorldItemHS(item), + ns => ns.SendWorldItem(item), + ProtocolChanges.StygianAbyss | ProtocolChanges.HighSeas + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs index 084ba4587..90bd89dfb 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPackets.cs @@ -187,21 +187,3 @@ public sealed class WorldItemHS : Packet Stream.Write((short)0x00); // ?? } } - -public sealed class OPLInfo : Packet -{ - /*public OPLInfo( ObjectPropertyList list ) : base( 0xBF ) - { - EnsureCapacity( 13 ); - - m_Stream.Write( (short) 0x10 ); - m_Stream.Write( (int) list.Entity.Serial ); - m_Stream.Write( (int) list.Hash ); - }*/ - - public OPLInfo(Serial serial, int hash) : base(0xDC, 9) - { - Stream.Write(serial); - Stream.Write(hash); - } -} \ No newline at end of file diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs index c66a61ca7..22ed4c423 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/LightPacketTests.cs @@ -10,13 +10,8 @@ public class LightPacketTests public void TestGlobalLightLevel() { const byte lightLevel = 5; - var expected = new GlobalLightLevel(lightLevel).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendGlobalLightLevel(lightLevel); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new GlobalLightLevel(lightLevel), ns => ns.SendGlobalLightLevel(lightLevel)); } [Fact] @@ -24,12 +19,10 @@ public class LightPacketTests { var serial = (Serial)0x1024; byte lightLevel = 5; - var expected = new PersonalLightLevel(serial, lightLevel).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPersonalLightLevel(serial, lightLevel); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new PersonalLightLevel(serial, lightLevel), + ns => ns.SendPersonalLightLevel(serial, lightLevel) + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs index a438730e0..d563db252 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MapPacketTests.cs @@ -9,26 +9,13 @@ public class MapPatchesTests [Fact] public void TestMapPatches() { - using var ns = PacketTestUtilities.CreateTestNetState(); - - var expected = new MapPatches().Compile(); - - ns.SendMapPatches(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MapPatches(), ns => ns.SendMapPatches()); } [Fact] public void TestInvalidMapEnable() { - var expected = new InvalidMapEnable().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendInvalidMap(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new InvalidMapEnable(), ns => ns.SendInvalidMap()); } [Theory] @@ -37,12 +24,7 @@ public class MapPatchesTests public void TestMapChange(string mapName) { var map = Map.Parse(mapName); - var expected = new MapChange(map).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMapChange(map); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MapChange(map), ns => ns.SendMapChange(map)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs index 3da55c89e..c87355ac8 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MenuPacketTests.cs @@ -42,13 +42,7 @@ public class MenuPacketTests ] ); - var expected = new DisplayItemListMenu(menu).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayItemListMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplayItemListMenu(menu), ns => ns.SendDisplayItemListMenu(menu)); } [Fact] @@ -63,12 +57,7 @@ public class MenuPacketTests ] ); - var expected = new DisplayQuestionMenu(menu).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayQuestionMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DisplayQuestionMenu(menu), ns => ns.SendDisplayQuestionMenu(menu)); } [Theory] @@ -84,18 +73,12 @@ public class MenuPacketTests var item = new ContextMenuItem(World.NewItem, newPacket); var menu = ContextMenuSystem.CreateContextMenu(m, item); - var packet = newHaven && newPacket ? (Packet)new DisplayContextMenu(menu) : new DisplayContextMenuOld(menu); - var expected = packet.Compile(); + var expected = newHaven && newPacket ? (Packet)new DisplayContextMenu(menu) : new DisplayContextMenuOld(menu); - using var ns = PacketTestUtilities.CreateTestNetState(); - if (newHaven) - { - ns.ProtocolChanges |= ProtocolChanges.NewHaven; - } - - ns.SendDisplayContextMenu(menu); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + expected, + ns => ns.SendDisplayContextMenu(menu), + newHaven ? ProtocolChanges.NewHaven : ProtocolChanges.None + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs index b5eb423f1..c1911db57 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MessagePacketTests.cs @@ -19,31 +19,10 @@ public class MessageTests var name = "Stuff"; var args = "Arguments"; - var expected = new MessageLocalized( - serial, - graphic, - messageType, - hue, - font, - number, - name, - args - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalized( - serial, - graphic, - messageType, - hue, - font, - number, - name, - args + PacketAssert.Sends( + new MessageLocalized(serial, graphic, messageType, hue, font, number, name, args), + ns => ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, args) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -58,15 +37,10 @@ public class MessageTests var name = "Stuff"; var argValue = 42; - var expected = new MessageLocalized( - serial, graphic, messageType, hue, font, number, name, $"value: {argValue}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}"); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new MessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}"), + ns => ns.SendMessageLocalized(serial, graphic, messageType, hue, font, number, name, $"value: {argValue}") + ); } [Fact] @@ -83,35 +57,12 @@ public class MessageTests var affixType = AffixType.System; var affix = "Affix"; - var expected = new MessageLocalizedAffix( - serial, - graphic, - messageType, - hue, - font, - number, - name, - affixType, - affix, - args - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalizedAffix( - serial, - graphic, - messageType, - hue, - font, - number, - name, - affixType, - affix, - args + PacketAssert.Sends( + new MessageLocalizedAffix(serial, graphic, messageType, hue, font, number, name, affixType, affix, args), + ns => ns.SendMessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, args + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -128,18 +79,14 @@ public class MessageTests var affix = "Affix"; var argValue = 42; - var expected = new MessageLocalizedAffix( - serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessageLocalizedAffix( - serial, graphic, messageType, hue, font, number, name, affixType, affix, - $"value: {argValue}" + PacketAssert.Sends( + new MessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" + ), + ns => ns.SendMessageLocalizedAffix( + serial, graphic, messageType, hue, font, number, name, affixType, affix, $"value: {argValue}" + ) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -154,17 +101,10 @@ public class MessageTests var name = "Stuff"; var who = "Alice"; - var expected = new UnicodeMessage( - serial, graphic, messageType, hue, font, lang, name, $"hello, {who}" - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, graphic, messageType, hue, font, false, lang, name, $"hello, {who}" + PacketAssert.Sends( + new UnicodeMessage(serial, graphic, messageType, hue, font, lang, name, $"hello, {who}"), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, false, lang, name, $"hello, {who}") ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -178,31 +118,10 @@ public class MessageTests var name = "Stuff"; var text = "Some Text"; - var expected = new AsciiMessage( - serial, - graphic, - messageType, - hue, - font, - name, - text - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, - graphic, - messageType, - hue, - font, - true, - null, - name, - text + PacketAssert.Sends( + new AsciiMessage(serial, graphic, messageType, hue, font, name, text), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, true, null, name, text) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -217,32 +136,10 @@ public class MessageTests var name = "Stuff"; var text = "Some Text"; - var expected = new UnicodeMessage( - serial, - graphic, - messageType, - hue, - font, - lang, - name, - text - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMessage( - serial, - graphic, - messageType, - hue, - font, - false, - lang, - name, - text + PacketAssert.Sends( + new UnicodeMessage(serial, graphic, messageType, hue, font, lang, name, text), + ns => ns.SendMessage(serial, graphic, messageType, hue, font, false, lang, name, text) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Fact] @@ -251,13 +148,7 @@ public class MessageTests var serial = (Serial)0x1024; var serial2 = (Serial)0x2; - var expected = new FollowMessage(serial, serial2).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendFollowMessage(serial, serial2); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new FollowMessage(serial, serial2), ns => ns.SendFollowMessage(serial, serial2)); } [Fact] @@ -266,33 +157,18 @@ public class MessageTests var s = (Serial)0x100; var text = "This is some testing text"; - var expected = new ObjectHelpResponse(s, text).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendHelpResponse(s, text); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ObjectHelpResponse(s, text), ns => ns.SendHelpResponse(s, text)); } - internal class TestPrompt : Prompt + [Fact] + public void TestUnicodePrompt() { + var prompt = new TestPrompt(); + + PacketAssert.Sends(new UnicodePrompt(prompt), ns => ns.SendPrompt(prompt)); } - [Collection("Sequential Server Tests")] - public class UnicodePromptTests + private class TestPrompt : Prompt { - [Fact] - public void TestUnicodePrompt() - { - var prompt = new TestPrompt(); - var expected = new UnicodePrompt(prompt).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPrompt(prompt); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - } } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs index d7d3beca2..3d839899b 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MobilePacketTests.cs @@ -6,19 +6,38 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class MobilePacketTests { + private static Mobile CreateMobileWithStats(uint serial = 0x1, string name = null) + { + var m = new Mobile((Serial)serial) { Name = name }; + m.DefaultMobileInit(); + m.Str = 50; + m.Hits = 100; + m.Int = 75; + m.Mana = 100; + m.Dex = 25; + m.Stam = 100; + + return m; + } + + private sealed class TestPoison : Poison + { + public TestPoison(int level) : base(level) => Level = level; + + public override string Name => $"Test Poison {Level}"; + public override int Level { get; } + public override PoisonFamily Family => PoisonFamily.Standard; + + public override Timer ConstructTimer(Mobile m) => null; + } + [Fact] public void TestDeathAnimation() { var killed = (Serial)0x1; var corpse = (Serial)0x1000; - var expected = new DeathAnimation(killed, corpse).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDeathAnimation(killed, corpse); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DeathAnimation(killed, corpse), ns => ns.SendDeathAnimation(killed, corpse)); } [Fact] @@ -27,13 +46,7 @@ public class MobilePacketTests var petSerial = (Serial)0x1; const bool bonded = true; - var expected = new BondedStatus(petSerial, bonded).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendBondedStatus(petSerial, bonded); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new BondedStatus(petSerial, bonded), ns => ns.SendBondedStatus(petSerial, bonded)); } [Theory] @@ -46,14 +59,11 @@ public class MobilePacketTests var noto = 10; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - var expected = new MobileMoving(m, noto, ns.StygianAbyss).Compile(); - - ns.SendMobileMoving(m, noto); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileMoving(m, noto, ns.StygianAbyss), + ns => ns.SendMobileMoving(m, noto), + protocolChanges + ); } [Theory] @@ -65,13 +75,7 @@ public class MobilePacketTests var m = new Mobile((Serial)0x1) { Name = name }; m.DefaultMobileInit(); - var expected = new MobileName(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileName(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileName(m), ns => ns.SendMobileName(m)); } [Theory] @@ -81,29 +85,10 @@ public class MobilePacketTests { var mobile = (Serial)0x1; - var expected = new MobileAnimation( - mobile, - action, - frameCount, - repeatCount, - !reverse, - repeat, - delay - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAnimation( - mobile, - action, - frameCount, - repeatCount, - !reverse, - repeat, - delay + PacketAssert.Sends( + new MobileAnimation(mobile, action, frameCount, repeatCount, !reverse, repeat, delay), + ns => ns.SendMobileAnimation(mobile, action, frameCount, repeatCount, !reverse, repeat, delay) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Theory] @@ -113,43 +98,24 @@ public class MobilePacketTests { var mobile = (Serial)0x1; - var expected = new NewMobileAnimation( - mobile, - action, - frameCount, - delay - ).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendNewMobileAnimation( - mobile, - action, - frameCount, - delay + PacketAssert.Sends( + new NewMobileAnimation(mobile, action, frameCount, delay), + ns => ns.SendNewMobileAnimation(mobile, action, frameCount, delay) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } + // Level -1 means not poisoned. Constructed directly: the poison registry is filled by UOContent. [Theory] - [InlineData("None")] - [InlineData("Lesser")] - [InlineData("Lethal")] - public void TestHealthbarPoison(string pName) + [InlineData(-1)] + [InlineData(0)] + [InlineData(4)] + public void TestHealthbarPoison(int level) { - var p = Poison.GetPoison(pName); var m = new Mobile((Serial)0x1); m.DefaultMobileInit(); - m.Poison = p; + m.Poison = level < 0 ? null : new TestPoison(level); - var expected = new HealthbarPoison(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHealthbar(m, Healthbar.Poison); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new HealthbarPoison(m), ns => ns.SendMobileHealthbar(m, Healthbar.Poison)); } [Theory] @@ -164,13 +130,7 @@ public class MobilePacketTests m.Blessed = isBlessed; m.YellowHealthbar = isYellowHealth; - var expected = new HealthbarYellow(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHealthbar(m, Healthbar.Yellow); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new HealthbarYellow(m), ns => ns.SendMobileHealthbar(m, Healthbar.Yellow)); } [Theory] @@ -178,22 +138,9 @@ public class MobilePacketTests [InlineData(false)] public void TestMobileStatusCompact(bool canBeRenamed) { - var m = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(name: "Random Mobile 1"); - var expected = new MobileStatusCompact(canBeRenamed, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStatusCompact(m, canBeRenamed); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStatusCompact(canBeRenamed, m), ns => ns.SendMobileStatusCompact(m, canBeRenamed)); } [Theory] @@ -201,32 +148,14 @@ public class MobilePacketTests [InlineData(ProtocolChanges.Version502b)] public void TestMobileStatus(ProtocolChanges changes) { - var beholder = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - beholder.DefaultMobileInit(); - beholder.Str = 50; - beholder.Hits = 100; - beholder.Int = 75; - beholder.Mana = 100; - beholder.Dex = 25; - beholder.Stam = 100; + var beholder = CreateMobileWithStats(0x1, "Random Mobile 1"); + var beheld = CreateMobileWithStats(0x2, "Random Mobile 2"); - var beheld = new Mobile((Serial)0x2) { Name = "Random Mobile 2" }; - beheld.DefaultMobileInit(); - beheld.Str = 50; - beheld.Hits = 100; - beheld.Int = 75; - beheld.Mana = 100; - beheld.Dex = 25; - beheld.Stam = 100; - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileStatus(beholder, beheld, ns).Compile(); - ns.SendMobileStatus(beholder, beheld); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileStatus(beholder, beheld, ns), + ns => ns.SendMobileStatus(beholder, beheld), + changes + ); } [Theory] @@ -240,33 +169,32 @@ public class MobilePacketTests int mobileStatusVersion ) { - var expansionInfo = ExpansionInfo.CoreExpansion; var oldExpansion = Core.Expansion; + var expansionInfo = ExpansionInfo.GetInfo(expansion); var oldVersion = expansionInfo.MobileStatusVersion; - Core.Expansion = expansion; - ExpansionInfo.CoreExpansion.MobileStatusVersion = mobileStatusVersion; - var m = new Mobile((Serial)0x1) { Name = "Random Mobile 1" }; - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + try + { + Core.Expansion = expansion; + expansionInfo.MobileStatusVersion = mobileStatusVersion; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - ns.Version = new ClientVersion(version); - ns.Flags = clientFlags; + var m = CreateMobileWithStats(name: "Random Mobile 1"); - var expected = new MobileStatusExtended(m, ns).Compile(); - ns.SendMobileStatus(m, m); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.ProtocolChanges = changes; + ns.Version = new ClientVersion(version); + ns.Flags = clientFlags; - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); - Core.Expansion = oldExpansion; - expansionInfo.MobileStatusVersion = oldVersion; + var expected = new MobileStatusExtended(m, ns).Compile(); + ns.SendMobileStatus(m, m); + + AssertThat.Equal(ns.SendBuffer.GetReadSpan(), expected); + } + finally + { + Core.Expansion = oldExpansion; + expansionInfo.MobileStatusVersion = oldVersion; + } } [Theory] @@ -278,14 +206,7 @@ public class MobilePacketTests m.DefaultMobileInit(); m.SolidHueOverride = solidHueOverride; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileUpdate(m, ns.StygianAbyss).Compile(); - ns.SendMobileUpdate(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(ns => new MobileUpdate(m, ns.StygianAbyss), ns => ns.SendMobileUpdate(m), changes); } [Theory] @@ -333,170 +254,82 @@ public class MobilePacketTests beheld.FacialHairItemID = facialHairItemId; beheld.FacialHairHue = facialHairHue; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; - - var expected = new MobileIncoming(ns, beholder, beheld).Compile(); - ns.SendMobileIncoming(beholder, beheld); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new MobileIncoming(ns, beholder, beheld), + ns => ns.SendMobileIncoming(beholder, beheld), + changes + ); } [Fact] public void TestMobileHits() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 100; - m.Hits = 100; + var m = CreateMobileWithStats(); - var expected = new MobileHits(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHits(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileHits(m), ns => ns.SendMobileHits(m)); } [Fact] public void TestMobileHitsN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 100; - m.Hits = 100; + var m = CreateMobileWithStats(); - var expected = new MobileHitsN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileHits(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileHitsN(m), ns => ns.SendMobileHits(m, true)); } [Fact] public void TestMobileMana() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Int = 75; - m.Mana = 100; + var m = CreateMobileWithStats(); - var expected = new MobileMana(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileMana(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileMana(m), ns => ns.SendMobileMana(m)); } [Fact] public void TestMobileManaN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Int = 75; - m.Mana = 100; + var m = CreateMobileWithStats(); - var expected = new MobileManaN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileMana(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileManaN(m), ns => ns.SendMobileMana(m, true)); } [Fact] public void TestMobileStam() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Dex = 75; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileStam(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStam(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStam(m), ns => ns.SendMobileStam(m)); } [Fact] public void TestMobileStamN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Dex = 75; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileStamN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileStam(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileStamN(m), ns => ns.SendMobileStam(m, true)); } [Fact] public void TestMobileAttributes() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileAttributes(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAttributes(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileAttributes(m), ns => ns.SendMobileAttributes(m)); } [Fact] public void TestMobileAttributesN() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - m.Str = 50; - m.Hits = 100; - m.Int = 75; - m.Mana = 100; - m.Dex = 25; - m.Stam = 100; + var m = CreateMobileWithStats(); - var expected = new MobileAttributesN(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMobileAttributes(m, true); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MobileAttributesN(m), ns => ns.SendMobileAttributes(m, true)); } [Fact] public void TestRemoveEntity() { var e = (Serial)0x1000; - var expected = new RemoveEntity(e).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendRemoveEntity(e); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new RemoveEntity(e), ns => ns.SendRemoveEntity(e)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs index 330eaf0b5..5f8a27206 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/MovementPacketTests.cs @@ -12,26 +12,18 @@ public class MovementPacketTests [InlineData(2)] public void TestSpeedControl(byte speedControl) { - var expected = new SpeedControl(speedControl).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSpeedControl((SpeedControlSetting)speedControl); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SpeedControl(speedControl), + ns => ns.SendSpeedControl((SpeedControlSetting)speedControl) + ); } [Fact] public void TestMovePlayer() { const Direction d = Direction.Left; - var expected = new MovePlayer(d).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovePlayer(d); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MovePlayer(d), ns => ns.SendMovePlayer(d)); } [Fact] @@ -42,13 +34,7 @@ public class MovementPacketTests const byte seq = 100; - var expected = new MovementRej(seq, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovementRej(seq, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MovementRej(seq, m), ns => ns.SendMovementRej(seq, m)); } [Fact] @@ -59,12 +45,6 @@ public class MovementPacketTests const byte seq = 100; - var expected = MovementAck.Instantiate(seq, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMovementAck(seq, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(MovementAck.Instantiate(seq, m), ns => ns.SendMovementAck(seq, m)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs index 6d6e5daa3..8bfe17a9e 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/PlayerPacketTests.cs @@ -8,23 +8,16 @@ namespace Server.Tests.Network; [Collection("Sequential Server Tests")] public class PlayerPacketTests { - [Theory] - [InlineData(StatLockType.Down, StatLockType.Up, StatLockType.Locked)] - public void TestStatLockInfo(StatLockType str, StatLockType intel, StatLockType dex) + [Fact] + public void TestStatLockInfo() { var m = new Mobile((Serial)0x1); m.DefaultMobileInit(); - m.StrLock = str; - m.IntLock = intel; - m.DexLock = dex; + m.StrLock = StatLockType.Down; + m.IntLock = StatLockType.Up; + m.DexLock = StatLockType.Locked; - var expected = new StatLockInfo(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendStatLockInfo(m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new StatLockInfo(m), ns => ns.SendStatLockInfo(m)); } [Theory] @@ -33,25 +26,13 @@ public class PlayerPacketTests [InlineData(200)] public void TestChangeUpdateRange(int range) { - var expected = new ChangeUpdateRange(range).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendChangeUpdateRange((byte)range); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ChangeUpdateRange(range), ns => ns.SendChangeUpdateRange((byte)range)); } [Fact] public void TestDeathStatus() { - var expected = new DeathStatus(false).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDeathStatus(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new DeathStatus(false), ns => ns.SendDeathStatus()); } [Theory] @@ -59,13 +40,10 @@ public class PlayerPacketTests [InlineData(0x1000u, null, null, null)] public void TestDisplayProfile(uint serial, string header, string body, string footer) { - var expected = new DisplayProfile((Serial)serial, header, body, footer).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayProfile((Serial)serial, header, body, footer); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayProfile((Serial)serial, header, body, footer), + ns => ns.SendDisplayProfile((Serial)serial, header, body, footer) + ); } [Theory] @@ -73,55 +51,31 @@ public class PlayerPacketTests [InlineData(LRReason.TryToSteal)] public void TestLiftRej(LRReason reason) { - var expected = new LiftRej(reason).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLiftReject(reason); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LiftRej(reason), ns => ns.SendLiftReject(reason)); } [Fact] public void TestLogoutAck() { - var expected = new LogoutAck().Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLogoutAck(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LogoutAck(), ns => ns.SendLogoutAck()); } [Theory] [InlineData(1, 2, 3)] [InlineData(4, 5, 6)] - [InlineData(0x1234, 0x5678, 0x9ABC)] - public void TestWeather(int type, int density, int temp) + [InlineData(0xFF, 0x46, 0xEC)] + public void TestWeather(byte type, byte density, byte temp) { - var expected = new Weather(type, density, temp).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendWeather((byte)type, (byte)density, (byte)temp); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Weather(type, density, temp), ns => ns.SendWeather(type, density, temp)); } - [Theory] - [InlineData(100, 1000, 1, 0)] - public void TestServerChange(int x, int y, int z, int mapID) + [Fact] + public void TestServerChange() { - var p = new Point3D(x, y, z); - var map = Map.Maps[mapID]; - var expected = new ServerChange(p, map).Compile(); + var p = new Point3D(100, 1000, 1); + var map = Map.Maps[0]; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendServerChange(p, map); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ServerChange(p, map), ns => ns.SendServerChange(p, map)); } [Theory] @@ -130,13 +84,7 @@ public class PlayerPacketTests [InlineData(255)] public void TestSequence(byte num) { - var expected = new Sequence(num).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSequence(num); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new Sequence(num), ns => ns.SendSequence(num)); } [Theory] @@ -146,37 +94,22 @@ public class PlayerPacketTests [InlineData("https://www.modernuo.com")] public void TestLaunchBrowser(string uri) { - var expected = new LaunchBrowser(uri).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendLaunchBrowser(uri); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new LaunchBrowser(uri), ns => ns.SendLaunchBrowser(uri)); } - [Theory] - [InlineData(0x1000u, 1000, 100, 10, 0x2000u, 1125, 125, 5, 0x384, 1024, 25)] - public void TestDragEffect( - uint srcSerial, int srcX, int srcY, int srcZ, - uint trgSerial, int trgX, int trgY, int trgZ, - int itemId, int hue, int amount - ) + [Fact] + public void TestDragEffect() { - var src = new Entity((Serial)srcSerial, new Point3D(srcX, srcY, srcZ), null); - var targ = new Entity((Serial)trgSerial, new Point3D(trgX, trgY, trgZ), null); + var src = new Entity((Serial)0x1000, new Point3D(1000, 100, 10), null); + var targ = new Entity((Serial)0x2000, new Point3D(1125, 125, 5), null); + const int itemId = 0x384; + const int hue = 1024; + const int amount = 25; - var expected = new DragEffect(src, targ, itemId, hue, amount).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDragEffect( - src.Serial, src.Location, - targ.Serial, targ.Location, - itemId, hue, amount + PacketAssert.Sends( + new DragEffect(src, targ, itemId, hue, amount), + ns => ns.SendDragEffect(src.Serial, src.Location, targ.Serial, targ.Location, itemId, hue, amount) ); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); } [Theory] @@ -184,13 +117,7 @@ public class PlayerPacketTests [InlineData(2, true)] public void TestSeasonChange(int season, bool playSound) { - var expected = new SeasonChange(season, playSound).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSeasonChange((byte)season, playSound); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SeasonChange(season, playSound), ns => ns.SendSeasonChange((byte)season, playSound)); } [Theory] @@ -199,13 +126,10 @@ public class PlayerPacketTests [InlineData(0x1024u, "Test Title", true, false)] public void TestDisplayPaperdoll(uint m, string title, bool warmode, bool canLift) { - var expected = new DisplayPaperdoll((Serial)m, title, warmode, canLift).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayPaperdoll((Serial)m, title, warmode, canLift); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayPaperdoll((Serial)m, title, warmode, canLift), + ns => ns.SendDisplayPaperdoll((Serial)m, title, warmode, canLift) + ); } [Theory] @@ -214,13 +138,7 @@ public class PlayerPacketTests [InlineData(MusicName.ValoriaShips)] public void TestPlayMusic(MusicName music) { - var expected = new PlayMusic(music).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPlayMusic(music); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PlayMusic(music), ns => ns.SendPlayMusic(music)); } [Theory] @@ -228,41 +146,23 @@ public class PlayerPacketTests [InlineData(100, 10, "Some more text")] public void TestScrollMessage(int type, int tip, string text) { - var expected = new ScrollMessage(type, tip, text).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendScrollMessage(type, tip ,text); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new ScrollMessage(type, tip, text), ns => ns.SendScrollMessage(type, tip, text)); } - [Theory] - [InlineData(14, 10, 05)] - public void TestCurrentTime(int hour, int minute, int second) + [Fact] + public void TestCurrentTime() { - var date = new DateTime(2020, 1, 1, hour, minute, second); - var expected = new CurrentTime(date).Compile(); + var date = new DateTime(2020, 1, 1, 14, 10, 5); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCurrentTime(date); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CurrentTime(date), ns => ns.SendCurrentTime(date)); } - [Theory] - [InlineData(1000, 10, 1)] - public void TestPathfindMessage(int x, int y, int z) + [Fact] + public void TestPathfindMessage() { - var p = new Point3D(x, y, z); - var expected = new PathfindMessage(p).Compile(); + var p = new Point3D(1000, 10, 1); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPathfindMessage(p); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PathfindMessage(p), ns => ns.SendPathfindMessage(p)); } [Theory] @@ -271,27 +171,17 @@ public class PlayerPacketTests [InlineData(100)] public void TestPingAck(byte ping) { - var expected = new PingAck(ping).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPingAck(ping); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new PingAck(ping), ns => ns.SendPingAck(ping)); } - [Theory] - [InlineData(0xFF01)] - public void TestDisplayHuePicker(int itemID) + [Fact] + public void TestDisplayHuePicker() { - var huePicker = new HuePicker(itemID); + var huePicker = new HuePicker(0xFF01); - var expected = new DisplayHuePicker(huePicker).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplayHuePicker(huePicker.Serial, huePicker.ItemID); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplayHuePicker(huePicker), + ns => ns.SendDisplayHuePicker(huePicker.Serial, huePicker.ItemID) + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs index 6c10b63e9..ca2934b0c 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/SecureTradePacketTests.cs @@ -18,13 +18,10 @@ public class SecureTradePacketTests var firstCont = new Container(World.NewItem); var secondCont = new Container(World.NewItem); - var expected = new DisplaySecureTrade(m, firstCont, secondCont, name).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendDisplaySecureTrade(m, firstCont, secondCont, name); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new DisplaySecureTrade(m, firstCont, secondCont, name), + ns => ns.SendDisplaySecureTrade(m, firstCont, secondCont, name) + ); } [Fact] @@ -32,13 +29,7 @@ public class SecureTradePacketTests { var cont = new Container(World.NewItem); - var expected = new CloseSecureTrade(cont).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCloseSecureTrade(cont); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CloseSecureTrade(cont), ns => ns.SendCloseSecureTrade(cont)); } [Theory, InlineData(true, false), InlineData(false, true)] @@ -50,13 +41,11 @@ public class SecureTradePacketTests var secondCont = new Container(World.NewItem); var cont = first ? firstCont : secondCont; - var expected = new UpdateSecureTrade(cont, first, second).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendUpdateSecureTrade(cont, first, second); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new UpdateSecureTrade(cont, first, second), + ns => ns.SendUpdateSecureTrade(cont, first, second) + ); } [Theory] @@ -65,13 +54,11 @@ public class SecureTradePacketTests public void TestUpdateGoldSecureTrade(int gold, int plat, TradeFlag flag) { var cont = new Container(World.NewItem); - var expected = new UpdateSecureTrade(cont, flag, gold, plat).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendUpdateSecureTrade(cont, flag, gold, plat); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new UpdateSecureTrade(cont, flag, gold, plat), + ns => ns.SendUpdateSecureTrade(cont, flag, gold, plat) + ); } [Fact] @@ -83,13 +70,7 @@ public class SecureTradePacketTests var cont = new Container(World.NewItem); var itemInCont = new Item(World.NewItem) { Parent = cont }; - var expected = new SecureTradeEquip(itemInCont, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendSecureTradeEquip(itemInCont, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new SecureTradeEquip(itemInCont, m), ns => ns.SendSecureTradeEquip(itemInCont, m)); } [Fact] @@ -101,14 +82,10 @@ public class SecureTradePacketTests var cont = new Container(World.NewItem); var itemInCont = new Item(World.NewItem) { Parent = cont }; - var expected = new SecureTradeEquip6017(itemInCont, m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; - - ns.SendSecureTradeEquip(itemInCont, m); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new SecureTradeEquip6017(itemInCont, m), + ns => ns.SendSecureTradeEquip(itemInCont, m), + ProtocolChanges.ContainerGridLines + ); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs index c2d4d29fc..4cc8112b9 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/TargetPacketsTests.cs @@ -38,14 +38,7 @@ public class TargetPacketsTests var p = new Point3D(1000, 100, 10); MultiTarget t = new TestMultiTarget(multiID, p); - var expected = new MultiTargetReqHS(t).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges |= ProtocolChanges.HighSeas; - ns.SendMultiTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MultiTargetReqHS(t), ns => ns.SendMultiTargetReq(t), ProtocolChanges.HighSeas); } [Fact] @@ -55,36 +48,20 @@ public class TargetPacketsTests var p = new Point3D(1000, 100, 10); MultiTarget t = new TestMultiTarget(multiID, p); - var expected = new MultiTargetReq(t).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendMultiTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new MultiTargetReq(t), ns => ns.SendMultiTargetReq(t)); } [Fact] public void TestCancelTarget() { - var expected = new CancelTarget().Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCancelTarget(); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new CancelTarget(), ns => ns.SendCancelTarget()); } [Fact] public void TestTargetReq() { var t = new TestTarget(10, true, TargetFlags.Beneficial); - var expected = new TargetReq(t).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendTargetReq(t); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new TargetReq(t), ns => ns.SendTargetReq(t)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs index 6262cc243..10f498a20 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorBuyPacketTests.cs @@ -22,15 +22,11 @@ public class VendorBuyPacketTests new("Third Item", cont.Serial, World.NewItem, 30, 10, 0x0F, 0) }; - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - - var expected = new VendorBuyContent(buyStates, ns.ContainerGridLines).Compile(); - - ns.SendVendorBuyContent(buyStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new VendorBuyContent(buyStates, ns.ContainerGridLines), + ns => ns.SendVendorBuyContent(buyStates), + protocolChanges + ); } [Theory] @@ -41,15 +37,11 @@ public class VendorBuyPacketTests var vendor = new Mobile((Serial)0x1); vendor.DefaultMobileInit(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = protocolChanges; - - var expected = new DisplayBuyList(vendor.Serial, ns.HighSeas).Compile(); - - ns.SendDisplayBuyList(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + ns => new DisplayBuyList(vendor.Serial, ns.HighSeas), + ns => ns.SendDisplayBuyList(vendor.Serial), + protocolChanges + ); } [Fact] @@ -67,13 +59,7 @@ public class VendorBuyPacketTests new("Third Item", cont.Serial, World.NewItem, 30, 10, 0x0F, 0) }; - var expected = new VendorBuyList(vendor, buyStates).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendVendorBuyList(vendor, buyStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new VendorBuyList(vendor, buyStates), ns => ns.SendVendorBuyList(vendor, buyStates)); } [Fact] @@ -82,12 +68,6 @@ public class VendorBuyPacketTests var vendor = new Mobile((Serial)0x1); vendor.DefaultMobileInit(); - var expected = new EndVendorBuy(vendor.Serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEndVendorBuy(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EndVendorBuy(vendor.Serial), ns => ns.SendEndVendorBuy(vendor.Serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs index 154223349..0b911e6bb 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPacketTests.cs @@ -25,13 +25,10 @@ public class VendorSellPacketTests new(item3, 1, "Item 3") }; - var expected = new VendorSellList(vendor, sellStates.ToList()).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendVendorSellList(vendor.Serial, sellStates); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new VendorSellList(vendor, sellStates.ToList()), + ns => ns.SendVendorSellList(vendor.Serial, sellStates) + ); } [Fact] @@ -40,12 +37,6 @@ public class VendorSellPacketTests var vendor = new Mobile((Serial)0x1024u); vendor.DefaultMobileInit(); - var expected = new EndVendorBuy(vendor.Serial).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendEndVendorSell(vendor.Serial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new EndVendorSell(vendor), ns => ns.SendEndVendorSell(vendor.Serial)); } } diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs index 3a105d1a4..2d70896f4 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPacketTests.cs @@ -15,13 +15,10 @@ public class VirtualHairPacketTests m.HairHue = 0x1000; m.HairItemID = 0x2000; - var expected = new HairEquipUpdate(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendHairEquipUpdatePacket(m, (uint)m.HairSerial, m.HairItemID, m.HairHue, Layer.Hair); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends( + new HairEquipUpdate(m), + ns => ns.SendHairEquipUpdatePacket(m, (uint)m.HairSerial, m.HairItemID, m.HairHue, Layer.Hair) + ); } [Fact] @@ -30,13 +27,7 @@ public class VirtualHairPacketTests var m = new Mobile((Serial)0x1024u); m.DefaultMobileInit(); - var expected = new RemoveHair(m).Compile(); - - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendRemoveHairPacket((uint)m.HairSerial); - - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + PacketAssert.Sends(new RemoveHair(m), ns => ns.SendRemoveHairPacket((uint)m.HairSerial)); } [Fact] diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs index 135a6e698..ac7ebbf59 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VirtualHairPackets.cs @@ -16,22 +16,6 @@ public sealed class HairEquipUpdate : Packet } } -public sealed class FacialHairEquipUpdate : Packet -{ - public FacialHairEquipUpdate(Mobile parent) - : base(0x2E, 15) - { - var hue = parent.SolidHueOverride >= 0 ? parent.SolidHueOverride : parent.FacialHairHue; - - Stream.Write(parent.FacialHairSerial); - Stream.Write((short)parent.FacialHairItemID); - Stream.Write((byte)0); - Stream.Write((byte)Layer.FacialHair); - Stream.Write(parent.Serial); - Stream.Write((short)hue); - } -} - public sealed class RemoveHair : Packet { public RemoveHair(Mobile parent) @@ -40,12 +24,3 @@ public sealed class RemoveHair : Packet Stream.Write(parent.HairSerial); } } - -public sealed class RemoveFacialHair : Packet -{ - public RemoveFacialHair(Mobile parent) - : base(0x1D, 5) - { - Stream.Write(parent.FacialHairSerial); - } -} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs index 19a8704b7..6dbef52b1 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListHashTests.cs @@ -102,36 +102,31 @@ public class ObjectPropertyListHashTests // 6- and 8-byte blocks take xxHash3's short-input paths. They still avalanche across all 26 // kept bits, so a counter ticking down never repeats the revision it just had. [Fact] - public void ShortNumericArguments_ConsecutiveValuesDiffer() + public void ShortPropertyBlocks_ConsecutiveValuesDifferAndStayWellDistributed() { - var previous = BuildHash((1060584, "0")); - - for (var charges = 1; charges < 20000; charges++) - { - var current = BuildHash((1060584, charges.ToString())); - Assert.NotEqual(previous, current); - previous = current; - } - } - - [Fact] - public void SmallPropertyBlocks_StayWellDistributed() - { - const int count = 20000; + const int count = 5000; var withArgument = new HashSet(); var withoutArgument = new HashSet(); + var previous = 0; for (var i = 0; i < count; i++) { - withArgument.Add(BuildHash((1060584, i.ToString()))); + var current = BuildHash((1060584, i.ToString())); + if (i > 0) + { + Assert.NotEqual(previous, current); + } + + previous = current; + withArgument.Add(current); withoutArgument.Add(BuildHash((1060000 + i, null))); } - // Birthday expects ~3 collisions over a 26-bit space; allow an order of magnitude so the - // bound holds for any seed. A hash that stopped mixing collapses far past it. - Assert.True(withArgument.Count >= count - 30, $"8-byte blocks: {withArgument.Count}/{count}"); - Assert.True(withoutArgument.Count >= count - 30, $"6-byte blocks: {withoutArgument.Count}/{count}"); + // Birthday expects ~0.2 collisions over a 26-bit space; allow a few so the bound holds for + // any seed. A hash that stopped mixing collapses far past it. + Assert.True(withArgument.Count >= count - 5, $"8-byte blocks: {withArgument.Count}/{count}"); + Assert.True(withoutArgument.Count >= count - 5, $"6-byte blocks: {withoutArgument.Count}/{count}"); } [Fact] diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs index a99b9850c..74edf09c3 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; using Xunit; namespace Server.Tests; @@ -107,83 +105,48 @@ public class AnchoredTimePersistenceTests [Fact] public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad() { - var previousAssemblies = AssemblyHandler.Assemblies; - AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly]; + // The scope also restores World.SaveStartTime, which this test backdates. + using var pipeline = new PersistencePipelineScope( + 2, + installWorkers: true, + visibleAssembly: typeof(AnchoredEntity).Assembly, + directoryPrefix: "muo-anchored" + ); + var source = pipeline.Source; + var dir = pipeline.Directory; - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) - { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); - } + var persistence = pipeline.Track(new AnchoredPersistence(2100)); - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + var lastRested = Core.Now - TimeSpan.FromMinutes(10); + var serial = (Serial)1u; + persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; + persistence.RegisterType(typeof(AnchoredEntity)); - var previousSaveStart = World.SaveStartTime; + // Pretend the save started two hours ago, as if the server had been down since. + var downtime = TimeSpan.FromHours(2); + World.SaveStartTime = Core.Now - downtime; - var persistence = new AnchoredPersistence(2100); - AnchoredPersistence loaded = null; + pipeline.WakeWorkers(); - var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - try - { - var lastRested = Core.Now - TimeSpan.FromMinutes(10); - var serial = (Serial)1u; - persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; - persistence.RegisterType(typeof(AnchoredEntity)); + pipeline.FlushAndSleepWorkers(); - // Pretend the save started two hours ago, as if the server had been down since. - var downtime = TimeSpan.FromHours(2); - World.SaveStartTime = Core.Now - downtime; + persistence.WriteSnapshot(dir); + persistence.PostWorldSave(); - foreach (var worker in workers) - { - worker.Wake(); - } + var loaded = pipeline.Track(new AnchoredPersistence(2101)); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); + var entity = loaded.EntitiesBySerial[serial]; + var expected = lastRested + downtime; - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } - - persistence.WriteSnapshot(dir); - persistence.PostWorldSave(); - - loaded = new AnchoredPersistence(2101); - loaded.DeserializeIndexes(dir, null); - loaded.Deserialize(dir, null); - - var entity = loaded.EntitiesBySerial[serial]; - var expected = lastRested + downtime; - - Assert.True( - (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), - $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." - ); - } - finally - { - World.SaveStartTime = previousSaveStart; - persistence.Unregister(); - loaded?.Unregister(); - - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - AssemblyHandler.Assemblies = previousAssemblies; - Directory.Delete(dir, true); - } + Assert.True( + (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), + $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." + ); } } diff --git a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs index 48ba16e55..c8c4522aa 100644 --- a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceRoundTripTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; using Xunit; namespace Server.Tests; @@ -72,103 +70,68 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void SnapshotRoundTripsThroughWorkersAndSegmentLogs() { - // The loader resolves types by hash through AssemblyHandler; make this test - // assembly visible for the duration of the test. - var previousAssemblies = AssemblyHandler.Assemblies; - AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(RoundTripEntity).Assembly]; + // The loader resolves types by hash through AssemblyHandler, so this test assembly must be visible. + using var pipeline = new PersistencePipelineScope( + 3, + installWorkers: true, + visibleAssembly: typeof(RoundTripEntity).Assembly, + directoryPrefix: "muo-roundtrip" + ); + var source = pipeline.Source; + var dir = pipeline.Directory; - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[3]; - for (var i = 0; i < workers.Length; i++) + var persistence = pipeline.Track(new RoundTripPersistence(2000)); + + const int entityCount = 25_000; + var rng = new System.Random(0x5EED); + + for (var i = 1; i <= entityCount; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) + { + Value = rng.Next(), + Name = i % 5 == 0 ? null : $"entity-{i}" + }; } - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + persistence.RegisterType(typeof(RoundTripEntity)); - var persistence = new RoundTripPersistence(2000); - RoundTripPersistence loaded = null; + // Freeze: what Persistence.SerializeAll + GenericEntityPersistence.Serialize do, + // against this test's chunk source instead of the world's. + pipeline.WakeWorkers(); - var dir = Path.Combine(Path.GetTempPath(), $"muo-roundtrip-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + source.SetOwner(persistence); + source.PushSingle(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - try + pipeline.FlushAndSleepWorkers(); + + // Background write phase: snapshot from the segment logs. v4 embeds the type + // table in the idx, so no SerializedTypes.db is produced or needed. + persistence.WriteSnapshot(dir); + + persistence.PostWorldSave(); // releases the entries snapshot + + // Load into a fresh persistence, like a server boot would. + var loaded = pipeline.Track(new RoundTripPersistence(2001)); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); + + Assert.True(loaded.SelfPayloadDeserialized); + Assert.Equal(persistence.EntitiesBySerial.Count, loaded.EntitiesBySerial.Count); + + foreach (var (serial, original) in persistence.EntitiesBySerial) { - const int entityCount = 25_000; - var rng = new System.Random(0x5EED); - - for (var i = 1; i <= entityCount; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) - { - Value = rng.Next(), - Name = i % 5 == 0 ? null : $"entity-{i}" - }; - } - - persistence.RegisterType(typeof(RoundTripEntity)); - - // Freeze: what Persistence.SerializeAll + GenericEntityPersistence.Serialize do, - // against this test's chunk source instead of the world's. - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - source.PushSingle(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - - // Mirrors World.PauseSerializationThreads - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } - - // Background write phase: snapshot from the segment logs. v4 embeds the type - // table in the idx, so no SerializedTypes.db is produced or needed. - persistence.WriteSnapshot(dir); - - persistence.PostWorldSave(); // releases the entries snapshot - - // Load into a fresh persistence, like a server boot would. - loaded = new RoundTripPersistence(2001); - loaded.DeserializeIndexes(dir, null); - loaded.Deserialize(dir, null); - - Assert.True(loaded.SelfPayloadDeserialized); - Assert.Equal(persistence.EntitiesBySerial.Count, loaded.EntitiesBySerial.Count); - - foreach (var (serial, original) in persistence.EntitiesBySerial) - { - var entity = loaded.EntitiesBySerial[serial]; - Assert.Equal(original.Value, entity.Value); - Assert.Equal(original.Name, entity.Name); - Assert.Equal(original.Created.Ticks, entity.Created.Ticks); - } - - // Loading must hydrate the type table so the next save can write indexes. - Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _)); + var entity = loaded.EntitiesBySerial[serial]; + Assert.Equal(original.Value, entity.Value); + Assert.Equal(original.Name, entity.Name); + Assert.Equal(original.Created.Ticks, entity.Created.Ticks); } - finally - { - persistence.Unregister(); - loaded?.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - AssemblyHandler.Assemblies = previousAssemblies; - Directory.Delete(dir, true); - } + // Loading must hydrate the type table so the next save can write indexes. + Assert.True(loaded.TryGetTypeIndex(typeof(RoundTripEntity), out _)); } /// @@ -179,70 +142,44 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void SerializerException_IsRecordedOnTheWorker_AndTheDrainCompletes() { - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2); + var source = pipeline.Source; + var workers = pipeline.Workers; + + var persistence = pipeline.Track(new RoundTripPersistence(2002)); + + for (var i = 1; i <= 100; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = i == 50 + ? new ThrowingRoundTripEntity(serial) + : new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; } - var persistence = new RoundTripPersistence(2002); + pipeline.WakeWorkers(); - try + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); + + pipeline.FlushAndSleepWorkers(); + + Exception error = null; + foreach (var worker in workers) { - for (var i = 1; i <= 100; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = i == 50 - ? new ThrowingRoundTripEntity(serial) - : new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; - } - - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - source.Flush(); - - foreach (var worker in workers) - { - worker.Sleep(); - } - - Exception error = null; - foreach (var worker in workers) - { - error ??= worker.Error; - } - - Assert.IsType(error); - persistence.PostWorldSave(); - - // The next drain starts clean. - foreach (var worker in workers) - { - worker.Wake(); - } - - foreach (var worker in workers) - { - worker.Sleep(); - Assert.Null(worker.Error); - } + error ??= worker.Error; } - finally - { - persistence.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } + Assert.IsType(error); + persistence.PostWorldSave(); + + // The next drain starts clean. + pipeline.WakeWorkers(); + + foreach (var worker in workers) + { + worker.Sleep(); + Assert.Null(worker.Error); } } @@ -253,60 +190,28 @@ public class GenericEntityPersistenceRoundTripTests [Fact] public void WriteSnapshot_FailsTheSave_InsteadOfDroppingASegment() { - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2, installWorkers: true, directoryPrefix: "muo-segmentfail"); + var source = pipeline.Source; + + var persistence = pipeline.Track(new RoundTripPersistence(2003)); + + for (var i = 1; i <= 100; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + var serial = (Serial)(uint)i; + persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; } - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; + // Deliberately not registered: the writer cannot index the type. - var persistence = new RoundTripPersistence(2003); - var dir = Path.Combine(Path.GetTempPath(), $"muo-segmentfail-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); + pipeline.WakeWorkers(); - try - { - for (var i = 1; i <= 100; i++) - { - var serial = (Serial)(uint)i; - persistence.EntitiesBySerial[serial] = new RoundTripEntity(serial) { Value = i, Name = $"entity-{i}" }; - } + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); - // Deliberately not registered: the writer cannot index the type. + pipeline.FlushAndSleepWorkers(); - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - source.Flush(); - - foreach (var worker in workers) - { - worker.Sleep(); - } - - Assert.Throws(() => persistence.WriteSnapshot(dir)); - persistence.PostWorldSave(); - } - finally - { - persistence.Unregister(); - - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - Directory.Delete(dir, true); - } + Assert.Throws(() => persistence.WriteSnapshot(pipeline.Directory)); + persistence.PostWorldSave(); } } diff --git a/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs b/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs index 69387f4e6..24fea88a7 100644 --- a/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/SerializationChunkSourceTests.cs @@ -199,114 +199,91 @@ public class SerializationChunkSourceTests [Fact] public void WorkersDrainAllEntitiesAndLogSegments() { - var source = new SerializationChunkSource(); - var owner = new TestPersistence { PayloadSize = 512 * 1024 }; - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) + using var pipeline = new PersistencePipelineScope(2); + var source = pipeline.Source; + var workers = pipeline.Workers; + var owner = pipeline.Track(new TestPersistence { PayloadSize = 512 * 1024 }); + + var entities = new List(); + for (var i = 0; i < 10_000; i++) { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); + entities.Add(new TestEntity { PayloadSize = 16 + i % 64 }); } - try + pipeline.WakeWorkers(); + + // A large payload published as a dedicated single chunk (a persistence + // self-payload), interleaved with the bare entity stream. + source.SetOwner(owner); + for (var i = 0; i < entities.Count; i++) { - var entities = new List(); - for (var i = 0; i < 10_000; i++) + if (i == 5000) { - entities.Add(new TestEntity { PayloadSize = 16 + i % 64 }); + source.PushSingle(owner); } - foreach (var worker in workers) - { - worker.Wake(); - } + source.Push(entities[i]); + } - // A large payload published as a dedicated single chunk (a persistence - // self-payload), interleaved with the bare entity stream. - source.SetOwner(owner); - for (var i = 0; i < entities.Count; i++) + pipeline.FlushAndSleepWorkers(); + + long totalEntities = 0; + long totalBytes = 0; + foreach (var worker in workers) + { + totalEntities += worker.EntitiesSerialized; + totalBytes += worker.BytesSerialized; + } + + Assert.Equal(entities.Count + 1, totalEntities); + + // The self-payload recorded its placement on the persistence itself. + var (selfThread, selfPosition, selfLength) = owner.Placement; + Assert.Equal(owner.PayloadSize, selfLength); + Assert.InRange(selfThread, (byte)0, (byte)(workers.Length - 1)); + var selfHeap = workers[selfThread].GetHeap(selfPosition, selfLength); + Assert.Equal(0, selfHeap[0]); + Assert.Equal((selfLength - 1) & 0xFF, selfHeap[^1]); + + // Every entity appears exactly once in the worker segment logs, with a + // consistent span on that worker's heap: positions are implicit (contiguous + // writes), identity comes from the buffer-entities log. + var seen = new HashSet(); + long expectedBytes = owner.PayloadSize; + foreach (var e in entities) + { + expectedBytes += e.PayloadSize; + } + + foreach (var worker in workers) + { + var lengths = worker.Lengths; + var bufferEntities = worker.BufferEntities; + + foreach (var segment in worker.Segments) { - if (i == 5000) + Assert.Same(owner, segment.Owner); + Assert.Equal(-1, segment.SlotOffset); + + var heapPos = (int)segment.HeapStart; + for (var i = 0; i < segment.RecordCount; i++) { - source.PushSingle(owner); - } + var entity = (TestEntity)bufferEntities[segment.EntitiesStart + i]; + var length = lengths[segment.LengthsStart + i]; - source.Push(entities[i]); - } + Assert.True(seen.Add(entity)); + Assert.Equal(entity.PayloadSize, length); - // Mirrors World.PauseSerializationThreads - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } + var heap = workers[Array.IndexOf(workers, worker)].GetHeap(heapPos, length); + Assert.Equal(0, heap[0]); + Assert.Equal((length - 1) & 0xFF, heap[^1]); - long totalEntities = 0; - long totalBytes = 0; - foreach (var worker in workers) - { - totalEntities += worker.EntitiesSerialized; - totalBytes += worker.BytesSerialized; - } - - Assert.Equal(entities.Count + 1, totalEntities); - - // The self-payload recorded its placement on the persistence itself. - var (selfThread, selfPosition, selfLength) = owner.Placement; - Assert.Equal(owner.PayloadSize, selfLength); - Assert.InRange(selfThread, (byte)0, (byte)(workers.Length - 1)); - var selfHeap = workers[selfThread].GetHeap(selfPosition, selfLength); - Assert.Equal(0, selfHeap[0]); - Assert.Equal((selfLength - 1) & 0xFF, selfHeap[^1]); - - // Every entity appears exactly once in the worker segment logs, with a - // consistent span on that worker's heap: positions are implicit (contiguous - // writes), identity comes from the buffer-entities log. - var seen = new HashSet(); - long expectedBytes = owner.PayloadSize; - foreach (var e in entities) - { - expectedBytes += e.PayloadSize; - } - - foreach (var worker in workers) - { - var lengths = worker.Lengths; - var bufferEntities = worker.BufferEntities; - - foreach (var segment in worker.Segments) - { - Assert.Same(owner, segment.Owner); - Assert.Equal(-1, segment.SlotOffset); - - var heapPos = (int)segment.HeapStart; - for (var i = 0; i < segment.RecordCount; i++) - { - var entity = (TestEntity)bufferEntities[segment.EntitiesStart + i]; - var length = lengths[segment.LengthsStart + i]; - - Assert.True(seen.Add(entity)); - Assert.Equal(entity.PayloadSize, length); - - var heap = workers[Array.IndexOf(workers, worker)].GetHeap(heapPos, length); - Assert.Equal(0, heap[0]); - Assert.Equal((length - 1) & 0xFF, heap[^1]); - - heapPos += length; - } + heapPos += length; } } + } - Assert.Equal(entities.Count, seen.Count); - Assert.Equal(expectedBytes, totalBytes); - } - finally - { - owner.Unregister(); - foreach (var worker in workers) - { - worker.Exit(); - } - } + Assert.Equal(entities.Count, seen.Count); + Assert.Equal(expectedBytes, totalBytes); } } diff --git a/Projects/Server.Tests/Tests/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/UOContent.Tests/Helpers/AiTestStubs.cs b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs new file mode 100644 index 000000000..9d954c062 --- /dev/null +++ b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs @@ -0,0 +1,42 @@ +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; +} + +public static class TestTiles +{ + /// + /// The first item ID whose tile data is an impassable surface, so a test can wall a cell off + /// without pinning a specific graphic. 0 when tile data is not loaded. + /// + public static ushort FirstImpassableItemId() + { + for (ushort id = 1; id < TileData.MaxItemValue; id++) + { + if (TileData.ItemTable[id].ImpassableSurface) + { + return id; + } + } + + return 0; + } +} 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/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 df499509e..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] 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 dc16a7f5b..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] diff --git a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs index 80e664647..dbeba969e 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ChainedBindingSortTests.cs @@ -4,6 +4,7 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; @@ -14,31 +15,13 @@ namespace UOContent.Tests.Commands; [Collection("Sequential UOContent Tests")] public class ChainedBindingSortTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - _items.Clear(); - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } - - private static Property Bind(string binding) - { - var prop = new Property(binding); - prop.BindTo(typeof(SkillTeleporter), PropertyAccess.Read); - return prop; - } + private static Property Bind(string binding) => CommandTestHarness.Bind(typeof(SkillTeleporter), binding); private static IComparer Sorter(string binding) => SortCompiler.Compile( @@ -107,6 +90,4 @@ public class ChainedBindingSortTests : IDisposable Assert.True(comparer.Compare(blank, valued) < 0); Assert.True(comparer.Compare(valued, blank) > 0); } - - // An unchained binding never had the problem and must keep working untouched. } diff --git a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs index f430973a9..31b1de29d 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ConditionalCompilerEdgeTests.cs @@ -4,42 +4,25 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; -// Two shapes the IL emitter got wrong or could not express. A chained binding ending in a value -// type (Message.Number) reused a temp while its value was still live, so every pair compared -// equal and `sort by` was a no-op. A struct with no CompareTo fell through to a raw Ceq, which is -// not valid IL for a non-primitive struct. Both are pinned here against the expression-tree port. +// Two shapes the compiled comparers must handle. A chained binding ending in a value type +// (Message.Number) must order and separate by that value, or every pair compares equal and +// `sort by` does nothing. A struct with no CompareTo compares by value equality for == and !=, +// and the relational operators are rejected when the condition is compiled. [Collection("Sequential UOContent Tests")] public class ConditionalCompilerEdgeTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - _items.Clear(); - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } - - private static Property Bind(Type type, string binding) - { - var prop = new Property(binding); - prop.BindTo(type, PropertyAccess.Read); - return prop; - } + private static Property Bind(Type type, string binding) => CommandTestHarness.Bind(type, binding); [Fact] public void SortingOnAChainedValueTypeOrdersValues() diff --git a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs index e1068f6e2..c135480da 100644 --- a/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/DistinctComparerTests.cs @@ -4,6 +4,7 @@ using Server; using Server.Commands; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; @@ -14,23 +15,14 @@ namespace UOContent.Tests.Commands; [Collection("Sequential UOContent Tests")] public class DistinctComparerTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } - - _items.Clear(); - } + public void Dispose() => _harness.Dispose(); private SkillTeleporter Teleporter(Action setup = null) { - var tp = new SkillTeleporter(); + var tp = _harness.Track(new SkillTeleporter()); setup?.Invoke(tp); - _items.Add(tp); return tp; } @@ -40,8 +32,7 @@ public class DistinctComparerTests : IDisposable for (var i = 0; i < bindings.Length; i++) { - props[i] = new Property(bindings[i]); - props[i].BindTo(typeof(SkillTeleporter), PropertyAccess.Read); + props[i] = CommandTestHarness.Bind(typeof(SkillTeleporter), bindings[i]); } return (IEqualityComparer)DistinctCompiler.Compile(typeof(SkillTeleporter), props); diff --git a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs index ef10033eb..e8cef86c6 100644 --- a/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/EmitterRobustnessTests.cs @@ -6,10 +6,8 @@ using Xunit; namespace UOContent.Tests.Commands; -// The conditional compiler emits into a fresh dynamic assembly for every command invocation -- -// there is no per-type cache, so `Run` (which can never be unloaded) grows the process by one -// assembly per `[global where`. And PropertyValue could only load a handful of primitive constant -// types, so a comparison against anything narrower or unsigned than int threw instead of running. +// A comparison constant is converted to the property's own type, however narrow or unsigned, so +// conditions on byte, sbyte, short, ushort, uint and ulong properties run and compare correctly. public class EmitterRobustnessTests { public class Subject diff --git a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs index 3d43eca42..3088e2d26 100644 --- a/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/ObjectConditionalTests.cs @@ -3,37 +3,27 @@ using System.Collections.Generic; using Server; using Server.Commands.Generic; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands; -// `where` compiles conditions to IL. Two shapes were broken there: a property type with value -// equality but no IComparable fell through to a raw Ceq (reference equality, so never true), and -// a chained binding dereferenced every link with no null guard (so the first object with a null -// intermediate took the whole sweep down with an NRE). +// `where` compiles conditions into a delegate. A property type with value equality but no +// IComparable compares by value, not reference identity, and a chained binding with a null +// intermediate makes the condition false rather than throwing and ending the whole sweep. [Collection("Sequential UOContent Tests")] public class ObjectConditionalTests : IDisposable { - private readonly List _items = []; + private readonly CommandTestHarness _harness = new(); private readonly Mobile _from = new() { AccessLevel = AccessLevel.Developer }; public void Dispose() { - for (var i = 0; i < _items.Count; i++) - { - _items[i].Delete(); - } - - _items.Clear(); + _harness.Dispose(); _from.Delete(); } - private SkillTeleporter Teleporter(TextDefinition message) - { - var tp = new SkillTeleporter { Message = message }; - _items.Add(tp); - return tp; - } + private SkillTeleporter Teleporter(TextDefinition message) => _harness.Teleporter(message); private bool Check(object target, params string[] condition) { diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs index df795a440..cbd818c40 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchConditionsTests.cs @@ -58,8 +58,7 @@ public class AdvancedSearchConditionsTests [InlineData("Hue")] // no operator public void UnusableLeafIsNoMatchAndDoesNotThrow(string test) { - var ex = Record.Exception(() => Assert.False(Check(test))); - Assert.Null(ex); + Assert.False(Check(test)); } // The old evaluator negated a failed comparison, so `~Hue=abc` matched every entity. @@ -195,8 +194,7 @@ public class AdvancedSearchConditionsTests [Fact] public void ReferenceTypeOrderingIsNoMatchAndDoesNotThrow() { - var ex = Record.Exception(() => Assert.False(Check("Reference>whatever"))); - Assert.Null(ex); + Assert.False(Check("Reference>whatever")); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs index 36f97eaf0..a7149eeae 100644 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace UOContent.Tests; +// Registers the poison table, which is process-wide, so this stays in the sequential collection. [Collection("Sequential UOContent Tests")] public class AdvancedSearchTypesTests { @@ -15,7 +16,11 @@ public class AdvancedSearchTypesTests [Fact] public void Poison_ReferenceTypeParsedViaTypes() { - PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set + // The fixture does not run PoisonKinds.Configure, and a second registration throws. + if (Poison.Poisons.Count == 0) + { + PoisonKinds.Configure(); + } // Poison is a reference type implementing ISpanParsable; its value routes through the shared // Server.Types converter. Poison.Parse returns the registered singleton, so "= Lethal" is a @@ -26,8 +31,6 @@ public class AdvancedSearchTypesTests Assert.True(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lethal")(subject)); Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=Lesser")(subject)); - var ex = Record.Exception(() => - Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject))); - Assert.Null(ex); + Assert.False(AdvancedSearchConditions.Compile(typeof(Subject), "Venom=notapoison")(subject)); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs index addfea7d0..4927d6813 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs @@ -2,13 +2,19 @@ using System; using System.Collections.Generic; using Server; using Server.Engines.Events; +using Server.Tests; using Xunit; namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] -public class EventSchedulerTests +public class EventSchedulerTests : IDisposable { + // Init() pins the process-wide loop clock and timer wheel; every other test in the host reads them. + private readonly TimerWheelScope _clock = new(); + + public void Dispose() => _clock.Dispose(); + // Test implementations for controlled testing private class TestRecurrencePattern : IRecurrencePattern { @@ -58,7 +64,6 @@ public class EventSchedulerTests private static void Init() { Core._now = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); - Timer.Init(0); EventScheduler.Configure(); } @@ -148,39 +153,36 @@ public class EventSchedulerTests [Fact] public void Scheduler_HandlesRecurringEvents() { - for (var i = 0; i < 100_000; i++) + Init(); + + try { - Init(); + var callCount = 0; - try - { - var callCount = 0; + // Create a recurrence pattern that fires every 10 seconds, up to 3 times + var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3); - // Create a recurrence pattern that fires every 10 seconds, up to 3 times - var recurrence = new TestRecurrencePattern(TimeSpan.FromSeconds(10), 3); + var evt = new TestScheduledEvent( + TimeOnly.FromDateTime(Core._now), + () => callCount++, + recurrence + ); - var evt = new TestScheduledEvent( - TimeOnly.FromDateTime(Core._now), - () => callCount++, - recurrence - ); + evt.Schedule(Core._now); - evt.Schedule(Core._now); + // Advance time to after all occurrences should have happened + Core._now = Core._now.AddSeconds(50); + Timer.Slice(8); - // Advance time to after all occurrences should have happened - Core._now = Core._now.AddSeconds(50); - Timer.Slice(8); + // One call per occurrence the pattern yields + Assert.Equal(3, callCount); + Assert.Equal(3, evt.CallCount); - // Should have fired 4 times (3 recurrences) - Assert.Equal(3, callCount); - Assert.Equal(3, evt.CallCount); - - evt.Cancel(); - } - finally - { - Finish(); - } + evt.Cancel(); + } + finally + { + Finish(); } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs index bd3ef178e..70d06a86d 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/RecurrencePatternTests.cs @@ -225,10 +225,13 @@ public class RecurrencePatternTests } [Theory] - [InlineData("America/New_York", 2024, 3, 10, 2, 30)] // Spring forward - 2:30 AM doesn't exist - [InlineData("America/New_York", 2024, 11, 3, 1, 30)] // Fall back - 1:30 AM happens twice + // 2:30 AM does not exist on the spring-forward day, so the occurrence moves to the next month. + [InlineData("America/New_York", 2024, 3, 10, 2, 30, 2024, 4, 10, 6, 30)] + // 1:30 AM happens twice on the fall-back day; LocalToUtc resolves it to the first (EDT) pass. + [InlineData("America/New_York", 2024, 11, 3, 1, 30, 2024, 11, 3, 5, 30)] public void MonthlyRecurrencePattern_DSTTransitions_HandlesCorrectly( - string tzId, int year, int month, int day, int hour, int minute) + string tzId, int year, int month, int day, int hour, int minute, + int expectedYear, int expectedMonth, int expectedDay, int expectedHour, int expectedMinute) { // Arrange var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); @@ -238,19 +241,14 @@ public class RecurrencePatternTests var beforeUtc = localDateBefore.LocalToUtc(tz); var timeOnly = new TimeOnly(hour, minute); - // Act - shouldn't throw exceptions + // Act var result = pattern.GetNextOccurrence(beforeUtc, timeOnly, tz); - // Assert - just make sure we got a valid result - Assert.NotEqual(DateTime.MaxValue, result); - - // For invalid times (spring forward), should skip to next valid time - var resultLocal = TimeZoneInfo.ConvertTimeFromUtc(result, tz); - if (month == 3) // Spring forward - { - // Should skip the invalid 2:30 AM time - Assert.True(resultLocal.Hour >= 3 || resultLocal > new DateTime(year, month, day)); - } + // Assert + var expectedUtc = new DateTime( + expectedYear, expectedMonth, expectedDay, expectedHour, expectedMinute, 0, DateTimeKind.Utc + ); + Assert.Equal(expectedUtc, result); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs index 2a22b74bf..ae8fe35d9 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -36,10 +36,8 @@ public class FishingTests [InlineData(4.5, true)] public void Give_SerpentGoesBackAfterFiveMinutesUnlessHitInTheLastTwo(double? hitAtMinutes, bool stays) { - Core._tickCount = 0; - Timer.Init(0); + using var clock = new TimerWheelScope(); - var previousNow = Core._now; var previousHitsRate = Mobile.DefaultHitsRate; var fisher = new PlayerMobile(); BaseCreature serpent = null; @@ -62,15 +60,15 @@ public class FishingTests if (hitAtMinutes is { } minutes) { elapsed = TimeSpan.FromMinutes(minutes); - Advance(elapsed); + TimerWheelScope.Advance(elapsed); serpent.Damage(20, fisher); Assert.Same(fisher, serpent.FindMostRecentDamager(false)); } - Advance(TimeSpan.FromMinutes(4.9) - elapsed); + TimerWheelScope.Advance(TimeSpan.FromMinutes(4.9) - elapsed); Assert.False(serpent.Deleted); - Advance(TimeSpan.FromMinutes(0.2)); + TimerWheelScope.Advance(TimeSpan.FromMinutes(0.2)); Assert.Equal(!stays, serpent.Deleted); Assert.Equal(!stays, bottle.Deleted); } @@ -79,7 +77,6 @@ public class FishingTests serpent?.Delete(); fisher.Delete(); Mobile.DefaultHitsRate = previousHitsRate; - Core._now = previousNow; } } @@ -125,13 +122,6 @@ public class FishingTests } } - private static void Advance(TimeSpan time) - { - Core._now += time; - Core._tickCount += (long)time.TotalMilliseconds; - Timer.Slice(Core.TickCount); - } - // Map.Internal skips FindLocation's search; the wreck is then where the fisher stands. private static SOS AddSOS(Mobile fisher) { diff --git a/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs b/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs index 99921aea6..6f4e6d85e 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Party/PartyPacketTests.cs @@ -9,8 +9,9 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class PartyPacketTests { + // Without a party, the remove-member packet tells the removed member their list is now empty. [Fact] - public void TestPartyEmptyList() + public void TestPartyRemoveMember_WithoutParty_SendsEmptyList() { var m = (Serial)0x1024u; @@ -26,43 +27,61 @@ public class PartyPacketTests [Fact] public void TestPartyRemoveMember() { - var leader = new Mobile((Serial)0x1024u); - leader.DefaultMobileInit(); + // Fresh serials: Delete removes by serial, and a fixed one could belong to a live entity. + var leader = new Mobile(World.NewMobile); + var member = new Mobile(World.NewMobile); - var member = new Mobile((Serial)0x2048u); - member.DefaultMobileInit(); + try + { + leader.DefaultMobileInit(); + member.DefaultMobileInit(); - var p = new Party(leader); - p.Add(member); + var p = new Party(leader); + p.Add(member); - var expected = new PartyRemoveMember(member.Serial, p).Compile(); + var expected = new PartyRemoveMember(member.Serial, p).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPartyRemoveMember(member.Serial, p); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendPartyRemoveMember(member.Serial, p); - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + member.Delete(); + leader.Delete(); + } } [Fact] public void TestPartyMemberList() { - var leader = new Mobile((Serial)0x1024u); - leader.DefaultMobileInit(); + // Fresh serials: Delete removes by serial, and a fixed one could belong to a live entity. + var leader = new Mobile(World.NewMobile); + var member = new Mobile(World.NewMobile); - var member = new Mobile((Serial)0x2048u); - member.DefaultMobileInit(); + try + { + leader.DefaultMobileInit(); + member.DefaultMobileInit(); - var p = new Party(leader); - p.Add(member); + var p = new Party(leader); + p.Add(member); - var expected = new PartyMemberList(p).Compile(); + var expected = new PartyMemberList(p).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendPartyMemberList(p); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendPartyMemberList(p); - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + member.Delete(); + leader.Delete(); + } } [Theory] diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index 667995278..ed8b8d006 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Mobiles; using Server.PathAlgorithms; @@ -14,15 +15,18 @@ namespace Server.Tests.Pathfinding; /// to "no path found" on reachable goals. /// [Collection("Sequential Pathfinding Tests")] -public class BitmapAStarAlgorithmTests +public class BitmapAStarAlgorithmTests : IDisposable { private readonly ITestOutputHelper _output; + private readonly StepCacheStateScope _cacheState = new(); public BitmapAStarAlgorithmTests(ITestOutputHelper output) { _output = output; } + public void Dispose() => _cacheState.Dispose(); + [Theory] // Pinned cell (1500, 1600, z=10): mask=0xC1 → N, W, NW walkable. // Use start.Z for goal.Z so destNode lands in the same Z plane the algorithm reaches @@ -33,22 +37,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 +71,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 +113,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); @@ -146,24 +155,14 @@ 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; - } - } + var blockerItemId = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, blockerItemId); 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 +196,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 +337,7 @@ public class BitmapAStarAlgorithmTests StepCache.Instance.MissPromotionThreshold = 2; var map = Map.Maps[1]; - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(1500, 1600, out _, out var startZ, out _); var start = new Point3D(1500, 1600, (sbyte)startZ); var goal = new Point3D(1498, 1598, (sbyte)startZ); @@ -376,7 +367,7 @@ public class BitmapAStarAlgorithmTests StepCache.Instance.MissPromotionWindowMs = 30_000; var map = Map.Maps[1]; - var stub = new DefaultWalkerStub(); + var stub = new WalkerStub(); map.GetAverageZ(1500, 1600, out _, out var startZ, out _); var start = new Point3D(1500, 1600, (sbyte)startZ); var goal = new Point3D(1498, 1598, (sbyte)startZ); @@ -401,18 +392,6 @@ public class BitmapAStarAlgorithmTests ); } - /// - /// Plain Mobile — RequiresSlowPath returns false, the bitmap algorithm uses the cache - /// fast path on every expansion. - /// - private sealed class DefaultWalkerStub : Mobile - { - public DefaultWalkerStub() - { - Body = 0xC9; - } - } - /// /// Mobile with Player=true and default AccessLevel (Player). Triggers the strict /// AND-rule for diagonal corner-cut while still using the cache. @@ -426,20 +405,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/HousePathRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs index 8ecbd0591..5ecb0cdc1 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/HousePathRoutingTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Server.Mobiles; @@ -9,25 +10,22 @@ 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; - private const int MapId = 1; + public void Dispose() => _cacheState.Dispose(); - private sealed class WalkerStub : Mobile - { - public WalkerStub() => Body = 0xC9; - } + private const int MapId = 1; [SkippableFact] 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); @@ -69,7 +67,6 @@ public class HousePathRoutingTests } finally { - StepCache.Instance.MissPromotionThreshold = prevThreshold; walker.Delete(); multi.Delete(); } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs index fc9e58898..50a27ee44 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiCacheUsedTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.PathAlgorithms; using Server.Systems.FeatureFlags; @@ -6,20 +7,25 @@ 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; - [Fact] + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); + + [SkippableFact] public void PathNearMulti_IncrementsMultiLocalHits() { + // Without the client's multi data the house has no footprint for the path to skirt. + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; map.GetAverageZ(1480, 1620, out _, out var z, out _); 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 +50,6 @@ public class MultiCacheUsedTests { mover.Delete(); multi.Delete(); - ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs index 38a6824ea..2b2adf93c 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Xunit; @@ -5,13 +6,17 @@ 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 PlaceX = 1480; private const int PlaceY = 1620; + private readonly StepCacheStateScope _cacheState = new(); + + public void Dispose() => _cacheState.Dispose(); + [SkippableFact] public void TryResolveCoveringMulti_FindsPlacedMulti_AndLocalIndices() { @@ -99,7 +104,6 @@ public class MultiMaskCacheTests map.GetAverageZ(CleanX, CleanY, out _, out var z, out _); var houseLoc = new Point3D(CleanX, CleanY, (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)); try @@ -124,17 +128,17 @@ public class MultiMaskCacheTests { mover.Delete(); multi.Delete(); - Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } - [Fact] + [SkippableFact] public void ClutteredHouse_DoesNotServeCache_ButStillPaths() { + // The clutter is Britain's statics; on a blank map the house has no footprint to dirty. + TileDataRequirement.SkipIfMissing(); 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 @@ -159,7 +163,6 @@ public class MultiMaskCacheTests { mover.Delete(); multi.Delete(); - Server.Systems.FeatureFlags.ContentFeatureFlags.BitmapPathfindingCache = cacheWas; } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs index 9a920282f..9ec598916 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiPathInvariantTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Server.Mobiles; @@ -9,41 +10,33 @@ 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] + [SkippableTheory] // start, goal: straddle a house placed between them (house at ~ midpoint). [InlineData(1500, 1600, 1500, 1612)] // N-S across the footprint [InlineData(1494, 1606, 1512, 1606)] // E-W across the footprint public void Find_CacheOn_EqualsCacheOff_WithHousePresent(int sx, int sy, int gx, int gy) { + // Without the client's multi data the house has no footprint and nothing stands in the way. + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; Assert.NotNull(map); @@ -100,11 +93,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 { @@ -112,9 +105,10 @@ public class MultiPathInvariantTests } } - [Fact] + [SkippableFact] public void Find_CacheOn_EqualsCacheOff_RoutesAroundHouse() { + TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; Assert.NotNull(map); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs index 1103cdb83..8f20d04f4 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiSplitRoutingTests.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Pathing.Cache; using Server.Items; using Xunit; @@ -6,12 +7,15 @@ 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; @@ -60,7 +64,6 @@ 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); @@ -72,20 +75,13 @@ public class MultiSplitRoutingTests 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..1f993d477 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs @@ -18,11 +18,6 @@ public class MultiWalkabilityTests private const int PlaceX = 1500; private const int PlaceY = 1600; - private sealed class WalkerStub : Mobile - { - public WalkerStub() => Body = 0xC9; - } - [SkippableFact] public void WallCell_CannotBeEnteredFromAnyAdjacentCell() { 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..de0fdaaae 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 { @@ -53,17 +53,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/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs index d29b79ef9..4094c093f 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -6,8 +6,12 @@ using Xunit; 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, @@ -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: 10); + Assert.Equal(CacheHitKind.Hit, lookup.HitKind); + Assert.Equal(coords.Length, cache.GetStats().ResidentChunks); } finally { diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs index dee698e59..6d2bc6f7a 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFingerprintTests.cs @@ -15,9 +15,12 @@ public class StepCacheFingerprintTests /// different one, marking the bake stale and re-baking on every boot. Hashing the file makes /// the fingerprint a pure function of the client's tile data, immune to those mutations. /// - [Fact] + [SkippableFact] public void Fingerprint_IgnoresRuntimeTileDataMutation() { + // Without tiledata.mul there is no file to hash, so the fingerprint is constant either way. + TileDataRequirement.SkipIfMissing(); + const int mapId = 1; // Trammel — loaded by the test bootstrap. var before = StepCacheFile.ComputeFingerprint(mapId); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index 9c410efc7..fd90aedf9 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -17,11 +17,9 @@ namespace Server.Tests.Pathfinding; [Collection("Sequential Pathfinding Tests")] public class StepCacheLifecycleTests : IDisposable { - // The window is a knob on the singleton and Clear() leaves it alone, so a test that narrows - // it would otherwise leak the narrow window into every later test in the collection. - private readonly uint _savedWindowMs = StepCache.Instance.MissPromotionWindowMs; + private readonly StepCacheStateScope _cacheState = new(); - public void Dispose() => StepCache.Instance.MissPromotionWindowMs = _savedWindowMs; + public void Dispose() => _cacheState.Dispose(); /// Resets to a known state and returns the singleton. private static StepCache FreshCache(int promotionThreshold) @@ -396,26 +394,19 @@ public class StepCacheLifecycleTests : IDisposable var cache = FreshCache(promotionThreshold: 1); cache.MaxResidentChunks = 4; - try + var map = TestMap; + + // Five chunks into a cache that holds four. Which one goes doesn't matter here, only that + // the count comes down and the key list stays in step, so equal touch ticks are fine. + for (var i = 0; i < 5; i++) { - var map = TestMap; - - // Five chunks into a cache that holds four. - for (var i = 0; i < 5; i++) - { - cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10); - Thread.Sleep(2); // separate their LastTouchedTicks so LRU has something to order by - } - - cache.EnforceLruCap(); - - Assert.Equal(4, cache.GetStats().ResidentChunks); - Assert.True(cache.GetStats().EvictionsByLruCap >= 1L); - Assert.True(cache.ResidentIndexInSync(), "eviction desynced the key list from the resident set"); - } - finally - { - cache.MaxResidentChunks = 8192; + cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10); } + + 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..769945af1 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs @@ -20,12 +20,15 @@ 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(); + // ---- layer 1: the bake agrees with MovementImpl ---- /// @@ -43,7 +46,7 @@ public class StepCacheParityTests 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; @@ -154,12 +157,16 @@ public class StepCacheParityTests /// the land average instead would trip the source-Z guard on raised terrain (a causeway, a /// walkway) and report a fallthrough that is correct behaviour rather than a fault. /// - [Theory] + [SkippableTheory] [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) { + // A storage fault (crossed cell index, swapped Z array) only shows against varied terrain; + // on a blank map every cell bakes the same answer. + TileDataRequirement.SkipIfMissing(); + var cache = StepCache.Instance; cache.Clear(); cache.MissPromotionThreshold = 1; // build on first touch: every cell should get a real answer @@ -253,7 +260,7 @@ public class StepCacheParityTests /// Cardinals only: the cache stores raw masks and applies the corner-cut at query time, so a /// raw diagonal bit legitimately differs from MovementImpl's diagonal answer. /// - [Theory] + [SkippableTheory] // Seeds chosen for the terrain classes the standable-surface bake has to get right. Each one // 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 @@ -262,6 +269,8 @@ public class StepCacheParityTests [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) { + TileDataRequirement.SkipIfMissing(); + var cache = StepCache.Instance; cache.Clear(); cache.MissPromotionThreshold = 1; // build on first touch: every reached state should be answered @@ -269,7 +278,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/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 6b4bba2dd..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,45 +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); } } } - 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", @@ -133,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); @@ -160,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/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/SectorSpawnCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs index 1acc29960..ad67e96b9 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Spawners/SectorSpawnCacheTests.cs @@ -260,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 diff --git a/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs b/Projects/UOContent.Tests/Tests/Engines/Spawners/SpawnerEntryOwnershipTests.cs index 96023870d..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,17 +81,15 @@ public class SpawnerEntryOwnershipTests spawner.RemoveAllEntries(); Assert.Empty(spawner.Entries); - - 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); @@ -84,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]; @@ -117,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); @@ -140,10 +148,6 @@ 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. diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs index 01a58a697..ca3c7399f 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs @@ -17,30 +17,42 @@ public class CorpseChildPublicTests private static VikingSword EquipSword(Mobile owner) { var sword = new VikingSword { Layer = Layer.OneHanded }; - Assert.True(owner.EquipItem(sword)); + var equipped = owner.EquipItem(sword); + if (!equipped) + { + sword.Delete(); + } + + Assert.True(equipped); return sword; } + private static Mobile NewOwner(int body) + { + var owner = new Mobile(World.NewMobile); + owner.DefaultMobileInit(); + owner.Body = body; + return owner; + } + [Fact] public void IsChildPublic_HumanCorpseWithWornGear_ReturnsTrue() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + Assert.Equal(0x2006, corpse.ItemID); Assert.True(((Body)corpse.Amount).IsHuman); Assert.True(corpse.IsChildPublic(wornItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -48,26 +60,24 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_HumanCorpseWithLootItem_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); - - var lootItem = new Gold(100); - corpse.DropItem(lootItem); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + + var lootItem = new Gold(100); + corpse.DropItem(lootItem); + Assert.Equal(0x2006, corpse.ItemID); Assert.True(((Body)corpse.Amount).IsHuman); Assert.False(corpse.IsChildPublic(lootItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -75,16 +85,14 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_BoneCorpse_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0x190); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + corpse.ItemID = 0xECA; Assert.True(((Body)corpse.Amount).IsHuman); @@ -93,7 +101,7 @@ public class CorpseChildPublicTests } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -101,23 +109,21 @@ public class CorpseChildPublicTests [Fact] public void IsChildPublic_NonHumanCorpse_ReturnsFalse() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0xC9; - - var wornItem = EquipSword(owner); - - var corpse = new Corpse(owner, owner.Items); + var owner = NewOwner(0xC9); + Corpse corpse = null; try { + var wornItem = EquipSword(owner); + corpse = new Corpse(owner, owner.Items); + Assert.Equal(0x2006, corpse.ItemID); Assert.False(((Body)corpse.Amount).IsHuman); Assert.False(corpse.IsChildPublic(wornItem)); } finally { - corpse.Delete(); + corpse?.Delete(); owner.Delete(); } } @@ -129,36 +135,48 @@ public class CorpseChildPublicTests return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; } + private static Mobile NewBystander(NetState ns, Point3D location) + { + ns.Account = new MockAccount(); + var bystander = new Mobile(World.NewMobile); + bystander.DefaultMobileInit(); + ns.Mobile = bystander; + bystander.NetState = ns; + bystander.MoveToWorld(location, Map.Felucca); + return bystander; + } + + private static void DeleteBystander(NetState ns, Mobile bystander) + { + ns.Mobile = null; + ns.Dispose(); + bystander?.Delete(); + } + [Fact] public void HumanCorpse_WornGearRemovalBroadcasts_LootRemovalDoesNot() { var corpseLoc = new Point3D(1500, 1500, 0); var bystanderNs = PacketTestUtilities.CreateTestNetState(); - bystanderNs.Account = new MockAccount(); - var bystander = new Mobile(World.NewMobile); - bystander.DefaultMobileInit(); - bystanderNs.Mobile = bystander; - bystander.NetState = bystanderNs; - bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - var sword = new VikingSword(); - var corpse = new Corpse(owner, [sword]); - corpse.AddItem(sword); - - var lootItem = new Gold(100); - corpse.DropItem(lootItem); - - corpse.MoveToWorld(corpseLoc, Map.Felucca); - + Mobile bystander = null; + var owner = NewOwner(0x190); + Corpse corpse = null; Container pouch = null; try { + bystander = NewBystander(bystanderNs, new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z)); + + var sword = new VikingSword(); + corpse = new Corpse(owner, [sword]); + corpse.AddItem(sword); + + var lootItem = new Gold(100); + corpse.DropItem(lootItem); + + corpse.MoveToWorld(corpseLoc, Map.Felucca); + // A container not in the world is a valid destination; only the removal from the corpse matters here. pouch = new Container(0xE75); pouch.AddItem(sword); @@ -171,11 +189,9 @@ public class CorpseChildPublicTests finally { pouch?.Delete(); - corpse.Delete(); + corpse?.Delete(); owner.Delete(); - bystanderNs.Mobile = null; - bystanderNs.Dispose(); - bystander.Delete(); + DeleteBystander(bystanderNs, bystander); } } @@ -185,25 +201,20 @@ public class CorpseChildPublicTests var corpseLoc = new Point3D(1500, 1500, 0); var bystanderNs = PacketTestUtilities.CreateTestNetState(); - bystanderNs.Account = new MockAccount(); - var bystander = new Mobile(World.NewMobile); - bystander.DefaultMobileInit(); - bystanderNs.Mobile = bystander; - bystander.NetState = bystanderNs; - bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - - var owner = new Mobile(World.NewMobile); - owner.DefaultMobileInit(); - owner.Body = 0x190; - owner.MoveToWorld(new Point3D(corpseLoc.X - 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); - + Mobile bystander = null; + var owner = NewOwner(0x190); var sword = new VikingSword { Layer = Layer.OneHanded }; - var corpse = new Corpse(owner, [sword]); - corpse.AddItem(sword); - corpse.MoveToWorld(corpseLoc, Map.Felucca); + Corpse corpse = null; try { + bystander = NewBystander(bystanderNs, new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z)); + owner.MoveToWorld(new Point3D(corpseLoc.X - 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); + + corpse = new Corpse(owner, [sword]); + corpse.AddItem(sword); + corpse.MoveToWorld(corpseLoc, Map.Felucca); + // Lifting takes the piece off the worn list before the remove is sent; the remove must // still reach everyone who was shown it worn. owner.Lift(sword, sword.Amount, out var rejected, out _); @@ -216,11 +227,9 @@ public class CorpseChildPublicTests { owner.Holding = null; sword.Delete(); - corpse.Delete(); + corpse?.Delete(); owner.Delete(); - bystanderNs.Mobile = null; - bystanderNs.Dispose(); - bystander.Delete(); + DeleteBystander(bystanderNs, bystander); } } } diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs index 26556155e..9cfec050a 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Server; using Server.Items; using Xunit; @@ -11,19 +10,22 @@ public class CorpseLootTests [Fact] public void LiftedWornGear_PutBack_StaysLoot() { - var owner = new Mobile((Serial)0x1); - owner.DefaultMobileInit(); - owner.Body = 0x190; - - // Weapons take their layer from tiledata, which CI doesn't load. - var sword = new VikingSword { Layer = Layer.OneHanded }; - Assert.True(owner.EquipItem(sword)); - - var corpse = new Corpse(owner, new List { sword }); - corpse.DropItem(sword); + var owner = new Mobile(World.NewMobile); + VikingSword sword = null; + Corpse corpse = null; try { + owner.DefaultMobileInit(); + owner.Body = 0x190; + + // Weapons take their layer from tiledata, which CI doesn't load. + sword = new VikingSword { Layer = Layer.OneHanded }; + Assert.True(owner.EquipItem(sword)); + + corpse = new Corpse(owner, [sword]); + corpse.DropItem(sword); + Assert.Contains(sword, corpse.EquipItems); corpse.OnItemLifted(owner, sword); @@ -35,7 +37,8 @@ public class CorpseLootTests } finally { - corpse.Delete(); + corpse?.Delete(); + sword?.Delete(); owner.Delete(); } } diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs index b5cec8aa6..1669147f2 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs @@ -10,19 +10,34 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class CorpsePacketTests { + // Weapons take their layer from tiledata, which CI doesn't load; without an explicit layer the + // equip is refused and the packets would only cover an empty item list. + private static VikingSword EquipSword(Mobile m) + { + var sword = new VikingSword { Layer = Layer.OneHanded }; + var equipped = m.EquipItem(sword); + if (!equipped) + { + sword.Delete(); + } + + Assert.True(equipped); + return sword; + } + [Fact] public void TestCorpseEquipPacket() { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - - var weapon = new VikingSword(); - m.EquipItem(weapon); - - var c = new Corpse(m, m.Items); + var m = new Mobile(World.NewMobile); + VikingSword weapon = null; + Corpse c = null; try { + m.DefaultMobileInit(); + weapon = EquipSword(m); + c = new Corpse(m, m.Items); + var expected = new CorpseEquip(m, c).Compile(); using var ns = PacketTestUtilities.CreateTestNetState(); @@ -33,8 +48,9 @@ public class CorpsePacketTests } finally { - c.Delete(); - weapon.Delete(); + c?.Delete(); + weapon?.Delete(); + m.Delete(); } } @@ -43,16 +59,16 @@ public class CorpsePacketTests [InlineData(ProtocolChanges.ContainerGridLines)] public void TestCorpseContainerPacket(ProtocolChanges changes) { - var m = new Mobile((Serial)0x1); - m.DefaultMobileInit(); - - var weapon = new VikingSword(); - m.EquipItem(weapon); - - var c = new Corpse(m, m.Items); + var m = new Mobile(World.NewMobile); + VikingSword weapon = null; + Corpse c = null; try { + m.DefaultMobileInit(); + weapon = EquipSword(m); + c = new Corpse(m, m.Items); + using var ns = PacketTestUtilities.CreateTestNetState(); ns.ProtocolChanges = changes; @@ -65,8 +81,9 @@ public class CorpsePacketTests } finally { - c.Delete(); - weapon.Delete(); + c?.Delete(); + weapon?.Delete(); + m.Delete(); } } } diff --git a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs index cc0ccad42..b9f13a2f1 100644 --- a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs @@ -87,14 +87,14 @@ public class VendorBuybackPackTests var pack = new VendorBuybackPack(); var items = new Item[10]; - for (var i = 0; i < items.Length; i++) - { - items[i] = new Item(0x1234); - pack.AddBuyback(items[i], 250); - } - try { + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.AddBuyback(items[i], 250); + } + pack.Purge(); Assert.Empty(pack.Items); diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs index da896ba8b..745922d08 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs @@ -10,13 +10,19 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class ThrowingTests { - // Exposes protected ModifyHitChance so the math can be verified directly. + // Exposes protected ModifyHitChance so the math can be verified directly. The requirement and + // throw band are pinned here so retuning a shipped weapon cannot move these expectations. private class TestThrown : BaseThrown { - public TestThrown() : base(0x8FF) { } + // Weapons take their layer from tiledata, which CI doesn't load; without it AddItem is refused. + public TestThrown() : base(0x8FF) => Layer = Layer.OneHanded; + + public override int AosStrengthReq => 60; public override int MinThrowRange => 4; + public override int MaxThrowRange => 7; + public double TestModifyHitChance(Mobile attacker, Mobile defender, double chance) => ModifyHitChance(attacker, defender, chance); @@ -220,18 +226,19 @@ public class ThrowingTests } } - // DefMaxRange STR scaling (uses SoulGlaive: StrReq 60, Min 8, Max 11) + // DefMaxRange STR scaling (TestThrown: StrReq 60, Min 4, Max 7, so one tile per 26 Str) [Fact] public void DefMaxRange_AtStrReq_EqualsMinThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5800, 500, 0)); try { attacker.RawStr = 60; // == AosStrengthReq attacker.AddItem(weapon); - Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 8 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 4 } finally { @@ -243,13 +250,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_At140Str_EqualsMaxThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5810, 500, 0)); try { attacker.RawStr = 140; attacker.AddItem(weapon); - Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 11 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 7 } finally { @@ -261,13 +269,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_AboveMaxStr_IsCappedAtMaxThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5820, 500, 0)); try { - attacker.RawStr = 200; // uncapped formula would give 13 + attacker.RawStr = 200; // uncapped formula would give 9 attacker.AddItem(weapon); - Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 11, not 13 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 7, not 9 } finally { @@ -279,13 +288,14 @@ public class ThrowingTests [Fact] public void DefMaxRange_BelowStrReq_FlooredAtMinThrowRange() { - var weapon = new SoulGlaive(); + var weapon = new TestThrown(); var attacker = CreateMobile(Map.Felucca, new Point3D(5830, 500, 0)); try { attacker.RawStr = 10; // below StrReq 60 attacker.AddItem(weapon); - Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 8, not 6 + Assert.Same(attacker, weapon.Parent); + Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 4, not 3 } finally { @@ -305,6 +315,7 @@ public class ThrowingTests { attacker.RawStr = 140; // MaxRange == MaxThrowRange == 7 attacker.AddItem(weapon); + Assert.Same(attacker, weapon.Parent); var defender = CreateMobile(map, new Point3D(5907, 500, 0)); // distance 7 == MaxRange try { @@ -333,6 +344,7 @@ public class ThrowingTests { attacker.RawStr = 140; // MaxRange 7 attacker.AddItem(weapon); + Assert.Same(attacker, weapon.Parent); var defender = CreateMobile(map, new Point3D(5925, 500, 0)); // distance 5 < 7 try { diff --git a/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs index a46c56003..68be84e7b 100644 --- a/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs @@ -17,22 +17,23 @@ public class CorpseNotorietyTests public void PlayerCorpseNotorietyIgnoresTransformedBody(int bodyMod, int expected, int kills = 0) { var victim = new PlayerMobile(World.NewMobile); - victim.DefaultMobileInit(); - victim.Kills = kills; - victim.BodyMod = bodyMod; - var looter = new PlayerMobile(World.NewMobile); - looter.DefaultMobileInit(); - - var corpse = new Corpse(victim, []); + Corpse corpse = null; try { + victim.DefaultMobileInit(); + victim.Kills = kills; + victim.BodyMod = bodyMod; + looter.DefaultMobileInit(); + + corpse = new Corpse(victim, []); + Assert.Equal(expected, NotorietyHandlers.CorpseNotoriety(looter, corpse)); } finally { - corpse.Delete(); + corpse?.Delete(); victim.Delete(); looter.Delete(); } diff --git a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs index de6825c9f..06d940512 100644 --- a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Reflection; using Server.Configurations; using Server.Misc; using Xunit; @@ -7,13 +8,22 @@ using Xunit; namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] -public class EmailTests +public class EmailTests : IDisposable { + private static readonly PropertyInfo _emailEnabled = + typeof(EmailConfiguration).GetProperty(nameof(EmailConfiguration.EmailEnabled)); + + private readonly bool _savedEmailEnabled = EmailConfiguration.EmailEnabled; + + // Pinned off rather than read from the host's email.json, so a developer config with email + // enabled cannot turn these into real sends. + public EmailTests() => _emailEnabled.SetValue(null, false); + + public void Dispose() => _emailEnabled.SetValue(null, _savedEmailEnabled); + [Fact] public void SendCrashEmailDoesNothingWhileEmailIsDisabled() { - Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); - // Missing input must be ignored while email is disabled. var report = Path.Combine(Path.GetTempPath(), $"crash-email-test-{Guid.NewGuid():N}", "Crash.log"); @@ -23,8 +33,7 @@ public class EmailTests [Fact] public void SendQueueEmailDoesNothingWhileEmailIsDisabled() { - Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); - + // A null entry would throw on the enabled path, so returning cleanly shows the guard held. Email.SendQueueEmail(null, "Other"); } diff --git a/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs index f596282f6..86787e0f2 100644 --- a/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/GuildSerializePurityTests.cs @@ -22,40 +22,55 @@ public class GuildSerializePurityTests public void SerializeDoesNotRecalculateFealtyOrMutateState() { var leader = CreatePlayer(); - var guild = new Guild(leader, "Purity Test Guild", "PTG"); - var staleFealty = Core.Now - TimeSpan.FromDays(3); - guild.LastFealty = staleFealty; + Guild guild = null; - var writer = new BufferWriter(true); - guild.Serialize(writer); + try + { + guild = new Guild(leader, "Purity Test Guild", "PTG"); + var staleFealty = Core.Now - TimeSpan.FromDays(3); + guild.LastFealty = staleFealty; - Assert.Equal(staleFealty, guild.LastFealty); - Assert.Same(leader, guild.Leader); + var writer = new BufferWriter(true); + guild.Serialize(writer); - var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); - writer.Seek(0, SeekOrigin.Begin); - guild.Serialize(writer); - var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); + Assert.Equal(staleFealty, guild.LastFealty); + Assert.Same(leader, guild.Leader); - Assert.Equal(first, second); + var first = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); + writer.Seek(0, SeekOrigin.Begin); + guild.Serialize(writer); + var second = writer.Buffer.AsSpan(0, (int)writer.Position).ToArray(); - writer.Close(); - guild.Disband(); - leader.Delete(); + Assert.Equal(first, second); + + writer.Close(); + } + finally + { + guild?.Disband(); + leader.Delete(); + } } [Fact] public void RunMaintenanceRecalculatesStaleFealty() { var leader = CreatePlayer(); - var guild = new Guild(leader, "Maintenance Test Guild", "MTG"); - guild.LastFealty = Core.Now - TimeSpan.FromDays(3); + Guild guild = null; - guild.RunMaintenance(); + try + { + guild = new Guild(leader, "Maintenance Test Guild", "MTG"); + guild.LastFealty = Core.Now - TimeSpan.FromDays(3); - Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1)); + guild.RunMaintenance(); - guild.Disband(); - leader.Delete(); + Assert.True(Core.Now - guild.LastFealty < TimeSpan.FromMinutes(1)); + } + finally + { + guild?.Disband(); + leader.Delete(); + } } } diff --git a/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs b/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs index 60b450b0d..233086f04 100644 --- a/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs +++ b/Projects/UOContent.Tests/Tests/Misc/NameVerificationTests.cs @@ -38,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)); } 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..55965375b 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs @@ -1,43 +1,47 @@ +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; } @@ -46,32 +50,24 @@ public class ApproachOutcomeTests { 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] @@ -79,10 +75,9 @@ public class ApproachOutcomeTests { 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(); + var id = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, id); // Impassable ring around the target: unreachable. @@ -96,35 +91,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..e330268f0 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Server.Engines.Pathing.Cache; using Server.Mobiles; @@ -9,29 +10,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 +66,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 +74,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 +82,40 @@ 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 } - [Fact] + // The desk is a Britain inn static; on a blank map the detour is a straight walk. + [SkippableFact] public void BritainInnDesk_PetReachesMaster() { + TileDataRequirement.SkipIfMissing(); 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 target = NewTarget(map, new Point3D(1494, 1605, 21)); StepCache.Instance.Clear(); // 200 ticks is generous for the ~17-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"); } - [Fact] + [SkippableFact] public void MoveTo_ReachesTargetAroundDesk() { + TileDataRequirement.SkipIfMissing(); 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 target = NewTarget(map, new Point3D(1494, 1605, 21)); StepCache.Instance.Clear(); @@ -131,9 +131,6 @@ public class ApproachTargetTests } } - bc.Delete(); - target.Delete(); - Assert.True(arrived, "MoveTo chaser must reach the target around the desk"); } @@ -145,8 +142,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,9 +166,6 @@ public class ApproachTargetTests } } - bc.Delete(); - target.Delete(); - Assert.True(caught, "chaser must catch a target that walks away then stops"); } @@ -185,13 +178,11 @@ public class ApproachTargetTests 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(); + var id = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, id); - var ring = new List(); for (var x = 1499; x <= 1501; x++) { for (var y = 1595; y <= 1597; y++) @@ -200,11 +191,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,16 +219,7 @@ 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"); } @@ -253,33 +232,20 @@ 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(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(); + var id = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, id); - var wall = new List(); for (var x = 1497; x <= 1503; x++) { - map.GetAverageZ(x, 1598, out _, out var wz, out _); - wall.Add(new Item(World.NewItem) - { - ItemID = id, Map = map, Location = new Point3D(x, 1598, (sbyte)wz) - }); + PlaceBlocker(map, id, x, 1598); } StepCache.Instance.Clear(); var arrived = DriveFollow(ai, bc, target, 2, 200); - bc.Delete(); - target.Delete(); - foreach (var it in wall) - { - it.Delete(); - } - Assert.True(arrived, "creature must route around the wall to reach the target"); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs index 383e2e103..84898b7f1 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs @@ -1,6 +1,7 @@ using System; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles.AI; @@ -37,9 +38,8 @@ public class DetectHiddenTimerTests public void DetectHidden_SchedulesBoundedCooldown_AndWaitsBeforeRetry( int intelligence, long minDelay, long maxDelay) { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); - Core._tickCount = 0; - Timer.Init(0); + Skip.If(!TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + using var clock = new TimerWheelScope(); var creature = new DetectingCreature(); DetectingAI ai = null; @@ -63,12 +63,8 @@ public class DetectHiddenTimerTests Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay); Assert.True(ai.AITimer.Running); - var deadline = ai._nextDetectHidden; - while (Core.TickCount + 8 < deadline) - { - Core._tickCount += 8; - Timer.Slice(Core.TickCount); - } + // Stop one wheel step short of the cooldown: no retry may have fired yet. + TimerWheelScope.RunFor(ai._nextDetectHidden - TimerWheelScope.Step - Core.TickCount); Assert.Equal(1, ai.Detections); AdvanceUntil(() => ai.Detections > 1, 1024); @@ -82,14 +78,9 @@ public class DetectHiddenTimerTests } } - private static void AdvanceUntil(Func condition, int milliseconds) - { - for (var elapsed = 0; !condition() && elapsed < milliseconds; elapsed += 8) - { - Core._tickCount += 8; - Timer.Slice(Core.TickCount); - } - - Assert.True(condition(), "The AI timer did not reach the expected detection."); - } + private static void AdvanceUntil(Func condition, int milliseconds) => + Assert.True( + TimerWheelScope.RunUntil(condition, milliseconds), + "The AI timer did not reach the expected detection." + ); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs index 692ff8152..27b4ee0a0 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs @@ -4,8 +4,8 @@ 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 +50,12 @@ 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,36 +111,6 @@ 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 Follows_WithoutBacktracking() { @@ -323,7 +295,7 @@ public class FamiliarAITests : IDisposable var f = Familiar(0, p, 1500, 1602); // 5x5 ring, open 3x3 interior: a landing tile exists, no route reaches it. - var id = ApproachTargetTests.FirstImpassableItemId(); + var id = TestTiles.FirstImpassableItemId(); Assert.NotEqual(0, id); for (var x = 1498; x <= 1502; x++) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs index 785278507..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,21 +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); + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(pet.FollowMoveSpeed, pet.CurrentMoveSpeed); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs index ec11a3ba5..ee3bfe7cd 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs @@ -11,10 +11,24 @@ public class GuardFriendlyFireTests : IDisposable { private readonly List _created = []; + // AI_Mage stands in for a shipped caster: MageAI's combat loop has its own reacquire path. + private sealed class MagePet : BaseCreature + { + public MagePet() : base(AIType.AI_Mage, FightMode.Closest, 10, 1) => Body = 0xC8; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } + private (PlayerMobile owner, PetTestStub pet) Spawn() { Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); var z = Map.Felucca.GetAverageZ(1500, 1600); + // The constructed PlayerMobile, not PetTestSetup's deserialization-shaped one: the harmful + // checks read PermaFlags, which only the full constructor allocates. var owner = new PlayerMobile(); owner.MoveToWorld(new Point3D(1500, 1600, z), Map.Felucca); _created.Add(owner); @@ -192,28 +206,28 @@ public class GuardFriendlyFireTests : IDisposable } [SkippableFact] - public void ActualWhiteWyrmDoesNotTurnOnOwnerWhenEnemyDisappears() + public void GuardingMageDoesNotTurnOnOwnerWhenEnemyDisappears() { var (owner, _) = Spawn(); - var wyrm = new WhiteWyrm(); - _created.Add(wyrm); - wyrm.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); - wyrm.SetControlMaster(owner); - wyrm.IssueOrder(OrderType.Guard, owner); - wyrm.AIObject.AITimer.Stop(); + var mage = new MagePet(); + _created.Add(mage); + mage.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + mage.SetControlMaster(owner); + mage.IssueOrder(OrderType.Guard, owner); + mage.AIObject.AITimer.Stop(); var enemy = new PetTestStub(); _created.Add(enemy); enemy.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map); enemy.AIObject.AITimer.Stop(); - wyrm.Combatant = enemy; + mage.Combatant = enemy; enemy.Hidden = true; - wyrm.NextReacquireTime = Core.TickCount - 1; - Assert.IsType(wyrm.AIObject); - wyrm.AIObject.DoActionCombat(); - Assert.NotSame(owner, wyrm.Combatant); - Assert.Null(wyrm.FocusMob); - wyrm.AIObject.DoOrderGuard(); - Assert.Null(wyrm.Combatant); + mage.NextReacquireTime = Core.TickCount - 1; + Assert.IsType(mage.AIObject); + mage.AIObject.DoActionCombat(); + Assert.NotSame(owner, mage.Combatant); + Assert.Null(mage.FocusMob); + mage.AIObject.DoOrderGuard(); + Assert.Null(mage.Combatant); } public void Dispose() diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs index 7d572786a..356994020 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles.AI; @@ -14,11 +15,6 @@ public class GuardOrderTests : IDisposable { private readonly List _created = new(); - private sealed class AggressorStub : Mobile - { - public AggressorStub() => Body = 0xC9; - } - public void Dispose() { foreach (var m in _created) @@ -35,14 +31,12 @@ public class GuardOrderTests : IDisposable Assert.NotNull(map); map.GetAverageZ(1500, 1600, out _, out z, out _); - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + var (master, pet) = PetTestSetup.SpawnControlledPet( + new Point3D(1500, 1600, (sbyte)z), + new Point3D(1499, 1600, (sbyte)z), + map + ); _created.Add(master); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map); - pet.SetControlMaster(master); _created.Add(pet); pet.AIObject.AITimer?.Stop(); // drive manually @@ -52,9 +46,9 @@ public class GuardOrderTests : IDisposable return (master, pet); } - private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) + private WalkerStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) { - var aggr = new AggressorStub(); + var aggr = new WalkerStub(); aggr.MoveToWorld(loc, pet.Map); _created.Add(aggr); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs index 357a39979..6c88649fa 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs @@ -10,11 +10,6 @@ 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] public void HerdedCreature_ReachesTheTile_AndClearsTargetLocation() { @@ -22,7 +17,7 @@ public class HerdingTests 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/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index 2789cd735..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] diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs index 1064fb317..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. @@ -145,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. @@ -161,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. @@ -208,41 +213,10 @@ public class PetPacingTests : IDisposable return (master, pet); } - // Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync. - private static void RunFor(long ms) - { - var deadline = Core._tickCount + ms; - - while (Core._tickCount < deadline) - { - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - } - - private static bool RunUntil(Func condition, long maxMs) - { - var deadline = Core._tickCount + maxMs; - - while (Core._tickCount < deadline) - { - if (condition()) - { - return true; - } - - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - - return condition(); - } - // Runs past the spawn stagger; returns right after a think with the next 0.4s away. private ThinkProbe SettledProbe(out PlayerMobile master) { - Core._tickCount = 0; - Timer.Init(0); + _clock = new TimerWheelScope(); var (m, pet) = SpawnProbe(); master = m; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs index 41b52a189..dec588d49 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs @@ -35,16 +35,10 @@ public class PetRetaliationTests : IDisposable private (PlayerMobile master, T pet) Spawn() where T : BaseCreature, new() { - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + var (master, pet) = PetTestSetup.SpawnControlledPet(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); _created.Add(master); - - var pet = new T(); - pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); - pet.SetControlMaster(master); - pet.AIObject.AITimer?.Stop(); _created.Add(pet); + pet.AIObject.AITimer?.Stop(); return (master, pet); } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs index d15228590..7779384b6 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs @@ -49,17 +49,21 @@ public class PetTestStub : BaseCreature public static class PetTestSetup { - // Places a player master and a controlled pet on Felucca and returns both. + // Places a player master and a controlled pet (on Felucca unless a map is given) and returns both. public static (PlayerMobile master, PetTestStub pet) SpawnControlledPet( - Point3D masterLoc, Point3D petLoc) + Point3D masterLoc, Point3D petLoc, Map map = null) => + SpawnControlledPet(masterLoc, petLoc, map); + + public static (PlayerMobile master, T pet) SpawnControlledPet( + Point3D masterLoc, Point3D petLoc, Map map = null) where T : BaseCreature, new() { - var map = Map.Felucca; + map ??= Map.Felucca; var master = new PlayerMobile(World.NewMobile); master.DefaultMobileInit(); master.MoveToWorld(masterLoc, map); - var pet = new PetTestStub(); + var pet = new T(); pet.MoveToWorld(petLoc, map); pet.SetControlMaster(master); // sets Controlled, Home=Zero, ControlOrder=Come diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs index ba106218c..5d92fca85 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Server; using Server.Items; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -15,60 +16,14 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class BaseCreatureSerializationTests : IDisposable { - private readonly List _created = new(); - private readonly List _createdItems = new(); + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } + public void Dispose() => _harness.Dispose(); - for (var i = 0; i < _createdItems.Count; i++) - { - _createdItems[i].Delete(); - } - } - - private class CreatureStub : BaseCreature - { - public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9; - - public CreatureStub(Serial serial) : base(serial) => Body = 0xC9; - - public DateTime SummonEndValue => SummonEnd; - - // Stands in for the npc-speeds table (unconfigured in the test fixture). - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - - public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) - { - activeMoveSpeed = 0.6; - passiveMoveSpeed = 1.2; - } - } - - private CreatureStub NewCreature() - { - var bc = new CreatureStub(); - _created.Add(bc); - return bc; - } + private TestCreature NewCreature() => _harness.NewCreature(); // ReadEntity resolves references through the world table, so the master must be registered. - private PlayerMobile NewMaster() - { - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - World.AddEntity(master); - _created.Add(master); - return master; - } + private PlayerMobile NewMaster() => _harness.NewPlayer(); private static byte[] Snapshot(Mobile m) { @@ -80,10 +35,10 @@ public class BaseCreatureSerializationTests : IDisposable return buffer; } - private CreatureStub Load(byte[] buffer) + private TestCreature Load(byte[] buffer) { - var copy = new CreatureStub(World.NewMobile); - _created.Add(copy); + var copy = new TestCreature(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -219,7 +174,7 @@ public class BaseCreatureSerializationTests : IDisposable var bc = NewCreature(); var friend = NewMaster(); var wayPoint = new WayPoint(); - _createdItems.Add(wayPoint); + _harness.TrackItem(wayPoint); bc.AddPetFriend(friend); bc.CurrentWayPoint = wayPoint; @@ -245,49 +200,20 @@ public class BaseCreatureSerializationTests : IDisposable Assert.InRange(copy.DeleteTimeLeft, TimeSpan.FromDays(3.0) - TimeSpan.FromSeconds(5), TimeSpan.FromDays(3.0)); } - private sealed class VendorStub : BaseVendor - { - private static readonly List _sbInfos = []; - - public VendorStub() : base("the stub") - { - } - - public VendorStub(Serial serial) : base(serial) - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - } - // BaseVendor is generated on top of the generated BaseCreature; the chain must write // and read both sections in order with exact consumption. [Fact] public void GeneratedVendorChain_RoundTrips() { - var vendor = new VendorStub(); - _created.Add(vendor); + var vendor = new TestVendor(); + _harness.Track(vendor); vendor.Home = new Point3D(1500, 1600, 0); vendor.RangeHome = 2; var buffer = Snapshot(vendor); - var copy = new VendorStub(World.NewMobile); - _created.Add(copy); + var copy = new TestVendor(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -378,7 +304,7 @@ public class BaseCreatureSerializationTests : IDisposable writer.Write(0.0); // passive move speed (v22) } - private CreatureStub LoadLegacyV22( + private TestCreature LoadLegacyV22( bool controlled = false, Mobile controlMaster = null, bool summoned = false, @@ -390,7 +316,7 @@ public class BaseCreatureSerializationTests : IDisposable // Mobile donor produces a byte-authentic one. var donor = new MobileStub(); donor.DefaultMobileInit(); - _created.Add(donor); + _harness.Track(donor); var writer = new BufferWriter(true); donor.Serialize(writer); @@ -399,8 +325,8 @@ public class BaseCreatureSerializationTests : IDisposable var buffer = new byte[writer.Position]; writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); - var copy = new CreatureStub(World.NewMobile); - _created.Add(copy); + var copy = new TestCreature(World.NewMobile); + _harness.Track(copy); var reader = new BufferReader(buffer); copy.Deserialize(reader); @@ -499,7 +425,7 @@ public class BaseCreatureSerializationTests : IDisposable var donor = new MobileStub(); donor.DefaultMobileInit(); - _created.Add(donor); + _harness.Track(donor); var writer = new BufferWriter(true); donor.Serialize(writer); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs index f01e5c7bd..be314b575 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs @@ -1,8 +1,8 @@ using System; -using System.Collections.Generic; using Server; using Server.Mobiles; using Server.Spells.Necromancy; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -11,40 +11,13 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class DeletedMasterTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); - private sealed class Stub : BaseCreature - { - public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + private TestCreature NewCreature() => _harness.NewCreature(); - public Stub(Serial serial) : base(serial) - { - } - } - - private Stub NewCreature() - { - var bc = new Stub(); - _created.Add(bc); - return bc; - } - - private PlayerMobile NewPlayer() - { - var pm = new PlayerMobile(World.NewMobile); - pm.DefaultMobileInit(); - World.AddEntity(pm); - _created.Add(pm); - return pm; - } + private PlayerMobile NewPlayer() => _harness.NewPlayer(); [Fact] public void DeletingAPlayer_ReleasesAnUnbondedPet() diff --git a/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs index 20578ecfc..bb027a951 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs @@ -1,7 +1,7 @@ using System; -using System.Collections.Generic; using Server; using Server.Mobiles; +using Server.Tests; using Xunit; namespace UOContent.Tests.Mobiles; @@ -10,40 +10,13 @@ namespace UOContent.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class MasterViewsTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); - private sealed class Stub : BaseCreature - { - public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + private TestCreature NewCreature() => _harness.NewCreature(); - public Stub(Serial serial) : base(serial) - { - } - } - - private Stub NewCreature() - { - var bc = new Stub(); - _created.Add(bc); - return bc; - } - - private PlayerMobile NewPlayer() - { - var pm = new PlayerMobile(World.NewMobile); - pm.DefaultMobileInit(); - World.AddEntity(pm); - _created.Add(pm); - return pm; - } + private PlayerMobile NewPlayer() => _harness.NewPlayer(); public enum Shape { diff --git a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs index f61ca6229..fac93cb54 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs @@ -9,15 +9,9 @@ namespace Server.Tests.Mobiles; [Collection("Sequential UOContent Tests")] public class VendorBuybackTests : IDisposable { - private readonly List _created = []; + private readonly MobileTestHarness _harness = new(); - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } + public void Dispose() => _harness.Dispose(); private sealed class BandageSB : SBInfo { @@ -30,34 +24,9 @@ public class VendorBuybackTests : IDisposable public override List BuyInfo { get; } = []; } - private sealed class VendorStub : BaseVendor + private sealed class BandageVendor : TestVendor { - private readonly List _sbInfos = []; - - public VendorStub() : base("the stub") - { - } - - public VendorStub(Serial serial) : base(serial) - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - _sbInfos.Add(new BandageSB()); - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } + protected override void AddSBInfos(List sbInfos) => sbInfos.Add(new BandageSB()); } // Same sell price as BandageSB, but a buy list at the 250-entry limit so buyback capacity is 0. @@ -81,55 +50,36 @@ public class VendorBuybackTests : IDisposable public override List BuyInfo => _buyInfo; } - private sealed class FullStockVendorStub : BaseVendor + private sealed class FullStockVendor : TestVendor { - private readonly List _sbInfos = []; - - public FullStockVendorStub() : base("the stub") - { - } - - protected override List SBInfos => _sbInfos; - - public override void InitSBInfo() - { - _sbInfos.Add(new FullStockSB()); - } - - public override void InitOutfit() - { - } - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } + protected override void AddSBInfos(List sbInfos) => sbInfos.Add(new FullStockSB()); } - private VendorStub NewVendor() - { - var vendor = new VendorStub(); - _created.Add(vendor); - return vendor; - } + private BandageVendor NewVendor() => _harness.Track(new BandageVendor()); - private FullStockVendorStub NewFullStockVendor() - { - var vendor = new FullStockVendorStub(); - _created.Add(vendor); - return vendor; - } + private FullStockVendor NewFullStockVendor() => _harness.Track(new FullStockVendor()); private Mobile NewSeller() { - var seller = new Mobile(World.NewMobile); + var seller = _harness.Track(new Mobile(World.NewMobile)); seller.DefaultMobileInit(); seller.AddItem(new Backpack()); - _created.Add(seller); return seller; } + // A fresh seller offers a stack of 10 bandages and sells of them. + private Bandage SellBandages(BaseVendor vendor, int amount, out Mobile seller) + { + seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + + Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, amount)])); + return bandages; + } + + private Bandage SellBandages(BaseVendor vendor, int amount) => SellBandages(vendor, amount, out _); + [Theory] [InlineData(0.25, 1.0)] // restocked 15m ago: next boundary is the regular one [InlineData(5.0, 6.0)] // exactly on a boundary: strictly after now @@ -160,11 +110,7 @@ public class VendorBuybackTests : IDisposable public void PartialStackSale_SplitGoesToBuyback_SellerKeepsRemainder() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - - Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 4)])); + var bandages = SellBandages(vendor, 4, out var seller); Assert.Equal(6, bandages.Amount); Assert.Same(seller.Backpack, bandages.Parent); @@ -181,10 +127,7 @@ public class VendorBuybackTests : IDisposable public void Restock_PurgesBuyback() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + var bandages = SellBandages(vendor, 10); Assert.Single(vendor.BuyPack.Items); vendor.Restock(); @@ -197,20 +140,14 @@ public class VendorBuybackTests : IDisposable public void Restock_ClearsPurgeSchedule_AndASubsequentSaleRearmsIt() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); vendor.Restock(); Assert.False(vendor.BuybackPurgeScheduled); - var seller2 = NewSeller(); - var moreBandages = new Bandage(10); - seller2.Backpack.DropItem(moreBandages); - vendor.OnSellItems(seller2, [new SellItemResponse(moreBandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); } @@ -219,10 +156,7 @@ public class VendorBuybackTests : IDisposable public void Delete_CancelsBuybackPurgeTimer() { var vendor = NewVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + SellBandages(vendor, 10); Assert.True(vendor.BuybackPurgeScheduled); vendor.Delete(); @@ -239,7 +173,8 @@ public class VendorBuybackTests : IDisposable var legacy = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; vendor.AddItem(legacy); - var children = new Item[20_000]; + // Several children, so a delete loop that skips entries as the list shrinks would show. + var children = new Item[3]; for (var i = 0; i < children.Length; i++) { children[i] = new Item(0x1234); @@ -261,11 +196,7 @@ public class VendorBuybackTests : IDisposable public void SaleWithNoBuybackCapacity_ConsumesItem_AndStillPays() { var vendor = NewFullStockVendor(); - var seller = NewSeller(); - var bandages = new Bandage(10); - seller.Backpack.DropItem(bandages); - - Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)])); + var bandages = SellBandages(vendor, 10, out var seller); Assert.True(bandages.Deleted); Assert.Empty(vendor.BuyPack.Items); diff --git a/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs b/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs index b981d9f86..d603a0c12 100644 --- a/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs +++ b/Projects/UOContent.Tests/Tests/Multis/Houses/HousePackets.cs @@ -216,7 +216,8 @@ public sealed class DesignStateDetailed : Packet if (deflatedLength == 0) { - Console.WriteLine("Compression error"); + // An oracle that silently emits a truncated plane would make the comparison meaningless. + throw new InvalidOperationException("Compression error"); } Write((byte)(0x20 | i)); @@ -248,7 +249,8 @@ public sealed class DesignStateDetailed : Packet if (deflatedLength == 0) { - Console.WriteLine("Compression error"); + // An oracle that silently emits a truncated plane would make the comparison meaningless. + throw new InvalidOperationException("Compression error"); } Write((byte)(9 + i)); diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs index b3a6d291f..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"); diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs index 1e1c0fedf..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,6 +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); } } 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/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 26e289f0c..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] 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/Packets/AuthIdTests.cs b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs index 2fb0f73cb..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)); @@ -336,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. @@ -349,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); diff --git a/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs b/Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs index 5cf7dbeea..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,16 +10,32 @@ 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); From 54c0040ac83bbb9c56a6a4528168a3c6c01dda03 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:07:40 -0700 Subject: [PATCH 20/26] test: run tile-dependent tests on synthetic terrain instead of client files (#2725) ## Summary About 114 tests skipped on CI because they needed the official client files (`tiledata.mul`, map and multi files). They gave CI no signal, and locally they ran against whichever client a developer had installed. The test hosts now build their own world instead, so every test runs on CI and runs the same on every machine. CI simulation: Server.Tests 837 passed, UOContent.Tests 1131 passed, **0 skipped**. - **Generated tile data.** The fixtures write a pre-HS `tiledata.mul` from a small invented tile kit (`Server.Tests/Terrain/TestTiles`) and load it through the real `TileData.Load()`. The test hosts no longer read any client directory (`MODERNUO_CLIENT_PATH`, `MODERNUO_TEST_DATA_DIR` and `C:\Ultima Online Classic` are gone from test code). The file is written to `SyntheticData` in each test project's output, overwritten every run and removed by `dotnet clean`. - **Injected terrain.** `TestTerrain` stamps land and statics into Felucca and Trammel, which share one terrain. Writes after startup must go in a `TestTerrain.Scope()`, which puts back every block it touched and keeps the step cache fresh. - **Prefab scenes.** Seven named scenes (town, causeway, sewer walkway, coastline, deep sea, map-edge sea, an upstairs room with an L-shaped counter) replace the Britain coordinates the pathing, AI and fishing tests relied on. Each scene-based test asserts its scene is present first, so a missing scene fails instead of passing on flat ground. Stamping all seven takes 3-5 ms. - **Multi footprints.** Contrived footprints for the three multi IDs content looks up (0x74, 0x7A, 0x0) are seeded through a new internal `MultiData.SetComponents`. - **Fingerprint.** `StepCacheFile.HashTileDataFile` is extracted so a test can show that a one-flag change to the file changes the fingerprint, while runtime table mutation does not. ### Engine changes - `TileMatrix.GetLandBlock`/`GetStaticBlock`: on a map without its files, return the block injected with `SetLandBlock`/`SetStaticBlock`, or the default when nothing was injected. Maps with files take the same path as before. - `MultiData.SetComponents` (internal): registers a footprint without multi files. ### Test changes worth a look - Values that were properties of real Britain terrain (probes at Z 10, a pinned plain walk mask of 0xC1) are re-pinned to the synthetic plain (Z 0, 0xFF). - No tests were deleted. The multi walkability and boat-deck tests assert movement behaviour over a footprint, not what a real multi looks like, so they run on the contrived footprints. - Maps 0 and 1 keep file indexes 0 and 1, and every stamp writes both, rather than sharing one file index. On a map without files the getters never consult the shared-file cache, and its patch bits would hide one facet's injection from the other. ## Test plan - [x] `dotnet test -p:SolutionDir=... --filter "Category!=Manual"` with the client env vars pointed at an empty directory: 837 + 1131 passed, 0 skipped, 0 failed - [x] Mutation check: removing the step-cache clear from `TestTerrain` makes the stale-cache tests fail - [x] CI green on all platforms --- .../Server.Tests/Fixtures/ServerFixture.cs | 35 +- .../Fixtures/TestServerInitializer.cs | 125 +------ Projects/Server.Tests/Server.Tests.csproj | 4 + Projects/Server.Tests/Terrain/Prefabs.cs | 306 ++++++++++++++++++ .../Server.Tests/Terrain/SyntheticTileData.cs | 108 +++++++ .../Server.Tests/Terrain/SyntheticWorld.cs | 27 ++ Projects/Server.Tests/Terrain/TestMultis.cs | 63 ++++ Projects/Server.Tests/Terrain/TestTerrain.cs | 215 ++++++++++++ Projects/Server.Tests/Terrain/TestTiles.cs | 61 ++++ .../Tests/Maps/CanFitItemTests.cs | 30 +- .../Tests/Maps/CanSpawnMobileTests.cs | 8 +- .../Tests/Maps/CanSpawnMobileTileDataTests.cs | 37 +-- .../Tests/Maps/StaticTileEnumeratorTests.cs | 2 +- .../Tests/Maps/TileMatrixInjectionTests.cs | 79 +++++ .../Packets/Outgoing/ItemPacketTests.cs | 2 +- .../Tests/Terrain/SyntheticTileDataTests.cs | 59 ++++ .../Tests/Terrain/TestMultisTests.cs | 33 ++ .../Tests/Terrain/TestTerrainTests.cs | 125 +++++++ Projects/Server/Client/MultiData.cs | 6 + Projects/Server/TileMatrix/TileMatrix.cs | 16 +- .../Fixtures/TestServerInitializer.cs | 88 +---- .../Fixtures/TileDataRequirement.cs | 18 -- .../UOContent.Tests/Helpers/AiTestStubs.cs | 20 -- .../Tests/Engines/Harvest/FishingTests.cs | 44 ++- .../Pathing/BitmapAStarAlgorithmTests.cs | 7 +- .../Engines/Pathing/Multi/BoatPathTests.cs | 13 +- .../Pathing/Multi/FoundationRedesignTests.cs | 14 +- .../Pathing/Multi/HousePathRoutingTests.cs | 11 +- .../Pathing/Multi/MultiCacheUsedTests.cs | 7 +- .../Pathing/Multi/MultiEdgeCaseTests.cs | 25 +- .../Pathing/Multi/MultiMaskCacheTests.cs | 56 ++-- .../Pathing/Multi/MultiMaskSynthesisTests.cs | 6 +- .../Pathing/Multi/MultiPathInvariantTests.cs | 17 +- .../Pathing/Multi/MultiSplitRoutingTests.cs | 8 +- .../Pathing/Multi/MultiWalkabilityTests.cs | 11 +- .../Engines/Pathing/PathingTestSupport.cs | 5 +- .../Engines/Pathing/StepCacheFileTests.cs | 23 +- .../Pathing/StepCacheFingerprintTests.cs | 35 +- .../Pathing/StepCacheLifecycleTests.cs | 56 ++-- .../Engines/Pathing/StepCacheParityTests.cs | 80 +++-- .../TreasureMapChestGuardianTests.cs | 4 +- .../Tests/Mobiles/AI/ApproachOutcomeTests.cs | 10 +- .../Tests/Mobiles/AI/ApproachTargetTests.cs | 48 +-- .../Mobiles/AI/DetectHiddenTimerTests.cs | 3 +- .../Tests/Mobiles/AI/FamiliarAITests.cs | 55 ++-- .../Mobiles/AI/GuardFriendlyFireTests.cs | 29 +- .../Tests/Mobiles/AI/HerdingTests.cs | 3 +- .../Mobiles/AI/SummonAcquireRulesTests.cs | 9 +- .../Tests/Terrain/TerrainScopeCacheTests.cs | 54 ++++ .../UOContent.Tests/UOContent.Tests.csproj | 4 + .../Engines/Pathing/Cache/StepCacheFile.cs | 14 +- 51 files changed, 1517 insertions(+), 601 deletions(-) create mode 100644 Projects/Server.Tests/Terrain/Prefabs.cs create mode 100644 Projects/Server.Tests/Terrain/SyntheticTileData.cs create mode 100644 Projects/Server.Tests/Terrain/SyntheticWorld.cs create mode 100644 Projects/Server.Tests/Terrain/TestMultis.cs create mode 100644 Projects/Server.Tests/Terrain/TestTerrain.cs create mode 100644 Projects/Server.Tests/Terrain/TestTiles.cs create mode 100644 Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs create mode 100644 Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs create mode 100644 Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs create mode 100644 Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs delete mode 100644 Projects/UOContent.Tests/Fixtures/TileDataRequirement.cs create mode 100644 Projects/UOContent.Tests/Tests/Terrain/TerrainScopeCacheTests.cs diff --git a/Projects/Server.Tests/Fixtures/ServerFixture.cs b/Projects/Server.Tests/Fixtures/ServerFixture.cs index e2795848f..cdc3aade5 100644 --- a/Projects/Server.Tests/Fixtures/ServerFixture.cs +++ b/Projects/Server.Tests/Fixtures/ServerFixture.cs @@ -1,41 +1,28 @@ +using Server.Tests.Terrain; using Xunit; namespace Server.Tests; /// -/// Fixture for all server tests. Attempts to load TileData from client files if available. -/// Configure client path via MODERNUO_CLIENT_PATH environment variable or place files at C:\Ultima Online Classic. +/// Fixture for all server tests. The host runs on generated tile data and terrain (see ). /// [CollectionDefinition("Sequential Server Tests", DisableParallelization = true)] public class ServerFixture : ICollectionFixture { - /// - /// True if TileData was successfully loaded from client files. - /// - public static bool TileDataLoaded => TestServerInitializer.TileDataLoaded; + /// A tile with the Surface flag. + public static ushort SurfaceTileId => TestTiles.Floor; - /// - /// A tile ID known to have the Surface flag. 0 if not found. - /// - public static ushort SurfaceTileId => TestServerInitializer.SurfaceTileId; + /// A tile with the Impassable flag. + public static ushort ImpassableTileId => TestTiles.Rock; - /// - /// A tile ID known to have the Impassable flag. 0 if not found. - /// - public static ushort ImpassableTileId => TestServerInitializer.ImpassableTileId; + /// A tile with the Wet flag (water). + public static ushort WetTileId => TestTiles.Water; - /// - /// A tile ID known to have the Wet flag (water). 0 if not found. - /// - public static ushort WetTileId => TestServerInitializer.WetTileId; - - /// - /// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found. - /// - public static ushort SurfaceImpassableTileId => TestServerInitializer.SurfaceImpassableTileId; + /// A tile with both Surface and Impassable flags (tables, furniture). + public static ushort SurfaceImpassableTileId => TestTiles.Counter; // Global init runs exactly once via the shared, guarded TestServerInitializer. The single // bootstrap owns global state for the lifetime of the test host, so there is no // per-collection teardown — matching UOContent.Tests' TestServerInitializer pattern. - public ServerFixture() => TestServerInitializer.Initialize(loadTileData: true); + public ServerFixture() => TestServerInitializer.Initialize(); } diff --git a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs index 845efb115..9a77377c5 100644 --- a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -1,9 +1,9 @@ using System; -using System.IO; using System.Reflection; using System.Threading; using Server.Items; using Server.Tests.Maps; +using Server.Tests.Terrain; namespace Server.Tests; @@ -13,51 +13,18 @@ namespace Server.Tests; /// internal static class TestServerInitializer { - private const string DefaultDataDirectory = @"C:\Ultima Online Classic"; private static bool _initialized; private static readonly Lock _lock = new(); - /// - /// True if TileData was successfully loaded from client files. - /// - public static bool TileDataLoaded { get; private set; } - - /// - /// A tile ID known to have the Surface flag. 0 if not found. - /// - public static ushort SurfaceTileId { get; private set; } - - /// - /// A tile ID known to have the Impassable flag. 0 if not found. - /// - public static ushort ImpassableTileId { get; private set; } - - /// - /// A tile ID known to have the Wet flag (water). 0 if not found. - /// - public static ushort WetTileId { get; private set; } - - /// - /// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found. - /// - public static ushort SurfaceImpassableTileId { get; private set; } - /// /// Initializes the test server. Safe to call multiple times - only initializes once. /// - /// If true, attempts to load TileData from client files. - public static void Initialize(bool loadTileData = false) + public static void Initialize() { lock (_lock) { if (_initialized) { - // Already initialized, but check if we need to load TileData now - if (loadTileData && !TileDataLoaded && TileData.MaxItemValue == 0) - { - TryLoadTileData(); - DetectTileIds(); - } return; } @@ -66,11 +33,7 @@ internal static class TestServerInitializer // Load Configurations ServerConfiguration.Load(true); - // Try to load TileData if requested - if (loadTileData) - { - TryLoadTileData(); - } + SyntheticWorld.LoadData(); // Load an empty assembly list into the resolver ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory); @@ -93,6 +56,7 @@ internal static class TestServerInitializer // Configure / Initialize TestMapDefinitions.ConfigureTestMapDefinitions(); + SyntheticWorld.StampTerrain(); // Configure the world World.Configure(); @@ -102,90 +66,9 @@ internal static class TestServerInitializer World.ExitSerializationThreads(); - // Detect tile IDs if TileData was loaded - if (loadTileData) - { - DetectTileIds(); - } - DecayScheduler.Configure(); _initialized = true; } } - - private static void TryLoadTileData() - { - var dataDir = GetDataDirectory(); - if (string.IsNullOrEmpty(dataDir) || !Directory.Exists(dataDir)) - { - return; - } - - ServerConfiguration.DataDirectories.Add(dataDir); - - var tileDataPath = Core.FindDataFile("tiledata.mul", false); - if (File.Exists(tileDataPath)) - { - TileData.Load(); - } - } - - private static void DetectTileIds() - { - if (TileData.MaxItemValue == 0) - { - return; - } - - SurfaceTileId = FindTileWithFlag(TileFlag.Surface); - ImpassableTileId = FindTileWithFlag(TileFlag.Impassable); - WetTileId = FindTileWithFlag(TileFlag.Wet); - SurfaceImpassableTileId = FindTileWithFlags(TileFlag.Surface | TileFlag.Impassable); - TileDataLoaded = SurfaceTileId > 0 && ImpassableTileId > 0 && WetTileId > 0; - } - - private static ushort FindTileWithFlag(TileFlag flag) - { - for (ushort i = 1; i <= TileData.MaxItemValue && i < 0xFFFF; i++) - { - var data = TileData.ItemTable[i]; - if ((data.Flags & flag) != 0) - { - return i; - } - } - return 0; - } - - private static ushort FindTileWithFlags(TileFlag flags) - { - for (ushort i = 1; i <= TileData.MaxItemValue && i < 0xFFFF; i++) - { - var data = TileData.ItemTable[i]; - if ((data.Flags & flags) == flags) - { - return i; - } - } - return 0; - } - - private static string GetDataDirectory() - { - // Check environment variable first - var envDir = System.Environment.GetEnvironmentVariable("MODERNUO_CLIENT_PATH"); - if (!string.IsNullOrEmpty(envDir) && Directory.Exists(envDir)) - { - return envDir; - } - - // Fall back to default directory - if (Directory.Exists(DefaultDataDirectory)) - { - return DefaultDataDirectory; - } - - return null; - } } diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 3d1190779..c97b92c94 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -26,4 +26,8 @@ + + + + diff --git a/Projects/Server.Tests/Terrain/Prefabs.cs b/Projects/Server.Tests/Terrain/Prefabs.cs new file mode 100644 index 000000000..677b5f14b --- /dev/null +++ b/Projects/Server.Tests/Terrain/Prefabs.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; + +namespace Server.Tests.Terrain; + +/// +/// A named scene stamped once into the shared synthetic terrain. Tests that rely on one call +/// first, so a missing scene fails instead of passing on flat default ground. +/// +public sealed class Prefab +{ + private readonly Action _stamp; + private readonly Func _isPresent; + + public Prefab(string name, Rectangle2D bounds, Point3D anchor, Action stamp, Func isPresent) + { + Name = name; + Bounds = bounds; + Anchor = anchor; + _stamp = stamp; + _isPresent = isPresent; + } + + public string Name { get; } + + public Rectangle2D Bounds { get; } + + /// The cell a test starts from, at its standing Z. + public Point3D Anchor { get; } + + /// A second named cell, for scenes with a start and a goal. + public Point3D Target { get; init; } + + internal void Stamp() => _stamp(this); + + public void AssertPresent() + { + if (!_isPresent(this)) + { + throw new InvalidOperationException($"Prefab {Name} is not stamped"); + } + } +} + +/// +/// The shared scenes. Each lives in its own part of the region (256, 256)-(1280, 1280), and they never +/// overlap. is the exception: it has to touch the map edge. Bespoke terrain belongs +/// outside that region; only tests about prefabs themselves stamp over one. +/// +public static class Prefabs +{ + private static bool _stamped; + + // Sea to the west at Z -5, grass to the east at Z 0. Anchor: the first grass cell east of the water. + public static Prefab Coastline { get; } = new( + "Coastline", + new Rectangle2D(256, 256, 32, 32), + new Point3D(272, 272, 0), + p => + { + TestTerrain.FillLand(new Rectangle2D(p.Bounds.X, p.Bounds.Y, 16, 32), TestTiles.SeaWater, -5); + TestTerrain.FillLand(new Rectangle2D(p.Bounds.X + 16, p.Bounds.Y, 16, 32), TestTiles.Grass, 0); + }, + p => IsLand(p.Anchor.X, p.Anchor.Y, TestTiles.Grass, 0) && IsLand(p.Anchor.X - 1, p.Anchor.Y, TestTiles.SeaWater, -5) + ); + + // Open water 64 tiles square, ringed by the default land. Anchor: the middle. + public static Prefab DeepSea { get; } = new( + "DeepSea", + new Rectangle2D(320, 256, 64, 64), + new Point3D(352, 288, -5), + p => TestTerrain.FillLand(p.Bounds, TestTiles.SeaWater, -5), + p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.SeaWater, -5) && + IsLand(p.Bounds.End.X - 1, p.Bounds.End.Y - 1, TestTiles.SeaWater, -5) && + IsLand(p.Anchor.X, p.Anchor.Y, TestTiles.SeaWater, -5) + ); + + // Open water against the west edge of the map. Anchor: 13 tiles in from the edge. + public static Prefab MapEdgeSea { get; } = new( + "MapEdgeSea", + new Rectangle2D(0, 176, 48, 48), + new Point3D(13, 200, -5), + p => TestTerrain.FillLand(p.Bounds, TestTiles.SeaWater, -5), + p => IsLand(0, p.Anchor.Y, TestTiles.SeaWater, -5) && IsLand(p.Bounds.End.X - 1, p.Anchor.Y, TestTiles.SeaWater, -5) + ); + + // Two walled buildings on open ground: one with a doorway and a counter inside, one with a window, and a + // ramp climbing two Z a tile to a walled deck at Z 20 over open ground, so its cells stand at two Zs. + // Anchor: the ground just outside the first building's doorway. + public static Prefab Town { get; } = new( + "Town", + new Rectangle2D(448, 256, 32, 32), + new Point3D(455, 271, 0), + p => + { + int x0 = p.Bounds.X, y0 = p.Bounds.Y; + + WallRing(x0 + 2, y0 + 2, x0 + 13, y0 + 13, 0, x0 + 7, y0 + 13); + for (var x = x0 + 4; x <= x0 + 10; x++) + { + TestTerrain.AddStatic(x, y0 + 6, TestTiles.Counter, 0); + } + + WallRing(x0 + 24, y0 + 20, x0 + 29, y0 + 25, 0, x0 + 26, y0 + 20); + TestTerrain.ClearStatics(x0 + 29, y0 + 22); + TestTerrain.AddStatic(x0 + 29, y0 + 22, TestTiles.Window, 0); + + // The ramp runs north up x0 + 17 from y0 + 20 (Z 2) to y0 + 11 (Z 20), onto the deck. + for (var i = 0; i < 10; i++) + { + TestTerrain.AddStatic(x0 + 17, y0 + 20 - i, TestTiles.Floor, (sbyte)(2 + 2 * i)); + } + + for (var x = x0 + 15; x <= x0 + 20; x++) + { + for (var y = y0 + 2; y <= y0 + 10; y++) + { + var edge = x == x0 + 15 || x == x0 + 20 || y == y0 + 2 || y == y0 + 10; + var ramp = x == x0 + 17 && y == y0 + 10; + TestTerrain.AddStatic(x, y, edge && !ramp ? TestTiles.Wall : TestTiles.Floor, 20); + } + } + }, + p => IsStatic(p.Bounds.X + 2, p.Bounds.Y + 2, TestTiles.Wall, 0) && + IsStatic(p.Bounds.X + 17, p.Bounds.Y + 5, TestTiles.Floor, 20) + ); + + // Impassable rock crossed by a T of bridge tiles. Anchor: on the bridge. + public static Prefab Causeway { get; } = new( + "Causeway", + new Rectangle2D(512, 256, 32, 32), + new Point3D(516, 272, 1), + p => + { + int x0 = p.Bounds.X, y0 = p.Bounds.Y; + TestTerrain.FillLand(p.Bounds, TestTiles.RockLand, 0); + + for (var x = x0; x < x0 + 32; x++) + { + for (var y = y0 + 15; y <= y0 + 17; y++) + { + TestTerrain.AddStatic(x, y, TestTiles.Bridge, 0); + } + } + + for (var y = y0; y < y0 + 15; y++) + { + for (var x = x0 + 15; x <= x0 + 17; x++) + { + TestTerrain.AddStatic(x, y, TestTiles.Bridge, 0); + } + } + }, + p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.RockLand, 0) && IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Bridge, 0) + ); + + // Impassable rock under a square loop of walkway three tiles wide at Z 5, the only ground to stand on. + // Anchor: on the walkway. + public static Prefab SewerWalkway { get; } = new( + "SewerWalkway", + new Rectangle2D(576, 256, 32, 32), + new Point3D(579, 271, 5), + p => + { + int x0 = p.Bounds.X, y0 = p.Bounds.Y; + TestTerrain.FillLand(p.Bounds, TestTiles.RockLand, 0); + + for (var x = x0 + 2; x <= x0 + 29; x++) + { + for (var y = y0 + 2; y <= y0 + 29; y++) + { + var onLoop = x <= x0 + 4 || x >= x0 + 27 || y <= y0 + 4 || y >= y0 + 27; + if (onLoop) + { + TestTerrain.AddStatic(x, y, TestTiles.Floor, 5); + } + } + } + }, + p => IsLand(p.Bounds.X, p.Bounds.Y, TestTiles.RockLand, 0) && IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Floor, 5) + ); + + // A walled upper floor at Z 20 with an L-shaped counter between the south and north halves; the only way + // round is past the east end. Anchor: south of the counter. Target: north of it. + public static Prefab InnUpstairs { get; } = new( + "InnUpstairs", + new Rectangle2D(640, 256, 24, 24), + new Point3D(648, 271, 20), + p => + { + int x0 = p.Bounds.X, y0 = p.Bounds.Y; + + for (var x = x0; x < x0 + 24; x++) + { + for (var y = y0; y < y0 + 24; y++) + { + var edge = x == x0 || x == x0 + 23 || y == y0 || y == y0 + 23; + TestTerrain.AddStatic(x, y, edge ? TestTiles.Wall : TestTiles.Floor, 20); + } + } + + for (var x = x0 + 1; x <= x0 + 16; x++) + { + TestTerrain.AddStatic(x, y0 + 11, TestTiles.Counter, 20); + } + + for (var y = y0 + 12; y <= y0 + 14; y++) + { + TestTerrain.AddStatic(x0 + 16, y, TestTiles.Counter, 20); + } + }, + p => IsStatic(p.Bounds.X + 8, p.Bounds.Y + 11, TestTiles.Counter, 20) && + IsStatic(p.Anchor.X, p.Anchor.Y, TestTiles.Floor, 20) + ) + { + Target = new Point3D(649, 262, 20) + }; + + public static IReadOnlyList All { get; } = + [Coastline, DeepSea, MapEdgeSea, Town, Causeway, SewerWalkway, InnUpstairs]; + + public static Prefab Named(string name) + { + for (var i = 0; i < All.Count; i++) + { + if (All[i].Name == name) + { + return All[i]; + } + } + + throw new ArgumentException($"No prefab named {name}", nameof(name)); + } + + /// Stamps every prefab once, then refuses terrain writes outside a . + public static void StampAll() + { + if (_stamped) + { + return; + } + + for (var i = 0; i < All.Count; i++) + { + for (var j = i + 1; j < All.Count; j++) + { + if (Overlaps(All[i].Bounds, All[j].Bounds)) + { + throw new InvalidOperationException($"Prefabs {All[i].Name} and {All[j].Name} overlap"); + } + } + } + + for (var i = 0; i < All.Count; i++) + { + All[i].Stamp(); + } + + _stamped = true; + TestTerrain.Sealed = true; + } + + // A ring of Wall from (x1, y1) to (x2, y2) inclusive, with one open cell at (gapX, gapY). + private static void WallRing(int x1, int y1, int x2, int y2, sbyte z, int gapX, int gapY) + { + for (var x = x1; x <= x2; x++) + { + for (var y = y1; y <= y2; y++) + { + var edge = x == x1 || x == x2 || y == y1 || y == y2; + if (edge && (x != gapX || y != gapY)) + { + TestTerrain.AddStatic(x, y, TestTiles.Wall, z); + } + } + } + } + + private static bool Overlaps(Rectangle2D a, Rectangle2D b) => + a.Start.X < b.End.X && b.Start.X < a.End.X && a.Start.Y < b.End.Y && b.Start.Y < a.End.Y; + + // Both facets must hold the tile; a scene on only one of them is not present. + private static bool IsLand(int x, int y, short id, sbyte z) + { + var felucca = Map.Felucca.Tiles.GetLandTile(x, y); + var trammel = Map.Trammel.Tiles.GetLandTile(x, y); + return felucca.ID == id && felucca.Z == z && trammel.ID == id && trammel.Z == z; + } + + private static bool IsStatic(int x, int y, ushort id, sbyte z) => + HasStatic(Map.Felucca, x, y, id, z) && HasStatic(Map.Trammel, x, y, id, z); + + private static bool HasStatic(Map map, int x, int y, ushort id, sbyte z) + { + var cell = map.Tiles.GetStaticBlock(x >> 3, y >> 3)[x & 7][y & 7]; + for (var i = 0; i < cell.Length; i++) + { + if (cell[i].ID == id && cell[i].Z == z) + { + return true; + } + } + + return false; + } +} diff --git a/Projects/Server.Tests/Terrain/SyntheticTileData.cs b/Projects/Server.Tests/Terrain/SyntheticTileData.cs new file mode 100644 index 000000000..93f0ca873 --- /dev/null +++ b/Projects/Server.Tests/Terrain/SyntheticTileData.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.Text; + +namespace Server.Tests.Terrain; + +/// +/// Writes as a pre-HS tiledata.mul, the smallest layout reads: +/// 0x4000 land records of 26 bytes and 0x4000 item records of 37 bytes, with a 4-byte header before every 32. +/// The file must stay under 1,644,544 bytes or TileData.Load picks the 7.0 layout. +/// +public static class SyntheticTileData +{ + private const int LandCount = 0x4000; + private const int ItemCount = 0x4000; + private const int NameLength = 20; + + public static byte[] Build(Action mutateItemFlags = null) + { + var landFlags = new TileFlag[LandCount]; + var landNames = new string[LandCount]; + for (var i = 0; i < TestTiles.Land.Count; i++) + { + var tile = TestTiles.Land[i]; + landFlags[tile.Id] = tile.Flags; + landNames[tile.Id] = tile.Name; + } + + var itemFlags = new TileFlag[ItemCount]; + var itemNames = new string[ItemCount]; + var itemHeights = new byte[ItemCount]; + for (var i = 0; i < TestTiles.Items.Count; i++) + { + var tile = TestTiles.Items[i]; + itemFlags[tile.Id] = tile.Flags; + itemNames[tile.Id] = tile.Name; + itemHeights[tile.Id] = tile.Height; + } + + mutateItemFlags?.Invoke(itemFlags); + + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + + for (var i = 0; i < LandCount; i++) + { + if ((i & 0x1F) == 0) + { + writer.Write(0); // header + } + + writer.Write((uint)landFlags[i]); + writer.Write((short)0); // texture + WriteName(writer, landNames[i]); + } + + for (var i = 0; i < ItemCount; i++) + { + if ((i & 0x1F) == 0) + { + writer.Write(0); // header + } + + writer.Write((uint)itemFlags[i]); + writer.Write((byte)0); // weight + writer.Write((byte)0); // quality + writer.Write((ushort)0); // animation + writer.Write((byte)0); + writer.Write((byte)0); // quantity + writer.Write(0); + writer.Write((byte)0); + writer.Write((byte)0); // value + writer.Write(itemHeights[i]); + WriteName(writer, itemNames[i]); + } + + writer.Flush(); + return stream.ToArray(); + } + + /// Writes tiledata.mul into and returns its path. + public static string WriteTo(string directory, Action mutateItemFlags = null) + { + var path = Path.Combine(directory, "tiledata.mul"); + File.WriteAllBytes(path, Build(mutateItemFlags)); + return path; + } + + /// Makes the only data directory and loads its tiledata.mul. + public static void LoadInto(string directory) + { + ServerConfiguration.DataDirectories.Clear(); + ServerConfiguration.DataDirectories.Add(directory); + TileData.Load(); + } + + private static void WriteName(BinaryWriter writer, string name) + { + Span buffer = stackalloc byte[NameLength]; + buffer.Clear(); + if (name != null) + { + Encoding.ASCII.GetBytes(name, buffer); + } + + writer.Write(buffer); + } +} diff --git a/Projects/Server.Tests/Terrain/SyntheticWorld.cs b/Projects/Server.Tests/Terrain/SyntheticWorld.cs new file mode 100644 index 000000000..df246584e --- /dev/null +++ b/Projects/Server.Tests/Terrain/SyntheticWorld.cs @@ -0,0 +1,27 @@ +using System.IO; + +namespace Server.Tests.Terrain; + +/// +/// Boots a test host on generated data instead of client files, so every machine and CI see the same world. +/// +public static class SyntheticWorld +{ + /// Where the generated data files are written: the test host's output, overwritten every run. + public static string DataDirectory => Path.Combine(Core.BaseDirectory, "SyntheticData"); + + /// + /// Writes and loads the generated tiledata.mul from , which becomes the only data + /// directory, and seeds the multi footprints. Run before anything reads . + /// + public static void LoadData() + { + Directory.CreateDirectory(DataDirectory); + SyntheticTileData.WriteTo(DataDirectory); + SyntheticTileData.LoadInto(DataDirectory); + TestMultis.Seed(); + } + + /// Stamps the prefab scenes. Run once Felucca and Trammel are registered. + public static void StampTerrain() => Prefabs.StampAll(); +} diff --git a/Projects/Server.Tests/Terrain/TestMultis.cs b/Projects/Server.Tests/Terrain/TestMultis.cs new file mode 100644 index 000000000..147e772bb --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestMultis.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; + +namespace Server.Tests.Terrain; + +/// +/// Invented footprints for the multi IDs content looks up through +/// (HouseFoundation, BaseBoat and the pathing tests). Only the IDs are real; the shapes are not. +/// Needs tile data loaded first: masks IDs with TileData.MaxItemValue. +/// +public static class TestMultis +{ + public const int GuildHouse = 0x74; + public const int Tower = 0x7A; + public const int SmallBoatNorth = 0x0; + + // MultiComponentList drops every entry after the first whose flags are 0. + private const TileFlag Visible = TileFlag.Background; + + private const short HouseFloorZ = 1; + + public static void Seed() + { + MultiData.SetComponents(GuildHouse, WalledHouse(3, doorwayOnSouth: true)); + MultiData.SetComponents(Tower, WalledHouse(4, doorwayOnSouth: false)); + MultiData.SetComponents(SmallBoatNorth, Boat()); + } + + // A square of Floor ringed by Wall, (2 * radius + 1) tiles a side, with a one-tile doorway of Floor + // in the middle of the south wall, or of the east wall. The floor sits one Z above the multi so it is + // clear of flat ground (MultiMaskCache.ComputeFootprintClean) yet still one step up from it. + private static MultiComponentList WalledHouse(int radius, bool doorwayOnSouth) + { + var entries = new List(); + for (var x = -radius; x <= radius; x++) + { + for (var y = -radius; y <= radius; y++) + { + var edge = x == -radius || x == radius || y == -radius || y == radius; + var doorway = doorwayOnSouth ? x == 0 && y == radius : x == radius && y == 0; + var id = edge && !doorway ? TestTiles.Wall : TestTiles.Floor; + entries.Add(new MultiTileEntry(id, (short)x, (short)y, HouseFloorZ, Visible)); + } + } + + return new MultiComponentList(entries); + } + + // A deck of Floor three tiles wide and seven long, with Wall rails down both sides. + private static MultiComponentList Boat() + { + var entries = new List(); + for (var x = -2; x <= 2; x++) + { + for (var y = -3; y <= 3; y++) + { + var id = x == -2 || x == 2 ? TestTiles.Wall : TestTiles.Floor; + entries.Add(new MultiTileEntry(id, (short)x, (short)y, 0, Visible)); + } + } + + return new MultiComponentList(entries); + } +} diff --git a/Projects/Server.Tests/Terrain/TestTerrain.cs b/Projects/Server.Tests/Terrain/TestTerrain.cs new file mode 100644 index 000000000..713a1cc22 --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestTerrain.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using Server.Engines.Pathing.Cache; + +namespace Server.Tests.Terrain; + +/// +/// Stamps land and statics into the synthetic terrain. Felucca and Trammel share one terrain, so every write +/// goes to both. Once has run, writes are only allowed inside a +/// , which puts back every block it touched when disposed. +/// +public static class TestTerrain +{ + // Blocks this class allocated, keyed by matrix and block. Anything else is the matrix's shared default + // block, which must never be written to. + private static readonly Dictionary<(TileMatrix, int, int), LandTile[]> _land = []; + private static readonly Dictionary<(TileMatrix, int, int), StaticTile[][][]> _statics = []; + + private static TerrainScope _scope; + + internal static bool Sealed { get; set; } + + /// Filled in by the compiler; names the opener if the scope is left open. + /// Filled in by the compiler. + public static TerrainScope Scope([CallerMemberName] string caller = null, [CallerFilePath] string callerFile = null) + { + if (_scope != null) + { + throw new InvalidOperationException( + $"A terrain scope opened by {_scope.Opener} is still open; dispose it (a using) first." + ); + } + + // Cached step masks describe the terrain as it was; drop them so nothing serves the old cells. + StepCache.Instance.Clear(); + return _scope = new TerrainScope($"{Path.GetFileNameWithoutExtension(callerFile)}.{caller}"); + } + + public static void SetLand(int x, int y, short id, sbyte z) + { + var facets = Facets(); + for (var i = 0; i < facets.Length; i++) + { + LandBlock(facets[i].Tiles, x >> 3, y >> 3)[((y & 7) << 3) + (x & 7)] = new LandTile(id, z); + } + } + + public static void FillLand(Rectangle2D area, short id, sbyte z) + { + for (var x = area.Start.X; x < area.End.X; x++) + { + for (var y = area.Start.Y; y < area.End.Y; y++) + { + SetLand(x, y, id, z); + } + } + } + + public static void AddStatic(int x, int y, ushort id, sbyte z) + { + var facets = Facets(); + for (var i = 0; i < facets.Length; i++) + { + var column = StaticBlock(facets[i].Tiles, x >> 3, y >> 3)[x & 7]; + column[y & 7] = [..column[y & 7], new StaticTile(id, z)]; + } + } + + public static void ClearStatics(int x, int y) + { + var facets = Facets(); + for (var i = 0; i < facets.Length; i++) + { + StaticBlock(facets[i].Tiles, x >> 3, y >> 3)[x & 7][y & 7] = []; + } + } + + internal static void EndScope(TerrainScope scope) + { + foreach (var ((tiles, bx, by), original) in scope.Land) + { + if (original == null) + { + _land.Remove((tiles, bx, by)); + } + else + { + _land[(tiles, bx, by)] = original; + } + + tiles.SetLandBlock(bx, by, original); + } + + foreach (var ((tiles, bx, by), original) in scope.Statics) + { + if (original == null) + { + _statics.Remove((tiles, bx, by)); + } + else + { + _statics[(tiles, bx, by)] = original; + } + + tiles.SetStaticBlock(bx, by, original); + } + + _scope = null; + StepCache.Instance.Clear(); + } + + private static Map[] Facets() + { + if (Sealed && _scope == null) + { + throw new InvalidOperationException("Stamp terrain inside TestTerrain.Scope() so it is put back afterwards."); + } + + // A test may query the cache and then stamp more; masks built before the stamp must not survive it. + if (_scope != null) + { + StepCache.Instance.Clear(); + } + + return [Map.Felucca, Map.Trammel]; + } + + private static LandTile[] LandBlock(TileMatrix tiles, int bx, int by) + { + var key = (tiles, bx, by); + _land.TryGetValue(key, out var owned); + + // The scope keeps the block as it was and the stamp goes into a copy, so disposing is a swap back. + if (_scope != null && !_scope.Land.ContainsKey(key)) + { + _scope.Land[key] = owned; + owned = (LandTile[])owned?.Clone(); + } + else if (owned != null) + { + return owned; + } + + var block = owned ?? new LandTile[64]; + _land[key] = block; + tiles.SetLandBlock(bx, by, block); + return block; + } + + private static StaticTile[][][] StaticBlock(TileMatrix tiles, int bx, int by) + { + var key = (tiles, bx, by); + _statics.TryGetValue(key, out var owned); + + if (_scope != null && !_scope.Statics.ContainsKey(key)) + { + _scope.Statics[key] = owned; + owned = owned == null ? null : CopyStatics(owned); + } + else if (owned != null) + { + return owned; + } + + var block = owned ?? CopyStatics(tiles.EmptyStaticBlock); + _statics[key] = block; + tiles.SetStaticBlock(bx, by, block); + return block; + } + + private static StaticTile[][][] CopyStatics(StaticTile[][][] source) + { + var copy = new StaticTile[8][][]; + for (var x = 0; x < 8; x++) + { + copy[x] = new StaticTile[8][]; + for (var y = 0; y < 8; y++) + { + copy[x][y] = source[x][y]; + } + } + + return copy; + } +} + +/// +/// Bespoke terrain for one test. Dispose it (a using) to put back every block it touched. +/// +public sealed class TerrainScope : IDisposable +{ + // The block each key held before this scope first wrote to it; null means it was the default. + internal Dictionary<(TileMatrix, int, int), LandTile[]> Land { get; } = []; + internal Dictionary<(TileMatrix, int, int), StaticTile[][][]> Statics { get; } = []; + + private bool _disposed; + + internal TerrainScope(string opener) => Opener = opener; + + /// The test that opened this scope, as Class.Method. + public string Opener { get; } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + TestTerrain.EndScope(this); + } +} diff --git a/Projects/Server.Tests/Terrain/TestTiles.cs b/Projects/Server.Tests/Terrain/TestTiles.cs new file mode 100644 index 000000000..22a03269b --- /dev/null +++ b/Projects/Server.Tests/Terrain/TestTiles.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; + +namespace Server.Tests.Terrain; + +public readonly record struct TestItemTile(ushort Id, string Name, TileFlag Flags, byte Height); + +public readonly record struct TestLandTile(short Id, string Name, TileFlag Flags); + +/// +/// The invented tiles the generated tiledata.mul holds. Every other entry in the file is zero. +/// IDs are arbitrary and carry no meaning from the client, except the content-referenced land below. +/// +public static class TestTiles +{ + public const ushort Wall = 0x3000; + public const ushort Floor = 0x3001; + public const ushort Counter = 0x3002; + public const ushort Stair = 0x3003; + public const ushort Bridge = 0x3004; + public const ushort Roof = 0x3005; + public const ushort Door = 0x3006; + public const ushort Window = 0x3007; + public const ushort Water = 0x3008; + public const ushort Rock = 0x3009; + public const ushort Plain = 0x300A; + + public const short Grass = 0x3000; + public const short RockLand = 0x3001; + public const short Dirt = 0x3002; + + // Fishing only treats these land IDs as water (Fishing.cs _waterLandTiles), so they keep their numbers. + public const short SeaWater = 0xA8; + + public static IReadOnlyList Items { get; } = + [ + new(Wall, "test wall", TileFlag.Wall | TileFlag.Impassable | TileFlag.NoShoot, 20), + new(Floor, "test floor", TileFlag.Surface | TileFlag.Background, 0), + new(Counter, "test counter", TileFlag.Surface | TileFlag.Impassable, 4), + new(Stair, "test stair", TileFlag.Bridge | TileFlag.Surface | TileFlag.StairBack, 5), + new(Bridge, "test bridge", TileFlag.Bridge | TileFlag.Surface, 2), + new(Roof, "test roof", TileFlag.Roof | TileFlag.Impassable, 0), + new(Door, "test door", TileFlag.Door | TileFlag.Impassable | TileFlag.NoShoot, 20), + new(Window, "test window", TileFlag.Window | TileFlag.Wall | TileFlag.Impassable, 20), + new(Water, "test water", TileFlag.Wet | TileFlag.Impassable | TileFlag.Surface, 0), + new(Rock, "test rock", TileFlag.Impassable, 20), + new(Plain, "test plain", TileFlag.None, 1) + ]; + + public static IReadOnlyList Land { get; } = + [ + new(Grass, "test grass", TileFlag.None), + new(RockLand, "test rock", TileFlag.Impassable), + new(Dirt, "test dirt", TileFlag.None), + new(0xA8, "test water", TileFlag.Wet | TileFlag.Impassable), + new(0xA9, "test water", TileFlag.Wet | TileFlag.Impassable), + new(0xAA, "test water", TileFlag.Wet | TileFlag.Impassable), + new(0xAB, "test water", TileFlag.Wet | TileFlag.Impassable), + new(0x136, "test water", TileFlag.Wet | TileFlag.Impassable), + new(0x137, "test water", TileFlag.Wet | TileFlag.Impassable) + ]; +} diff --git a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs index 5531e0530..ace2dfcf4 100644 --- a/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanFitItemTests.cs @@ -9,11 +9,6 @@ namespace Server.Tests.Maps; [Collection("Sequential Server Tests")] public class CanFitItemTests { - private void SkipIfNoTileData() - { - Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); - } - [Fact] public void CanFitItem_InternalMapReturnsFalse() { @@ -33,8 +28,7 @@ public class CanFitItemTests Assert.False(map.CanFitItem(100, map.Height + 1, 0, 1)); } - // Plain walkable land in the real Felucca map. Without map files every cell is the default land - // tile at Z 0 with no flags, which these tests treat the same way: a surface with nothing above it. + // The synthetic map's default ground: land ID 0 at Z 0 with no flags, a surface with nothing above it. private const int TestLandX = 1500; private const int TestLandY = 1600; @@ -61,12 +55,9 @@ public class CanFitItemTests Assert.False(result, "No surface 50 units above land"); } - [SkippableFact] + [Fact] public void CanFitItem_SurfaceImpassableMultiIsValidSurface() { - SkipIfNoTileData(); - Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData"); - var map = Map.Felucca; const int x = 1700; const int y = 1700; @@ -94,12 +85,9 @@ public class CanFitItemTests } } - [SkippableFact] + [Fact] public void CanFitItem_CanFitDoesNotAllowSurfaceImpassable() { - SkipIfNoTileData(); - Skip.If(ServerFixture.SurfaceImpassableTileId == 0, "No Surface+Impassable tile found in TileData"); - var map = Map.Felucca; const int x = 1750; const int y = 1750; @@ -130,11 +118,9 @@ public class CanFitItemTests } } - [SkippableFact] + [Fact] public void CanFitItem_NonMovableWorldItemSurfaceWorks() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1800; const int y = 1800; @@ -160,11 +146,9 @@ public class CanFitItemTests } } - [SkippableFact] + [Fact] public void CanFitItem_MovableWorldItemNotValidSurface() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1850; const int y = 1850; @@ -192,11 +176,9 @@ public class CanFitItemTests } } - [SkippableFact] + [Fact] public void CanFitItem_ImpassableTileBlocksPlacement() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1900; const int y = 1900; diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs index 23bc9d173..0fd8b41fa 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTests.cs @@ -3,8 +3,8 @@ using Xunit; namespace Server.Tests.Maps; /// -/// Tests for CanSpawnMobile that don't require TileData (client files). -/// For tests that require client files, see CanSpawnMobileTileDataTests. +/// Tests for CanSpawnMobile on the default ground. +/// For tests over tile flags, see CanSpawnMobileTileDataTests. /// [Collection("Sequential Server Tests")] public class CanSpawnMobileTests @@ -29,8 +29,8 @@ public class CanSpawnMobileTests Assert.False(map.CanSpawnMobile(100, map.Height + 1, -128, 127, false, false, out _)); } - // Plain walkable land near Britain in the real Felucca map. Without map files every cell is the - // default land tile at Z 0 with no flags, which CanSpawnMobile treats the same way: walkable, not water. + // The synthetic map's default ground: land ID 0 at Z 0 with no flags, which CanSpawnMobile treats as + // walkable, not water. private const int TestLandX = 1500; private const int TestLandY = 1600; diff --git a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs index 38d757463..a3daaf04c 100644 --- a/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs +++ b/Projects/Server.Tests/Tests/Maps/CanSpawnMobileTileDataTests.cs @@ -4,23 +4,14 @@ using Xunit; namespace Server.Tests.Maps; /// -/// Tests for CanSpawnMobile that require TileData (client files) to be loaded. -/// These tests will be skipped if client files are not available. -/// Configure client path via MODERNUO_CLIENT_PATH environment variable or place files at C:\Ultima Online Classic. +/// Tests for CanSpawnMobile over tile flags, using tiles from the generated test kit. /// [Collection("Sequential Server Tests")] public class CanSpawnMobileTileDataTests { - private void SkipIfNoTileData() - { - Skip.If(!ServerFixture.TileDataLoaded, "TileData not loaded - client files required"); - } - - [SkippableFact] + [Fact] public void CanSpawnMobile_FindsLowestValidSurface() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1200; const int y = 1200; @@ -44,11 +35,9 @@ public class CanSpawnMobileTileDataTests } } - [SkippableFact] + [Fact] public void CanSpawnMobile_ZRangeSelectsCorrectFloor() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1300; const int y = 1300; @@ -72,11 +61,9 @@ public class CanSpawnMobileTileDataTests } } - [SkippableFact] + [Fact] public void CanSpawnMobile_LowCeilingBlocksSpawn() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1400; const int y = 1400; @@ -99,11 +86,9 @@ public class CanSpawnMobileTileDataTests } } - [SkippableFact] + [Fact] public void CanSpawnMobile_HighCeilingAllowsSpawn() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1450; const int y = 1450; @@ -124,11 +109,9 @@ public class CanSpawnMobileTileDataTests } } - [SkippableFact] + [Fact] public void CanSpawnMobile_BlockerOutsideZRangeIgnored() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1475; const int y = 1475; @@ -150,11 +133,9 @@ public class CanSpawnMobileTileDataTests } } - [SkippableFact] + [Fact] public void CanSpawnMobile_WaterSurfaceForSwimmingMob() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1550; const int y = 1550; @@ -176,11 +157,9 @@ public class CanSpawnMobileTileDataTests } } - [SkippableFact] + [Fact] public void CanSpawnMobile_WorldItemSurfaceWorks() { - SkipIfNoTileData(); - var map = Map.Felucca; const int x = 1650; const int y = 1650; diff --git a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs index 9c6e01a36..70fc9e006 100644 --- a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs @@ -13,7 +13,7 @@ public class StaticTileEnumeratorTests var map = Map.Felucca; var location = new Point2D(200, 200); - // Real map files may have statics here; without them there are none. Either way the multi adds nothing. + // The synthetic map has no statics here; the multi must add nothing to the statics-only walk. var mapStatics = CollectTiles(map, location, includeStatics: true, includeMultis: false); TestMulti multi = null; diff --git a/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs b/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs new file mode 100644 index 000000000..443e142eb --- /dev/null +++ b/Projects/Server.Tests/Tests/Maps/TileMatrixInjectionTests.cs @@ -0,0 +1,79 @@ +using Xunit; + +namespace Server.Tests.Maps; + +[Collection("Sequential Server Tests")] +public class TileMatrixInjectionTests +{ + // Not registered in Map.Maps, and no data directory holds map96.mul, so this matrix has no file streams. + private static Map NewFilelessMap() => new(0x60, 0x60, 0x60, 64, 64, 0, "inject-test", MapRules.TrammelRules); + + [Fact] + public void InjectedLandBlock_IsReturned_WithNoMapFile() + { + var tiles = NewFilelessMap().Tiles; + var block = new LandTile[64]; + block[9] = new LandTile(0x10, 7); + + tiles.SetLandBlock(1, 1, block); + + var tile = tiles.GetLandTile(9, 9); + Assert.Equal(0x10, tile.ID); + Assert.Equal(7, tile.Z); + } + + [Fact] + public void UninjectedLand_IsIdZeroAtZZero() + { + var tile = NewFilelessMap().Tiles.GetLandTile(30, 30); + + Assert.Equal(0, tile.ID); + Assert.Equal(0, tile.Z); + } + + [Fact] + public void InjectedStaticBlock_IsReturned_WithNoStaticFiles() + { + var tiles = NewFilelessMap().Tiles; + var block = new StaticTile[8][][]; + for (var x = 0; x < 8; x++) + { + block[x] = new StaticTile[8][]; + for (var y = 0; y < 8; y++) + { + block[x][y] = []; + } + } + + block[1][1] = [new StaticTile(0x20, 5)]; + + tiles.SetStaticBlock(1, 1, block); + + var cell = tiles.GetStaticBlock(1, 1)[1][1]; + Assert.Single(cell); + Assert.Equal(0x20, cell[0].ID); + Assert.Equal(5, cell[0].Z); + } + + [Fact] + public void ClearingABlock_RestoresTheDefault() + { + var tiles = NewFilelessMap().Tiles; + var block = new LandTile[64]; + block[9] = new LandTile(0x10, 7); + tiles.SetLandBlock(1, 1, block); + + tiles.SetLandBlock(1, 1, null); + + Assert.Equal(0, tiles.GetLandTile(9, 9).ID); + } + + [Fact] + public void OutOfRange_StillReturnsDefault() + { + var tiles = NewFilelessMap().Tiles; + + Assert.Equal(0, tiles.GetLandBlock(-1, 0)[0].ID); + Assert.Equal(0, tiles.GetLandBlock(8, 0)[0].ID); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs index f9d5cb948..d2736a723 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ItemPacketTests.cs @@ -9,7 +9,7 @@ public class ItemPacketTests : IDisposable { private const int TestItemId = 1; - // TileData is global and holds real client data when client files are loaded, so each test restores it. + // TileData is global, so each test restores it. private readonly ItemData _originalItemData; public ItemPacketTests() diff --git a/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs b/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs new file mode 100644 index 000000000..12e057204 --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/SyntheticTileDataTests.cs @@ -0,0 +1,59 @@ +using System.IO; +using Xunit; + +namespace Server.Tests.Terrain; + +[Collection("Sequential Server Tests")] +public class SyntheticTileDataTests +{ + [Fact] + public void GeneratedFile_IsPreHsSize() + { + Assert.Equal(1_036_288, SyntheticTileData.Build().Length); + } + + // Inside the test host's output, rewritten every run, so runs leave nothing behind in the temp directory. + [Fact] + public void DataDirectory_IsTheOnlyDataDirectory_AndLivesInTheTestHost() + { + Assert.Equal(Path.Combine(Core.BaseDirectory, "SyntheticData"), SyntheticWorld.DataDirectory); + Assert.Equal([SyntheticWorld.DataDirectory], ServerConfiguration.DataDirectories); + } + + [Fact] + public void KitIds_FitThePreHsTable() + { + for (var i = 0; i < TestTiles.Items.Count; i++) + { + Assert.True(TestTiles.Items[i].Id < 0x4000, $"{TestTiles.Items[i].Name} is outside the pre-HS item table"); + } + + for (var i = 0; i < TestTiles.Land.Count; i++) + { + Assert.True(TestTiles.Land[i].Id < 0x4000, $"{TestTiles.Land[i].Name} is outside the land table"); + } + } + + [Fact] + public void GeneratedFile_RoundTripsKitThroughLoad() + { + SyntheticTileData.LoadInto(SyntheticWorld.DataDirectory); + + for (var i = 0; i < TestTiles.Items.Count; i++) + { + var tile = TestTiles.Items[i]; + Assert.Equal(tile.Flags, TileData.ItemTable[tile.Id].Flags); + Assert.Equal((int)tile.Height, TileData.ItemTable[tile.Id].Height); + Assert.Equal(tile.Name, TileData.ItemTable[tile.Id].Name); + } + + for (var i = 0; i < TestTiles.Land.Count; i++) + { + var tile = TestTiles.Land[i]; + Assert.Equal(tile.Flags, TileData.LandTable[tile.Id].Flags); + } + + Assert.Equal(TileFlag.None, TileData.ItemTable[0x2A0].Flags); + Assert.Equal(TileFlag.None, TileData.LandTable[3].Flags); + } +} diff --git a/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs b/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs new file mode 100644 index 000000000..afb35b784 --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/TestMultisTests.cs @@ -0,0 +1,33 @@ +using Xunit; + +namespace Server.Tests.Terrain; + +[Collection("Sequential Server Tests")] +public class TestMultisTests +{ + [Theory] + [InlineData(TestMultis.GuildHouse)] + [InlineData(TestMultis.Tower)] + [InlineData(TestMultis.SmallBoatNorth)] + public void Seed_RegistersFootprint(int multiID) + { + Assert.NotEmpty(MultiData.GetComponents(multiID).List); + } + + [Fact] + public void Footprints_DifferBetweenGuildHouseAndTower() + { + var house = MultiData.GetComponents(TestMultis.GuildHouse); + var tower = MultiData.GetComponents(TestMultis.Tower); + Assert.NotEqual(house.Width, tower.Width); + } + + [Fact] + public void GuildHouse_HasAnOpenDoorway() + { + var house = MultiData.GetComponents(TestMultis.GuildHouse); + var doorway = house.Tiles[house.Center.X][house.Height - 1]; + Assert.Single(doorway); + Assert.Equal(TestTiles.Floor, doorway[0].ID & TileData.MaxItemValue); + } +} diff --git a/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs b/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs new file mode 100644 index 000000000..8d04ae98f --- /dev/null +++ b/Projects/Server.Tests/Tests/Terrain/TestTerrainTests.cs @@ -0,0 +1,125 @@ +using System; +using Xunit; + +namespace Server.Tests.Terrain; + +[Collection("Sequential Server Tests")] +public class TestTerrainTests +{ + // Default ground outside the prefab region. + private const int X = 1400; + private const int Y = 1400; + + [Fact] + public void StampingOneTile_KeepsTheRestOfTheBlock() + { + using var scope = TestTerrain.Scope(); + + TestTerrain.SetLand(X, Y, TestTiles.Grass, 3); + TestTerrain.SetLand(X + 1, Y, TestTiles.Dirt, 4); + + Assert.Equal(TestTiles.Grass, Map.Felucca.Tiles.GetLandTile(X, Y).ID); + Assert.Equal(TestTiles.Dirt, Map.Felucca.Tiles.GetLandTile(X + 1, Y).ID); + } + + [Fact] + public void StampingAnUninjectedBlock_DoesNotTouchOtherBlocks() + { + using var scope = TestTerrain.Scope(); + + TestTerrain.SetLand(X, Y, TestTiles.Grass, 3); + + Assert.Equal(0, Map.Felucca.Tiles.GetLandTile(X + 64, Y + 64).ID); + Assert.Equal(0, Map.Felucca.Tiles.GetLandTile(X + 1, Y).ID); + } + + [Fact] + public void Stamps_ReachBothFacets() + { + using var scope = TestTerrain.Scope(); + + TestTerrain.SetLand(X, Y, TestTiles.Grass, 3); + TestTerrain.AddStatic(X, Y, TestTiles.Wall, 3); + + foreach (var map in (ReadOnlySpan)[Map.Felucca, Map.Trammel]) + { + Assert.Equal(TestTiles.Grass, map.Tiles.GetLandTile(X, Y).ID); + var statics = map.Tiles.GetStaticBlock(X >> 3, Y >> 3)[X & 7][Y & 7]; + Assert.Single(statics); + Assert.Equal(TestTiles.Wall, statics[0].ID); + } + } + + [Fact] + public void ScopeDispose_RestoresBothFacets() + { + using (TestTerrain.Scope()) + { + TestTerrain.SetLand(X, Y, TestTiles.Grass, 3); + TestTerrain.AddStatic(X, Y, TestTiles.Wall, 3); + } + + foreach (var map in (ReadOnlySpan)[Map.Felucca, Map.Trammel]) + { + Assert.Equal(0, map.Tiles.GetLandTile(X, Y).ID); + Assert.Empty(map.Tiles.GetStaticBlock(X >> 3, Y >> 3)[X & 7][Y & 7]); + } + } + + [Fact] + public void ScopeDispose_RestoresAPrefabItOverwrote() + { + Prefabs.StampAll(); + var coast = Prefabs.Coastline.Anchor; + var before = Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y); + + using (TestTerrain.Scope()) + { + TestTerrain.SetLand(coast.X, coast.Y, TestTiles.RockLand, 40); + } + + Assert.Equal(before.ID, Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y).ID); + Assert.Equal(before.Z, Map.Felucca.Tiles.GetLandTile(coast.X, coast.Y).Z); + Prefabs.Coastline.AssertPresent(); + } + + [Fact] + public void NestedScopes_Throw() + { + using var scope = TestTerrain.Scope(); + + var error = Assert.Throws(() => TestTerrain.Scope()); + Assert.Contains($"{nameof(TestTerrainTests)}.{nameof(NestedScopes_Throw)}", error.Message); + } + + [Fact] + public void StampingOutsideAScope_Throws_OncePrefabsAreStamped() + { + Prefabs.StampAll(); + + Assert.Throws(() => TestTerrain.SetLand(X, Y, TestTiles.Grass, 0)); + } + + [Fact] + public void StampAll_PrefabsDoNotOverlap_AndAreAllPresent() + { + Prefabs.StampAll(); + + for (var i = 0; i < Prefabs.All.Count; i++) + { + Prefabs.All[i].AssertPresent(); + } + } + + [Fact] + public void AssertPresent_Throws_WhenThePrefabIsGone() + { + Prefabs.StampAll(); + var coast = Prefabs.Coastline.Anchor; + + using var scope = TestTerrain.Scope(); + TestTerrain.SetLand(coast.X, coast.Y, 0, 0); + + Assert.Throws(() => Prefabs.Coastline.AssertPresent()); + } +} diff --git a/Projects/Server/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/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/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 index 9d954c062..6e69523b8 100644 --- a/Projects/UOContent.Tests/Helpers/AiTestStubs.cs +++ b/Projects/UOContent.Tests/Helpers/AiTestStubs.cs @@ -20,23 +20,3 @@ public sealed class BareCreatureStub : BaseCreature { public BareCreatureStub(Serial serial) : base(serial) => Body = 0xC9; } - -public static class TestTiles -{ - /// - /// The first item ID whose tile data is an impassable surface, so a test can wall a cell off - /// without pinning a specific graphic. 0 when tile data is not loaded. - /// - public static ushort FirstImpassableItemId() - { - for (ushort id = 1; id < TileData.MaxItemValue; id++) - { - if (TileData.ItemTable[id].ImpassableSurface) - { - return id; - } - } - - return 0; - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs index ae8fe35d9..328123d7a 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Harvest/FishingTests.cs @@ -5,6 +5,7 @@ using Server.Items; using Server.Mobiles; using Server.Random; using Server.Tests; +using Server.Tests.Terrain; using Xunit; namespace UOContent.Tests; @@ -12,22 +13,31 @@ namespace UOContent.Tests; [Collection("Sequential UOContent Tests")] public class FishingTests { - // Open sea with no land within 30 tiles: deep water once the client's map is loaded. - private static readonly Point3D _deep = new(1840, 1760, -5); - - [SkippableTheory] - [InlineData(1840, 1760, true)] // land 115 tiles away - [InlineData(1660, 1760, true)] // land 14 tiles away - [InlineData(1659, 1760, false)] // land 13 tiles away - [InlineData(2188, 1000, true)] // land 23 tiles away, on a diagonal - [InlineData(1718, 1913, false)] // land 8 tiles away, between the diagonals - [InlineData(13, 200, true)] // the edge of the map 14 tiles away - [InlineData(12, 200, false)] // the edge of the map 13 tiles away - public void FullValidation_NeedsLandAndTheMapEdgeAtLeast14TilesAway(int x, int y, bool deep) + [Theory] + [InlineData(32, 32, true)] // the middle of the sea: land 32 tiles away + [InlineData(13, 32, true)] // land 14 tiles west + [InlineData(12, 32, false)] // land 13 tiles west + [InlineData(50, 32, true)] // land 14 tiles east + [InlineData(51, 32, false)] // land 13 tiles east + [InlineData(13, 13, true)] // land 14 tiles away on the diagonal + [InlineData(13, 12, false)] // land 13 tiles north + public void FullValidation_NeedsLandAtLeast14TilesAway(int dx, int dy, bool deep) { - TileDataRequirement.SkipIfMissing(); + var sea = Prefabs.DeepSea; + sea.AssertPresent(); - Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, x, y)); + Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, sea.Bounds.X + dx, sea.Bounds.Y + dy)); + } + + [Theory] + [InlineData(13, true)] // the edge of the map 14 tiles away + [InlineData(12, false)] // the edge of the map 13 tiles away + public void FullValidation_TreatsTheMapEdgeAsLand(int x, bool deep) + { + var sea = Prefabs.MapEdgeSea; + sea.AssertPresent(); + + Assert.Equal(deep, SpecialFishingNet.FullValidation(Map.Felucca, x, sea.Anchor.Y)); } [Theory] @@ -46,7 +56,8 @@ public class FishingTests { // The rate RegenRates sets before AOS, so the hit is not healed before the serpent's five minutes are up. Mobile.DefaultHitsRate = TimeSpan.FromSeconds(11.0); - fisher.MoveToWorld(_deep, Map.Felucca); + Prefabs.DeepSea.AssertPresent(); + fisher.MoveToWorld(Prefabs.DeepSea.Anchor, Map.Felucca); var bottle = new MessageInABottle(Map.Felucca); Fishing.System.Give(fisher, bottle, false); @@ -93,7 +104,8 @@ public class FishingTests BuiltInRng.Generator = new System.Random(1); fisher.Skills.Fishing.Base = 65.0; fisher.AddItem(new Backpack()); - fisher.MoveToWorld(_deep, Map.Felucca); + Prefabs.DeepSea.AssertPresent(); + fisher.MoveToWorld(Prefabs.DeepSea.Anchor, Map.Felucca); var sos = AddSOS(fisher); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index ed8b8d006..ad63c7982 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -1,3 +1,4 @@ +using Server.Tests.Terrain; using System; using Server.Engines.Pathing.Cache; using Server.Mobiles; @@ -139,10 +140,9 @@ public class BitmapAStarAlgorithmTests : IDisposable blocker.Delete(); } - [SkippableFact] + [Fact] public void DynamicObstaclePass_RejectsCellOccupiedByImpassableItem() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); var map = Map.Maps[1]; Assert.NotNull(map); @@ -155,8 +155,7 @@ public class BitmapAStarAlgorithmTests : IDisposable var blockX = 1499; var blockY = 1599; - var blockerItemId = TestTiles.FirstImpassableItemId(); - 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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/BoatPathTests.cs index cf63d4858..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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/FoundationRedesignTests.cs index 5284df402..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,15 +18,16 @@ public class FoundationRedesignTests private const int PlaceX = 1500; private const int PlaceY = 1600; - [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); @@ -43,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); @@ -54,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 5ecb0cdc1..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,4 @@ +using Server.Tests.Terrain; using System; using Server.Engines.Pathing.Cache; using Server.Items; @@ -21,10 +22,9 @@ public class HousePathRoutingTests : IDisposable private const int MapId = 1; - [SkippableFact] + [Fact] public void PathAround_NeverTraversesAWallCell() { - TileDataRequirement.SkipIfMissing(); StepCache.Instance.Clear(); StepCache.Instance.MissPromotionThreshold = 1; var map = Map.Maps[MapId]; @@ -36,7 +36,7 @@ public class HousePathRoutingTests : IDisposable 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); @@ -72,17 +72,16 @@ public class HousePathRoutingTests : IDisposable } } - [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 50a27ee44..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,4 @@ +using Server.Tests.Terrain; using System; using Server.Engines.Pathing.Cache; using Server.PathAlgorithms; @@ -10,17 +11,15 @@ namespace Server.Tests.Pathfinding; 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(); - [SkippableFact] + [Fact] public void PathNearMulti_IncrementsMultiLocalHits() { - // Without the client's multi data the house has no footprint for the path to skirt. - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; map.GetAverageZ(1480, 1620, out _, out var z, out _); var houseLoc = new Point3D(1480, 1620, (sbyte)z); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiEdgeCaseTests.cs index e7e368c38..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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs index 2b2adf93c..8bad6655b 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiMaskCacheTests.cs @@ -1,6 +1,7 @@ using System; using Server.Engines.Pathing.Cache; using Server.Items; +using Server.Tests.Terrain; using Xunit; namespace Server.Tests.Pathfinding; @@ -9,7 +10,7 @@ namespace Server.Tests.Pathfinding; 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; @@ -17,10 +18,9 @@ public class MultiMaskCacheTests : IDisposable public void Dispose() => _cacheState.Dispose(); - [SkippableFact] + [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 _); @@ -90,31 +90,25 @@ public class MultiMaskCacheTests : IDisposable 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); 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); @@ -131,11 +125,13 @@ public class MultiMaskCacheTests : IDisposable } } - [SkippableFact] + [Fact] public void ClutteredHouse_DoesNotServeCache_ButStillPaths() { - // The clutter is Britain's statics; on a blank map the house has no footprint to dirty. - TileDataRequirement.SkipIfMissing(); + // 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); @@ -143,10 +139,10 @@ public class MultiMaskCacheTests : IDisposable 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); @@ -200,7 +196,7 @@ public class MultiMaskCacheTests : IDisposable // 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 @@ -209,27 +205,21 @@ public class MultiMaskCacheTests : IDisposable } } - [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 9ec598916..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,4 @@ +using Server.Tests.Terrain; using System; using Server.Engines.Pathing.Cache; using Server.Items; @@ -29,14 +30,12 @@ public class MultiPathInvariantTests : IDisposable return BitmapAStarAlgorithm.Instance.Find(m, map, start, goal); } - [SkippableTheory] + [Theory] // start, goal: straddle a house placed between them (house at ~ midpoint). [InlineData(1500, 1600, 1500, 1612)] // N-S across the footprint [InlineData(1494, 1606, 1512, 1606)] // E-W across the footprint public void Find_CacheOn_EqualsCacheOff_WithHousePresent(int sx, int sy, int gx, int gy) { - // Without the client's multi data the house has no footprint and nothing stands in the way. - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[MapId]; Assert.NotNull(map); @@ -44,7 +43,7 @@ public class MultiPathInvariantTests : IDisposable 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(); @@ -105,22 +104,20 @@ public class MultiPathInvariantTests : IDisposable } } - [SkippableFact] + [Fact] public void Find_CacheOn_EqualsCacheOff_RoutesAroundHouse() { - TileDataRequirement.SkipIfMissing(); 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 8f20d04f4..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,4 @@ +using Server.Tests.Terrain; using System; using Server.Engines.Pathing.Cache; using Server.Items; @@ -21,16 +22,15 @@ public class MultiSplitRoutingTests : IDisposable 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 @@ -70,7 +70,7 @@ public class MultiSplitRoutingTests : IDisposable 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"); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/Multi/MultiWalkabilityTests.cs index 1f993d477..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,15 +19,14 @@ public class MultiWalkabilityTests private const int PlaceX = 1500; private const int PlaceY = 1600; - [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(); @@ -82,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/PathingTestSupport.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs index de0fdaaae..aa6a065cb 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/PathingTestSupport.cs @@ -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); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs index 4094c093f..4584b4464 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheFileTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using Server.Engines.Pathing.Cache; using Xunit; +using static Server.Tests.Pathfinding.PathingTestSupport; namespace Server.Tests.Pathfinding; @@ -195,7 +196,7 @@ public class StepCacheFileTests : IDisposable 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 @@ -246,7 +247,7 @@ public class StepCacheFileTests : IDisposable }; 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"); @@ -261,7 +262,7 @@ public class StepCacheFileTests : IDisposable 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 } @@ -291,7 +292,7 @@ public class StepCacheFileTests : IDisposable 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); @@ -340,10 +341,9 @@ public class StepCacheFileTests : IDisposable /// 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; @@ -357,7 +357,7 @@ public class StepCacheFileTests : IDisposable }; 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"); @@ -374,7 +374,7 @@ public class StepCacheFileTests : IDisposable 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); + 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); } @@ -394,10 +394,9 @@ public class StepCacheFileTests : IDisposable /// 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 @@ -405,7 +404,7 @@ public class StepCacheFileTests : IDisposable 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 { @@ -419,7 +418,7 @@ public class StepCacheFileTests : IDisposable // 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 6d2bc6f7a..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; @@ -15,17 +17,14 @@ public class StepCacheFingerprintTests /// different one, marking the bake stale and re-baking on every boot. Hashing the file makes /// the fingerprint a pure function of the client's tile data, immune to those mutations. /// - [SkippableFact] + [Fact] public void Fingerprint_IgnoresRuntimeTileDataMutation() { - // Without tiledata.mul there is no file to hash, so the fingerprint is constant either way. - TileDataRequirement.SkipIfMissing(); - const int mapId = 1; // Trammel — loaded by the test bootstrap. var before = StepCacheFile.ComputeFingerprint(mapId); - const int probeId = 0x2A0; + const int probeId = TestTiles.Wall; var original = TileData.ItemTable[probeId].Flags; try { @@ -43,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 fd90aedf9..abe9a87d2 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -33,7 +33,7 @@ public class StepCacheLifecycleTests : IDisposable /// Builds the plain chunk and hands it back for a test to inject state into. private static StepChunk BuiltPlainChunk(StepCache cache, Map map) { - cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); var chunk = cache.GetResidentChunk(map.MapID, PlainX >> 4, PlainY >> 4); Assert.NotNull(chunk); @@ -53,7 +53,7 @@ public class StepCacheLifecycleTests : IDisposable var cache = FreshCache(promotionThreshold: 2); var map = TestMap; - var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.False(lookup.IsHit); Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind); @@ -65,21 +65,19 @@ public class StepCacheLifecycleTests : IDisposable Assert.Equal(1L, stats.FallthroughNotBuilt); } - [SkippableFact] + [Fact] public void SecondTouchInsideWindow_PromotesAndServes() { - TileDataRequirement.SkipIfMissing(); - var cache = FreshCache(promotionThreshold: 2); var map = TestMap; - Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit); + Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit); - var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.True(promoted.IsHit); Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind); - Assert.Equal((byte)0xC1, promoted.WalkMask); // pinned: open plain, walkable N/NE/... per the bake - Assert.Equal((sbyte)10, promoted.WalkZ_N); + Assert.Equal((byte)0xFF, promoted.WalkMask); // flat default ground: walkable in all eight directions + Assert.Equal(PlainZ, promoted.WalkZ_N); var stats = cache.GetStats(); Assert.Equal(1, stats.ResidentChunks); @@ -88,9 +86,9 @@ public class StepCacheLifecycleTests : IDisposable Assert.Equal(1L, stats.FallthroughNotBuilt); // Now resident: a third query is a clean hit, not another miss. - var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var hit = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.Equal(CacheHitKind.Hit, hit.HitKind); - Assert.Equal((byte)0xC1, hit.WalkMask); + Assert.Equal((byte)0xFF, hit.WalkMask); } /// @@ -106,10 +104,10 @@ public class StepCacheLifecycleTests : IDisposable var map = TestMap; - Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit); + Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit); Thread.Sleep(20); // outrun the window - var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.False(second.IsHit); Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind); Assert.Equal(0, cache.GetStats().ResidentChunks); @@ -121,11 +119,9 @@ public class StepCacheLifecycleTests : IDisposable /// there, so counting probes would cross any threshold on the second cell and gate nothing at /// all — the deferral would be dead code. /// - [SkippableFact] + [Fact] public void ManyProbesInOneFind_CountAsOneTouch() { - TileDataRequirement.SkipIfMissing(); - var cache = FreshCache(promotionThreshold: 2); var map = TestMap; @@ -133,7 +129,7 @@ public class StepCacheLifecycleTests : IDisposable for (var i = 0; i < 8; i++) { // Eight different cells, all inside the same chunk. - var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: 10); + var lookup = cache.TryGetMask(map, PlainX + i, PlainY, sourceZ: PlainZ); Assert.False(lookup.IsHit); Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, lookup.HitKind); } @@ -144,7 +140,7 @@ public class StepCacheLifecycleTests : IDisposable // A second Find is the second distinct touch, and crosses the threshold. cache.BeginFindGeneration(); - var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var promoted = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.Equal(CacheHitKind.Miss_NotBuilt, promoted.HitKind); Assert.Equal(1, cache.GetStats().ResidentChunks); @@ -161,12 +157,12 @@ public class StepCacheLifecycleTests : IDisposable var map = TestMap; cache.BeginFindGeneration(); - Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit); + Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit); Thread.Sleep(20); cache.BeginFindGeneration(); - var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var second = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.Equal(CacheHitKind.Fallthrough_NotBuilt, second.HitKind); Assert.Equal(0, cache.GetStats().ResidentChunks); @@ -179,8 +175,8 @@ public class StepCacheLifecycleTests : IDisposable var map = TestMap; // One touch each, in two different chunks: neither reaches the threshold on its own. - Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10).IsHit); - Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: 10).IsHit); + Assert.False(cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ).IsHit); + Assert.False(cache.TryGetMask(map, 1600, 1700, sourceZ: PlainZ).IsHit); Assert.Equal(0, cache.GetStats().ResidentChunks); Assert.Equal(2L, cache.GetStats().FallthroughNotBuilt); @@ -202,16 +198,14 @@ public class StepCacheLifecycleTests : IDisposable /// A multi's cells fall through, and so does the 1-cell halo around it: a cell's mask encodes /// the edges TO its neighbours, so a wall one cell over has to block them. /// - [SkippableFact] + [Fact] public void MultiCoveredCell_AndItsHalo_FallThrough() { - TileDataRequirement.SkipIfMissing(); - var cache = FreshCache(promotionThreshold: 1); var map = TestMap; // A cell nowhere near a multi still serves from the static cache. - Assert.True(cache.TryGetMask(map, PlainX, PlainY, 10).IsHit); + Assert.True(cache.TryGetMask(map, PlainX, PlainY, PlainZ).IsHit); // Mark an isolated sector as multi-bearing. Sector.HasMultis only tests Count > 0 and the // fallthrough never dereferences the multi, so a single null entry is enough — no real @@ -254,7 +248,7 @@ public class StepCacheLifecycleTests : IDisposable var cache = FreshCache(promotionThreshold: 1); var map = TestMap; - cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); var before = cache.GetStats().FallthroughSourceZMismatch; var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 100); @@ -296,8 +290,8 @@ public class StepCacheLifecycleTests : IDisposable offsets[CellIndex(PlainX, PlainY)] = 0; chunk.SetStrata(offsets, OneStratum(zCenter: 42)); - // 10 is more than StepHeight from the only stratum, so nothing can answer. - var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + // The plain is more than StepHeight below the only stratum (Z 42), so nothing can answer. + var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.False(lookup.IsHit); Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind); @@ -321,7 +315,7 @@ public class StepCacheLifecycleTests : IDisposable offsets[CellIndex(PlainX, PlainY)] = 0; chunk.SetStrata(offsets, [0]); // a record declaring zero strata - var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: 10); + var lookup = cache.TryGetMask(map, PlainX, PlainY, sourceZ: PlainZ); Assert.False(lookup.IsHit); Assert.Equal(CacheHitKind.Fallthrough_MultiZ, lookup.HitKind); @@ -400,7 +394,7 @@ public class StepCacheLifecycleTests : IDisposable // the count comes down and the key list stays in step, so equal touch ticks are fine. for (var i = 0; i < 5; i++) { - cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: 10); + cache.TryGetMask(map, PlainX + i * 16, PlainY, sourceZ: PlainZ); } cache.EnforceLruCap(); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheParityTests.cs index 769945af1..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; @@ -29,6 +30,22 @@ public class StepCacheParityTests : IDisposable 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 ---- /// @@ -36,12 +53,13 @@ public class StepCacheParityTests : IDisposable /// 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); @@ -101,7 +119,7 @@ public class StepCacheParityTests : IDisposable // 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); @@ -115,18 +133,15 @@ public class StepCacheParityTests : IDisposable /// 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++) @@ -142,7 +157,7 @@ public class StepCacheParityTests : IDisposable } } - _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"); } @@ -157,15 +172,16 @@ public class StepCacheParityTests : IDisposable /// the land average instead would trip the source-Z guard on raised terrain (a causeway, a /// walkway) and report a fallthrough that is correct behaviour rather than a fault. /// - [SkippableTheory] - [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) + [Theory] + [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 a blank map every cell bakes the same answer. - TileDataRequirement.SkipIfMissing(); + // 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(); @@ -235,7 +251,7 @@ public class StepCacheParityTests : IDisposable // 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"); } @@ -260,16 +276,18 @@ public class StepCacheParityTests : IDisposable /// Cardinals only: the cache stores raw masks and applies the corner-cut at query time, so a /// raw diagonal bit legitimately differs from MovementImpl's diagonal answer. /// - [SkippableTheory] - // Seeds chosen for the terrain classes the standable-surface bake has to get right. Each one + [Theory] + // 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) { - TileDataRequirement.SkipIfMissing(); + Region(label); + var seed = Seed(label); + int sx = seed.X, sy = seed.Y, sz = seed.Z; var cache = StepCache.Instance; cache.Clear(); diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs index 633025042..7a3145d70 100644 --- a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs @@ -51,13 +51,11 @@ public class TreasureMapChestGuardianTests // Drives the real dig, one DigTimer tick per call, until the chest surfaces and its guardians // spawn. Levels 0 and 2+ spawn guardians; level 1 spawns none. - [SkippableTheory] + [Theory] [InlineData(0)] [InlineData(2)] public void Dig_AddsSpawnedGuardiansToTheChest(int level) { - TileDataRequirement.SkipIfMissing(); - var map = Map.Felucca; var site = new Point3D(SiteX, SiteY, map.GetAverageZ(SiteX, SiteY)); var player = CreatePlayerMobile(map, new Point3D(site.X + 1, site.Y, site.Z)); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs index 55965375b..ff7211755 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachOutcomeTests.cs @@ -1,3 +1,4 @@ +using Server.Tests.Terrain; using System; using System.Collections.Generic; using Server; @@ -45,10 +46,9 @@ public class ApproachOutcomeTests : IDisposable return t; } - [SkippableFact] + [Fact] public void OpenGround_ReportsDirectProgress_ThenArrived() { - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; var (_, ai) = NewFollower(map, 1500, 1600); var target = NewTarget(map, 1497, 1600); @@ -70,15 +70,13 @@ public class ApproachOutcomeTests : IDisposable Assert.Equal(ApproachOutcome.Arrived, ai.LastApproach); } - [SkippableFact] + [Fact] public void WalledTarget_ReportsGaveUp() { - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; var (_, ai) = NewFollower(map, 1500, 1601); var target = NewTarget(map, 1500, 1596); - var id = TestTiles.FirstImpassableItemId(); - Assert.NotEqual(0, id); + const ushort id = TestTiles.Counter; // Impassable ring around the target: unreachable. for (var x = 1499; x <= 1501; x++) diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index e330268f0..aa8e7d27b 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -1,3 +1,4 @@ +using Server.Tests.Terrain; using System; using System.Collections.Generic; using Server.Engines.Pathing.Cache; @@ -87,35 +88,40 @@ public class ApproachTargetTests : IDisposable Assert.Equal(buildsBefore, buildsAfter); // greedy fast path: no chunk builds } - // The desk is a Britain inn static; on a blank map the detour is a straight walk. - [SkippableFact] - public void BritainInnDesk_PetReachesMaster() + // An upstairs room with an L-shaped counter: the pet is south of it, the master north. + [Fact] + public void InnCounter_PetReachesMaster() { - TileDataRequirement.SkipIfMissing(); + 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 = NewTarget(map, new Point3D(1494, 1605, 21)); + 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); - 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"); } - [SkippableFact] - public void MoveTo_ReachesTargetAroundDesk() + [Fact] + public void MoveTo_ReachesTargetAroundCounter() { - TileDataRequirement.SkipIfMissing(); + 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 = NewTarget(map, new Point3D(1494, 1605, 21)); + var (bc, ai) = NewFollower(map, inn.Anchor); + var target = NewTarget(map, inn.Target); StepCache.Instance.Clear(); @@ -131,7 +137,7 @@ public class ApproachTargetTests : IDisposable } } - 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] @@ -169,10 +175,9 @@ public class ApproachTargetTests : IDisposable 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 _); @@ -181,8 +186,7 @@ public class ApproachTargetTests : IDisposable 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 = TestTiles.FirstImpassableItemId(); - Assert.NotEqual(0, id); + const ushort id = TestTiles.Counter; for (var x = 1499; x <= 1501; x++) { for (var y = 1595; y <= 1597; y++) @@ -223,10 +227,9 @@ public class ApproachTargetTests : IDisposable 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 _); @@ -235,8 +238,7 @@ public class ApproachTargetTests : IDisposable 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 = TestTiles.FirstImpassableItemId(); - Assert.NotEqual(0, id); + const ushort id = TestTiles.Counter; for (var x = 1497; x <= 1503; x++) { PlaceBlocker(map, id, x, 1598); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs index 84898b7f1..7bf6ffb5c 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs @@ -27,7 +27,7 @@ public class DetectHiddenTimerTests public override void DetectHidden() => Detections++; } - [SkippableTheory] + [Theory] [InlineData(0, 108000, 132000)] [InlineData(1, 108000, 132000)] [InlineData(100, 108000, 132000)] @@ -38,7 +38,6 @@ public class DetectHiddenTimerTests public void DetectHidden_SchedulesBoundedCooldown_AndWaitsBeforeRetry( int intelligence, long minDelay, long maxDelay) { - Skip.If(!TestServerInitializer.TileDataLoaded, "Requires UO client map data."); using var clock = new TimerWheelScope(); var creature = new DetectingCreature(); DetectingAI ai = null; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs index 27b4ee0a0..5b366ddab 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/FamiliarAITests.cs @@ -1,3 +1,4 @@ +using Server.Tests.Terrain; using System; using System.Collections.Generic; using Server; @@ -54,7 +55,6 @@ public class FamiliarAITests : IDisposable public FamiliarAITests() { - TileDataRequirement.SkipIfMissing(); _clock = new TimerWheelScope(); } @@ -111,7 +111,7 @@ public class FamiliarAITests : IDisposable return e; } - [SkippableFact] + [Fact] public void Follows_WithoutBacktracking() { var p = Master(1500, 1600); @@ -143,7 +143,7 @@ public class FamiliarAITests : IDisposable Assert.Equal(Point3D.Zero, f.Home); } - [SkippableFact] + [Fact] public void Orders_AreInert() { var p = Master(1500, 1600); @@ -159,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 @@ -193,7 +193,7 @@ public class FamiliarAITests : IDisposable } } - [SkippableFact] + [Fact] public void DropsTarget_WhenMasterHides() { var p = Master(1500, 1600); @@ -208,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); @@ -223,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); @@ -244,7 +244,7 @@ public class FamiliarAITests : IDisposable Assert.False(wisp.Warmode); } - [SkippableFact] + [Fact] public void KeepUp_SnapsWhenFarOnOpenGround() { var p = Master(1500, 1600); @@ -257,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". @@ -288,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 = TestTiles.FirstImpassableItemId(); - Assert.NotEqual(0, id); + const ushort id = TestTiles.Counter; for (var x = 1498; x <= 1502; x++) { @@ -321,7 +322,7 @@ public class FamiliarAITests : IDisposable ); } - [SkippableFact] + [Fact] public void MirrorsHidden_EvenWhileWalking() { var p = Master(1500, 1600); @@ -349,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); @@ -363,7 +364,7 @@ public class FamiliarAITests : IDisposable Assert.True(f.InRange(goal, 1)); } - [SkippableFact] + [Fact] public void NonCombatFamiliar_RefusesAnyCombatant() { var p = Master(1500, 1600); @@ -374,7 +375,7 @@ public class FamiliarAITests : IDisposable Assert.Null(wisp.Combatant); } - [SkippableFact] + [Fact] public void HiddenCaster_FamiliarRefusesRetaliation() { var p = Master(1500, 1600); @@ -394,7 +395,7 @@ public class FamiliarAITests : IDisposable Assert.True(wolf.Hidden); } - [SkippableFact] + [Fact] public void LeftBehind_StandsDown() { var p = Master(1500, 1600); @@ -409,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); @@ -433,7 +434,7 @@ public class FamiliarAITests : IDisposable Assert.Same(e, p.Combatant); } - [SkippableFact] + [Fact] public void AssistsAgainstWhatTheCastersPetIsFighting() { var p = Master(1500, 1600); @@ -457,7 +458,7 @@ public class FamiliarAITests : IDisposable ); } - [SkippableFact] + [Fact] public void DefendsAnAttackedCaster_WhoseOwnCombatantExpired() { var p = Master(1500, 1600); @@ -475,7 +476,7 @@ public class FamiliarAITests : IDisposable ); } - [SkippableFact] + [Fact] public void DropsATarget_ThatNoLongerFightsAnyone() { var p = Master(1500, 1600); @@ -494,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/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs index ee3bfe7cd..0161ae2dd 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs @@ -25,7 +25,6 @@ public class GuardFriendlyFireTests : IDisposable private (PlayerMobile owner, PetTestStub pet) Spawn() { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); var z = Map.Felucca.GetAverageZ(1500, 1600); // The constructed PlayerMobile, not PetTestSetup's deserialization-shaped one: the harmful // checks read PermaFlags, which only the full constructor allocates. @@ -48,7 +47,7 @@ public class GuardFriendlyFireTests : IDisposable return pet; } - [SkippableFact] + [Fact] public void GuardFallbackDoesNotAcquireItsOwner() { var (owner, pet) = Spawn(); @@ -61,7 +60,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.Combatant); } - [SkippableFact] + [Fact] public void GuardDropsStaleCombatAgainstAnotherPetOfItsOwner() { var (owner, pet) = Spawn(); @@ -75,7 +74,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Equal(OrderType.Guard, pet.ControlOrder); } - [SkippableFact] + [Fact] public void GuardDoesNotRetaliateAgainstOwnerFriendlyDamage() { var (owner, pet) = Spawn(); @@ -84,7 +83,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Equal(OrderType.Guard, pet.ControlOrder); } - [SkippableFact] + [Fact] public void GuardDoesNotRetaliateAgainstSiblingFriendlyDamage() { var (owner, pet) = Spawn(); @@ -93,7 +92,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.Combatant); } - [SkippableFact] + [Fact] public void GuardReacquiresAnEnemyAttackingItsOwner() { var (owner, pet) = Spawn(); @@ -107,7 +106,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(enemy, pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardStillDefendsAgainstAnotherPlayersPet() { var (owner, pet) = Spawn(); @@ -120,7 +119,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(enemy, pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardIgnoresSiblingInOwnersAggressorHistory() { var (owner, pet) = Spawn(); @@ -131,7 +130,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardIgnoresOwnControlledSummonAttackingOwner() { var (owner, pet) = Spawn(); @@ -146,7 +145,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardDefendsAgainstOwnUncontrolledSummonAttackingOwner() { var (owner, pet) = Spawn(); @@ -160,7 +159,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(vortex, pet.FocusMob); } - [SkippableFact] + [Fact] public void FriendlyDamageDoesNotInterruptGuardCombat() { var (owner, pet) = Spawn(); @@ -173,7 +172,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(enemy, pet.Combatant); } - [SkippableFact] + [Fact] public void ExplicitAttackStillAcquiresTheCommandedTarget() { var (owner, pet) = Spawn(); @@ -183,7 +182,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(sibling, pet.FocusMob); } - [SkippableFact] + [Fact] public void BardProvocationStillOverridesGuard() { var (owner, pet) = Spawn(); @@ -193,7 +192,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Same(owner, pet.FocusMob); } - [SkippableTheory] + [Theory] [InlineData(OrderType.Follow)] [InlineData(OrderType.Come)] [InlineData(OrderType.Stay)] @@ -205,7 +204,7 @@ public class GuardFriendlyFireTests : IDisposable Assert.Null(pet.FocusMob); } - [SkippableFact] + [Fact] public void GuardingMageDoesNotTurnOnOwnerWhenEnemyDisappears() { var (owner, _) = Spawn(); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs index 6c88649fa..13119ca03 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/HerdingTests.cs @@ -10,10 +10,9 @@ namespace UOContent.Tests.Mobiles.AI; [Collection("Sequential Pathfinding Tests")] public class HerdingTests { - [SkippableFact] + [Fact] public void HerdedCreature_ReachesTheTile_AndClearsTargetLocation() { - TileDataRequirement.SkipIfMissing(); var map = Map.Maps[1]; map.GetAverageZ(1500, 1600, out _, out var z, out _); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs index d80dbd5bc..1a2f6d737 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs @@ -36,10 +36,9 @@ public class SummonAcquireRulesTests : IDisposable return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true); } - [SkippableFact] + [Fact] public void PreAosFeluccaVortexPrefersAnyoneOverItsCaster() { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); Core.Expansion = Expansion.UOR; // The caster outranks the bystander on the vortex's int-over-distance ranking. @@ -51,12 +50,11 @@ public class SummonAcquireRulesTests : IDisposable Assert.Same(bystander, vortex.FocusMob); } - [SkippableTheory] + [Theory] [InlineData(Expansion.UOR, 1)] // Trammel [InlineData(Expansion.AOS, 0)] // Felucca public void VortexSparesItsCasterOutsidePreAosFelucca(Expansion expansion, int mapIndex) { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); Core.Expansion = expansion; var caster = Player(Map.Maps[mapIndex], 1500, 100); @@ -66,10 +64,9 @@ public class SummonAcquireRulesTests : IDisposable Assert.Null(vortex.FocusMob); } - [SkippableFact] + [Fact] public void MasterlessSummonDoesNotBreakAcquisition() { - Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); Core.Expansion = Expansion.AOS; var player = Player(Map.Felucca, 1500, 100); diff --git a/Projects/UOContent.Tests/Tests/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/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 85ec80f45..1d7f20e68 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs index 538807448..faa3a982c 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCacheFile.cs @@ -216,16 +216,22 @@ internal static class StepCacheFile var path = Core.FindDataFile("tiledata.mul", false); if (path != null) { - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var hasher = HashUtility.CreateXxHash3(); - hasher.Append(fs); - _tileDataFileFingerprint = hasher.GetCurrentHashAsUInt64(); + _tileDataFileFingerprint = HashTileDataFile(path); } _tileDataFileFingerprintComputed = true; return _tileDataFileFingerprint; } + /// XxHash3 of the bytes of the tiledata file at . + internal static ulong HashTileDataFile(string path) + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var hasher = HashUtility.CreateXxHash3(); + hasher.Append(fs); + return hasher.GetCurrentHashAsUInt64(); + } + /// /// Writes the map's chunks to : header, then one record per chunk, then /// the index trailer. IndexOffset isn't known until the records are down, so it goes in as a From ef8057d38e1a2719b42efe8adafacf37454e1017 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:12:10 -0700 Subject: [PATCH 21/26] fix: T2A menus only offer what the chosen resource can make (#2695) ## Summary The T2A crafting menus offered items the chosen resource couldn't make: - **Tailoring honours the targeted cloth hue.** The menu was built from all cloth in the pack, but the craft consumes only the targeted hue, so it offered items that then failed on resources. `CanCraftItem` / `FilterEntries` / `AnyCraftableInCategory` now take the hue and count with the same helper the craft uses. - **Uncut cloth counts in the hued path.** The hue-aware count and consume matched only `Cloth`, so targeting uncut cloth gave an empty menu. The hued path now uses `CraftItem`'s equivalence table like the unhued path. `T2ACraftSystem` reuses that table instead of a narrower copy, which also covers the coloured woods and leathers on later-era shards. - **T2A tinkering only uses iron ingots.** Coloured ingots are refused with a re-prompt. The menu filter now uses the selected ingot, and Make Last for tinkering always resolves to iron, so a coloured choice left over from the gump flow can't be used. ## Testing - `dotnet build`: 0 warnings, 0 errors - New `T2AHuedFilterTests`: only cloth of the targeted hue counts, and uncut cloth of the same hue counts toward the recipe - No test for the tinkering target and message flow; not yet exercised in game --- .../Tests/Engines/Craft/T2AHuedFilterTests.cs | 92 +++++++++++++++++++ .../UOContent/Engines/Craft/Core/CraftItem.cs | 58 +++++++++--- .../Engines/Craft/T2A/T2ACraftSystem.cs | 63 +++++++------ .../Engines/Craft/T2A/T2ACraftToolTarget.cs | 7 ++ .../Engines/Craft/T2A/TailoringMenu.cs | 12 +-- .../Engines/Craft/T2A/TinkeringMenu.cs | 30 +++--- 6 files changed, 206 insertions(+), 56 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs diff --git a/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs b/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs new file mode 100644 index 000000000..8a50ef4ac --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Craft/T2AHuedFilterTests.cs @@ -0,0 +1,92 @@ +using Server; +using Server.Engines.Craft; +using Server.Engines.Craft.T2A; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class T2AHuedFilterTests +{ + private static PlayerMobile CreatePlayer(Point3D location) + { + var m = new PlayerMobile(World.NewMobile); + m.DefaultMobileInit(); + m.MoveToWorld(location, Map.Felucca); + m.AddItem(new Backpack()); + return m; + } + + private static CraftItem MakeRecipe() + { + var item = new CraftItem(typeof(Shirt), "shirt", "shirt"); + item.AddRes(typeof(Cloth), "cloth", 8, "You do not have enough cloth."); + item.AddSkill(SkillName.Tailoring, 0.0, 100.0); + return item; + } + + private static CraftSystem GetTailoringSystem() + { + if (DefTailoring.CraftSystem == null) + { + DefTailoring.Initialize(); + } + + return DefTailoring.CraftSystem; + } + + [Fact] + public void CanCraftItem_WithHue_CountsOnlyMatchingHueCloth() + { + var player = CreatePlayer(new Point3D(4140, 500, 0)); + + try + { + var pack = player.Backpack; + pack.AddItem(new Cloth(5) { Hue = 0x21 }); + pack.AddItem(new Cloth(5) { Hue = 0x30 }); + + var system = GetTailoringSystem(); + var recipe = MakeRecipe(); + + // 10 cloth total, but no single hue reaches the 8 the recipe needs + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + + pack.AddItem(new Cloth(3) { Hue = 0x21 }); + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + } + finally + { + player.Delete(); + } + } + + [Fact] + public void CanCraftItem_WithHue_CountsSameHueUncutCloth() + { + var player = CreatePlayer(new Point3D(4140, 500, 0)); + + try + { + var pack = player.Backpack; + pack.AddItem(new Cloth(5) { Hue = 0x21 }); + pack.AddItem(new UncutCloth(3) { Hue = 0x21 }); + pack.AddItem(new UncutCloth(5) { Hue = 0x30 }); + + var system = GetTailoringSystem(); + var recipe = MakeRecipe(); + + Assert.True(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x21)); + Assert.False(T2ACraftSystem.CanCraftItem(player, recipe, system, null, 0x30)); + } + finally + { + player.Delete(); + } + } +} diff --git a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs index e99760bbc..0ba6b771a 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs @@ -635,15 +635,7 @@ namespace Server.Engines.Craft } // ****************** - for (var j = 0; types[i] == null && j < m_TypesTable.Length; ++j) - { - if (m_TypesTable[j][0] == baseType) - { - types[i] = m_TypesTable[j]; - } - } - - types[i] ??= [baseType]; + types[i] = GetEquivalentTypes(baseType) ?? [baseType]; amounts[i] = craftRes.Amount; // For stackable items that can be crafted more than one at a time @@ -1242,13 +1234,54 @@ namespace Server.Engines.Craft return true; } - private static int GetHuedAmount(Container pack, Type type, int hue) + /// + /// Returns the interchangeable resource types for (e.g. logs and boards), + /// or null when it has no equivalents. + /// + internal static Type[] GetEquivalentTypes(Type baseType) + { + for (var i = 0; i < m_TypesTable.Length; i++) + { + if (m_TypesTable[i][0] == baseType) + { + return m_TypesTable[i]; + } + } + + return null; + } + + private static bool IsHuedMatch(Item item, Type type, Type[] equivalents, int hue) + { + if (item.Hue != hue) + { + return false; + } + + if (equivalents == null) + { + return type.IsInstanceOfType(item); + } + + for (var i = 0; i < equivalents.Length; i++) + { + if (equivalents[i].IsInstanceOfType(item)) + { + return true; + } + } + + return false; + } + + internal static int GetHuedAmount(Container pack, Type type, int hue) { var total = 0; + var equivalents = GetEquivalentTypes(type); foreach (var item in pack.FindItems(true)) { - if (item.Hue == hue && type.IsInstanceOfType(item)) + if (IsHuedMatch(item, type, equivalents, hue)) { total += item.Amount; } @@ -1260,6 +1293,7 @@ namespace Server.Engines.Craft private static bool ConsumeHuedAmount(Container pack, Type type, int hue, int amount) { var remaining = amount; + var equivalents = GetEquivalentTypes(type); using var toDelete = PooledRefList.Create(); foreach (var item in pack.FindItems(true)) @@ -1269,7 +1303,7 @@ namespace Server.Engines.Craft break; } - if (item.Hue != hue || !type.IsInstanceOfType(item)) + if (!IsHuedMatch(item, type, equivalents, hue)) { continue; } diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs index 785a319ad..a0fff9d0f 100644 --- a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs @@ -190,9 +190,11 @@ public static class T2ACraftSystem /// Checks if a player can craft a specific item, accounting for sub-resource types. /// When is non-null, checks against that specific sub-resource. /// When null, checks against ANY available sub-resource the player has sufficient skill and materials for. + /// A non-negative counts only same-hue stock for the primary resource, exactly as + /// the hue-aware craft path consumes it. /// public static bool CanCraftItem( - Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null + Mobile from, CraftItem itemDef, CraftSystem system, Type selectedResourceType = null, int hue = -1 ) { var pack = from.Backpack; @@ -214,6 +216,27 @@ public static class T2ACraftSystem var res = itemDef.Resources[i]; var resType = res.ItemType; + if (hue >= 0 && i == 0) + { + if (resCol.Init && resType == resCol.ResType && selectedResourceType != null) + { + var subRes = resCol.SearchFor(selectedResourceType); + if (subRes != null && from.Skills[system.MainSkill].Value < subRes.RequiredSkill) + { + return false; + } + + resType = selectedResourceType; + } + + if (CraftItem.GetHuedAmount(pack, resType, hue) < res.Amount) + { + return false; + } + + continue; + } + // If this resource is the base sub-resource type (e.g. IronIngot for blacksmithing), // handle sub-resource substitution if (resCol.Init && resType == resCol.ResType) @@ -250,17 +273,20 @@ public static class T2ACraftSystem /// /// Convenience overload that resolves a Type to a CraftItem first. /// - public static bool CanCraftItem(Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null) + public static bool CanCraftItem( + Mobile from, Type itemType, CraftSystem system, Type selectedResourceType = null, int hue = -1 + ) { var itemDef = system.CraftItems.SearchFor(itemType); - return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType); + return itemDef != null && CanCraftItem(from, itemDef, system, selectedResourceType, hue); } /// /// Filters static template entries to only those the player can craft. /// public static ItemListEntry[] FilterEntries( - Mobile from, ItemListEntry[] staticEntries, Type[] types, CraftSystem system, Type selectedResourceType = null + Mobile from, ItemListEntry[] staticEntries, Type[] types, CraftSystem system, + Type selectedResourceType = null, int hue = -1 ) { var filtered = new ItemListEntry[staticEntries.Length]; @@ -271,7 +297,7 @@ public static class T2ACraftSystem var entry = staticEntries[i]; var typeIndex = entry.CraftIndex; if (typeIndex >= 0 && typeIndex < types.Length && - CanCraftItem(from, types[typeIndex], system, selectedResourceType)) + CanCraftItem(from, types[typeIndex], system, selectedResourceType, hue)) { filtered[count++] = entry; } @@ -294,12 +320,12 @@ public static class T2ACraftSystem /// Returns true if at least one item in the type array is craftable. /// public static bool AnyCraftableInCategory( - Mobile from, Type[] types, CraftSystem system, Type selectedResourceType = null + Mobile from, Type[] types, CraftSystem system, Type selectedResourceType = null, int hue = -1 ) { for (var i = 0; i < types.Length; i++) { - if (CanCraftItem(from, types[i], system, selectedResourceType)) + if (CanCraftItem(from, types[i], system, selectedResourceType, hue)) { return true; } @@ -308,28 +334,11 @@ public static class T2ACraftSystem return false; } - /// - /// Equivalent type pairs mirroring CraftItem.m_TypesTable — used so that menu filtering - /// counts boards when checking for logs, hides when checking for leather, etc. - /// - private static readonly Type[][] _equivalentTypes = - [ - [typeof(Log), typeof(Board)], - [typeof(Cloth), typeof(UncutCloth)], - [typeof(Items.Leather), typeof(Hides)] - ]; - + // Shares CraftItem's equivalence table so the menu counts exactly what the craft will consume private static int GetResourceAmount(Container pack, Type type) { - for (var i = 0; i < _equivalentTypes.Length; i++) - { - if (_equivalentTypes[i][0] == type) - { - return pack.GetAmount(_equivalentTypes[i]); - } - } - - return pack.GetAmount(type); + var equivalents = CraftItem.GetEquivalentTypes(type); + return equivalents != null ? pack.GetAmount(equivalents) : pack.GetAmount(type); } private static bool HasAnySufficientSubResource( diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs index d7398f000..80978103c 100644 --- a/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftToolTarget.cs @@ -36,6 +36,13 @@ public class T2ACraftToolTarget : Target var resIndex = lastMade.UseSubRes2 ? context.LastResourceIndex2 : context.LastResourceIndex; var type = resIndex > -1 ? res[resIndex].ItemType : null; + // T2A tinkering has no colored ingots; a stale index from the gump flow must not leak in + if (_system == DefTinkering.CraftSystem) + { + type = typeof(IronIngot); + T2ACraftSystem.SetLastResourceIndex(from, _system, type); + } + // Jewelry requires gem targeting — re-prompt instead of crafting directly if (typeof(BaseJewel).IsAssignableFrom(lastMade.ItemType)) { diff --git a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs index a7a5feccc..704845c39 100644 --- a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs +++ b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs @@ -92,7 +92,7 @@ public class TailoringMenu : ItemListMenu }; private TailoringMenu(Mobile from, BaseTool tool, Category category, int hue = -1) - : base(GetQuestion(category), BuildFilteredEntries(from, category)) + : base(GetQuestion(category), BuildFilteredEntries(from, category, hue)) { _tool = tool; _category = category; @@ -198,11 +198,11 @@ public class TailoringMenu : ItemListMenu public static ItemListEntry[] Studded() => _studdedEntries ??= BuildStaticEntries(StuddedArmorTypes, "leather"); public static ItemListEntry[] Female() => _femaleEntries ??= BuildStaticEntries(FemaleArmorTypes, "leather"); - private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category, int hue) { if (category is Category.Main or Category.LeatherMain) { - return BuildFilteredMainEntries(from, category); + return BuildFilteredMainEntries(from, category, hue); } var types = GetTypes(category); @@ -212,10 +212,10 @@ public class TailoringMenu : ItemListMenu return []; } - return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem); + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTailoring.CraftSystem, null, hue); } - private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory) + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Category mainCategory, int hue) { var system = DefTailoring.CraftSystem; var mainStatic = GetStaticEntries(mainCategory); @@ -231,7 +231,7 @@ public class TailoringMenu : ItemListMenu { var entry = mainStatic[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, null, hue)) { filtered[count++] = entry; } diff --git a/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs index e823d68ee..766b6a545 100644 --- a/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs +++ b/Projects/UOContent/Engines/Craft/T2A/TinkeringMenu.cs @@ -117,7 +117,7 @@ public class TinkeringMenu : ItemListMenu }; private TinkeringMenu(Mobile from, BaseTool tool, Category category, Type selectedResourceType) - : base(GetQuestion(category), BuildFilteredEntries(from, category)) + : base(GetQuestion(category), BuildFilteredEntries(from, category, selectedResourceType)) { _tool = tool; _category = category; @@ -224,16 +224,16 @@ public class TinkeringMenu : ItemListMenu public static ItemListEntry[] Rings() => _ringEntries ??= BuildStaticEntries(RingTypes, "ingots"); public static ItemListEntry[] KegItems() => _kegEntries ??= BuildStaticEntries(KegItemTypes, "kegs"); - private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category) + private static ItemListEntry[] BuildFilteredEntries(Mobile from, Category category, Type selectedResourceType) { if (category == Category.Main) { - return BuildFilteredMainEntries(from); + return BuildFilteredMainEntries(from, selectedResourceType); } if (category == Category.Jewelry) { - return BuildFilteredJewelryEntries(from); + return BuildFilteredJewelryEntries(from, selectedResourceType); } var types = GetTypes(category); @@ -243,7 +243,7 @@ public class TinkeringMenu : ItemListMenu return []; } - return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem); + return T2ACraftSystem.FilterEntries(from, staticEntries, types, DefTinkering.CraftSystem, selectedResourceType); } private static readonly ItemListEntry[] JewelrySubcategoryEntries = @@ -253,7 +253,7 @@ public class TinkeringMenu : ItemListMenu new("Rings", 0x108a, 0, (int)Category.Rings) ]; - private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from) + private static ItemListEntry[] BuildFilteredJewelryEntries(Mobile from, Type selectedResourceType) { var system = DefTinkering.CraftSystem; var filtered = new ItemListEntry[JewelrySubcategoryEntries.Length]; @@ -263,7 +263,7 @@ public class TinkeringMenu : ItemListMenu { var entry = JewelrySubcategoryEntries[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResourceType)) { filtered[count++] = entry; } @@ -282,7 +282,7 @@ public class TinkeringMenu : ItemListMenu return filtered; } - private static ItemListEntry[] BuildFilteredMainEntries(Mobile from) + private static ItemListEntry[] BuildFilteredMainEntries(Mobile from, Type selectedResourceType) { var system = DefTinkering.CraftSystem; var mainStatic = Main(); @@ -293,7 +293,7 @@ public class TinkeringMenu : ItemListMenu { var entry = mainStatic[i]; var types = GetTypes((Category)entry.CraftIndex); - if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system)) + if (types != null && T2ACraftSystem.AnyCraftableInCategory(from, types, system, selectedResourceType)) { filtered[count++] = entry; } @@ -407,9 +407,17 @@ public class TinkeringMenu : ItemListMenu return true; } - if (targeted is BaseIngot) + if (targeted is BaseIngot and not IronIngot) { - var menu = new TinkeringMenu(from, tool, Category.Main, targeted.GetType()); + // T2A tinkering predates colored ingots; metal items are always made from iron. + from.SendAsciiMessage("Tinkering can only use iron ingots. Please select iron ingots or wood."); + from.Target = new ResourceSelectTarget(from, tool); + return true; + } + + if (targeted is IronIngot) + { + var menu = new TinkeringMenu(from, tool, Category.Main, typeof(IronIngot)); if (menu.Entries.Length == 0) { from.SendAsciiMessage("You lack the skill and materials to craft anything."); From 88859ac69ecc2dfde480b8e03625830ded8a6229 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:37:28 -0700 Subject: [PATCH 22/26] fix: T2A cooking works without a craft menu (#2693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary With T2A packet crafting menus enabled (`t2aCraftMenus`, default for pre-UOTD eras), cooking did nothing. Double-clicking a Skillet set a target, and `T2ACraftSystem.ShowMenu` had no Cooking branch, so the target was accepted and silently dropped. Nothing else could cook either: the old RunUO "use food on a heat source" code was never hooked up and was removed in #1054. T2A cooking never used a menu, so this restores the item-on-item / item-on-heat-source flow: - **Per-item handling:** each ingredient (`CookableFood`, `Dough`, `SweetDough`, `SackFlour`, `BowlFlour`, `JarHoney`) implements `IT2ACombinable.TryCombine` and switches on the target. `T2ACooking` holds the shared target, water, combine and heat-timer helpers. - **Heat source:** all `CookableFood` (raw meats, fish, eggs, unbaked pies/quiche/pizzas, cookie and cake mix), dough → bread loaf, sweet dough → muffins. One unit is used up at the start, then a 5 s timer; the Cooking check runs from `CookingLevel` to 100 (bread and muffins use `DefCooking`'s range). The food burns on failure or if the cook moves more than 3 tiles away. - **Combinations:** - Open flour sack or bowl of flour + water → dough - Dough + pear / peach / apple / pumpkin / eggs / meat / sausage → the matching unbaked pie, quiche or pizza - Dough + honey → sweet dough - Sweet dough + honey → cookie mix - Flour + sweet dough → cake mix - The honey and flour + sweet dough recipes work in either direction. - **Water:** water drinks (pitchers), buckets, barrels, tubs and trough add-ons, in the pack, in hand, or on the ground within 2 tiles and in line of sight. - **Cooking tools are passive:** with T2A menus on, a tool whose system has no T2A menu (`T2ACraftSystem.HasMenu`) falls through to `ShowCraftMenu`, which does nothing under T2A. No dead target is left. Behaviour with T2A menus off is unchanged. ## Follow-ups - Static troughs/barrels and open water aren't water sources here or for pitcher filling (no static water ID list exists); to be fixed separately. - Whether a bowl of flour returns an empty bowl is unconfirmed (`//TODO`). - Cooking tools that must merely be in the pack for some recipes are not modelled yet. ## Testing - `dotnet build`: 0 warnings, 0 errors - New `T2ACookingTests`: flour + water, closed sack refused, dough + apple, honey in both directions, cookie mix, cake mix, wrong target consumes nothing, water in another player's pack refused - Not covered: the heat timer (untested); not yet exercised in game --- .../Tests/Engines/Craft/T2ACookingTests.cs | 145 ++++++++++ .../UOContent/Engines/Craft/T2A/T2ACooking.cs | 211 +++++++++++++++ .../Engines/Craft/T2A/T2ACraftSystem.cs | 6 + Projects/UOContent/Items/Food/CookableFood.cs | 18 +- Projects/UOContent/Items/Food/Cooking.cs | 252 +++++++++++++++++- .../Items/Skill Items/Tools/BaseTool.cs | 2 +- 6 files changed, 627 insertions(+), 7 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/Craft/T2ACookingTests.cs create mode 100644 Projects/UOContent/Engines/Craft/T2A/T2ACooking.cs diff --git a/Projects/UOContent.Tests/Tests/Engines/Craft/T2ACookingTests.cs b/Projects/UOContent.Tests/Tests/Engines/Craft/T2ACookingTests.cs new file mode 100644 index 000000000..e0d551bed --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Craft/T2ACookingTests.cs @@ -0,0 +1,145 @@ +using Server; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class T2ACookingTests +{ + private static PlayerMobile CreatePlayer() + { + var m = new PlayerMobile(World.NewMobile); + m.DefaultMobileInit(); + m.MoveToWorld(new Point3D(4200, 500, 0), Map.Felucca); + m.AddItem(new Backpack()); + return m; + } + + [Fact] + public void FlourAndWaterPitcher_MakeDough() + { + var player = CreatePlayer(); + var pack = player.Backpack; + var sack = new SackFlour { ItemID = 0x103A }; + var pitcher = new Pitcher(BeverageType.Water); + pack.AddItem(sack); + pack.AddItem(pitcher); + + var sackBefore = sack.Quantity; + var pitcherBefore = pitcher.Quantity; + + Assert.True(sack.TryCombine(player, pitcher)); + Assert.Equal(sackBefore - 1, sack.Quantity); + Assert.Equal(pitcherBefore - 1, pitcher.Quantity); + Assert.Equal(1, pack.GetAmount(typeof(Dough))); + } + + [Fact] + public void ClosedFlourSack_IsRefused() + { + var player = CreatePlayer(); + var sack = new SackFlour(); + var pitcher = new Pitcher(BeverageType.Water); + player.Backpack.AddItem(sack); + player.Backpack.AddItem(pitcher); + + Assert.False(sack.TryCombine(player, pitcher)); + Assert.Equal(20, sack.Quantity); + Assert.Equal(pitcher.MaxQuantity, pitcher.Quantity); + Assert.Equal(0, player.Backpack.GetAmount(typeof(Dough))); + } + + [Fact] + public void DoughAndApple_MakeUnbakedApplePie() + { + var player = CreatePlayer(); + var dough = new Dough(); + var apple = new Apple(); + player.Backpack.AddItem(dough); + player.Backpack.AddItem(apple); + + Assert.True(dough.TryCombine(player, apple)); + Assert.Equal(1, player.Backpack.GetAmount(typeof(UnbakedApplePie))); + Assert.Equal(0, player.Backpack.GetAmount(typeof(Apple))); + Assert.Equal(0, player.Backpack.GetAmount(typeof(Dough))); + } + + [Fact] + public void HoneyRows_WorkInEitherDirection() + { + var player = CreatePlayer(); + var pack = player.Backpack; + var dough = new Dough(); + var honey = new JarHoney(); + pack.AddItem(dough); + pack.AddItem(honey); + Assert.True(dough.TryCombine(player, honey)); + Assert.Equal(1, pack.GetAmount(typeof(SweetDough))); + + var dough2 = new Dough(); + var honey2 = new JarHoney(); + pack.AddItem(dough2); + pack.AddItem(honey2); + Assert.True(honey2.TryCombine(player, dough2)); + Assert.Equal(2, pack.GetAmount(typeof(SweetDough))); + + var sweet = pack.FindItemByType(); + var honey3 = new JarHoney(); + pack.AddItem(honey3); + Assert.True(sweet.TryCombine(player, honey3)); + Assert.Equal(1, pack.GetAmount(typeof(CookieMix))); + } + + [Fact] + public void WrongTarget_ConsumesNothing() + { + var player = CreatePlayer(); + var dough = new Dough(); + var honey = new JarHoney(); + player.Backpack.AddItem(dough); + player.Backpack.AddItem(honey); + var apple = new Apple(); + player.Backpack.AddItem(apple); + + var pitcher = new Pitcher(BeverageType.Water); + player.Backpack.AddItem(pitcher); + + Assert.False(honey.TryCombine(player, apple)); + Assert.False(dough.TryCombine(player, pitcher)); + Assert.Equal(1, player.Backpack.GetAmount(typeof(Dough))); + Assert.Equal(1, player.Backpack.GetAmount(typeof(JarHoney))); + Assert.Equal(1, player.Backpack.GetAmount(typeof(Apple))); + } + + [Fact] + public void BowlFlourAndSweetDough_MakeCakeMix() + { + var player = CreatePlayer(); + var bowl = new BowlFlour(); + var sweetDough = new SweetDough(); + player.Backpack.AddItem(bowl); + player.Backpack.AddItem(sweetDough); + + Assert.True(sweetDough.TryCombine(player, bowl)); + Assert.True(bowl.Deleted); + Assert.True(sweetDough.Deleted); + Assert.Equal(1, player.Backpack.GetAmount(typeof(CakeMix))); + } + + [Fact] + public void WaterInAnotherPlayersPack_IsRefused() + { + var player = CreatePlayer(); + var other = CreatePlayer(); + var sack = new SackFlour { ItemID = 0x103A }; + var pitcher = new Pitcher(BeverageType.Water); + player.Backpack.AddItem(sack); + other.Backpack.AddItem(pitcher); + + Assert.False(sack.TryCombine(player, pitcher)); + Assert.Equal(20, sack.Quantity); + Assert.Equal(pitcher.MaxQuantity, pitcher.Quantity); + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACooking.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACooking.cs new file mode 100644 index 000000000..e8fbb05d6 --- /dev/null +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACooking.cs @@ -0,0 +1,211 @@ +using System; +using Server.Items; +using Server.Targeting; + +namespace Server.Engines.Craft.T2A; + +/// An item that can be double-clicked under T2A to combine with, or cook over, a target. +public interface IT2ACombinable +{ + bool TryCombine(Mobile from, object target); +} + +/// +/// Shared helpers for T2A cooking, which is item-on-item and item-on-heat-source rather than a craft menu. +/// Each ingredient item decides its own results in . +/// +public sealed class T2ACooking +{ + private const int _heatSourceRange = 1; + private const int _waterRange = 2; + private const int _burnRange = 3; + + private T2ACooking() + { + } + + /// Asks the player for a target if the item is in their pack. + public static void Prompt(Mobile from, Item source) + { + if (!ValidateInPack(from, source)) + { + return; + } + + from.SendAsciiMessage("What do you wish to combine this with, or cook it over?"); + from.Target = new CookTarget(source); + } + + public static bool ValidateInPack(Mobile from, Item item) + { + if (item is { Deleted: false } && item.IsChildOf(from.Backpack)) + { + return true; + } + + from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return false; + } + + public static bool CannotCombine(Mobile from) + { + from.SendAsciiMessage("You cannot combine those."); + return false; + } + + //TODO Support static water troughs and barrels (no static water ID list exists yet) + public static IHasQuantity GetWaterSource(object o) => + o switch + { + BaseBeverage { Content: BeverageType.Water, Quantity: > 0 } bev => bev, + BaseWaterContainer { IsEmpty: false } container => container, + AddonComponent { Addon: IWaterSource { Quantity: > 0 } trough } => trough, + IWaterSource { Quantity: > 0 } source => source, + _ => null + }; + + // Ground water only, so a nearby open container (someone else's pack) cannot be drawn from. + public static bool CanReachWater(Mobile from, Item water) + { + if (water.IsChildOf(from.Backpack) || water.Parent == from || + water.RootParent == null && water.Map == from.Map && + from.InRange(water.GetWorldLocation(), _waterRange) && from.InLOS(water)) + { + return true; + } + + from.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1019045); // I can't reach that. + return false; + } + + /// Spends one unit of each ingredient and gives the player the result. + public static bool Combine(Mobile from, object source, object target, Item result, string message) + { + ConsumeOne(source); + ConsumeOne(target); + + if (!GiveResult(from, result)) + { + return false; + } + + from.SendAsciiMessage(message); + return true; + } + + // AddToBackpack drops a result that does not fit at the mobile's feet (or logout location), + // but with no valid map either it would be left on the internal map forever. + private static bool GiveResult(Mobile from, Item result) + { + if (from.AddToBackpack(result) || result.Map != null && result.Map != Map.Internal) + { + return true; + } + + result.Delete(); + return false; + } + + //TODO Confirm whether an empty bowl is returned when a BowlFlour is used up + private static void ConsumeOne(object o) + { + if (o is IHasQuantity container) + { + container.Quantity--; + } + else if (o is Item item) + { + item.Consume(); + } + } + + /// Cooks the source over a heat source if it is close enough and the player is not already cooking. + public static bool BeginHeat(Mobile from, Item source, object target) + { + var point = target is IPoint3D p ? new Point3D(p) : from.Location; + if (from.Map == null || !from.InRange(point, _heatSourceRange)) + { + from.SendLocalizedMessage(500446); // That is too far away. + return false; + } + + if (!from.BeginAction()) + { + from.SendLocalizedMessage(500119); // You must wait to perform another action + return false; + } + + from.PlaySound(0x225); + source.Consume(); + + Timer.DelayCall(TimeSpan.FromSeconds(5.0), FinishHeat, from, source, from.Map, point); + return true; + } + + // The source is already consumed here and may be deleted, so only its type and CookingLevel are read. + private static void FinishHeat(Mobile from, Item source, Map map, Point3D point) + { + from.EndAction(); + + if (from.Deleted) + { + return; + } + + if (from.Map != map || !from.InRange(point, _burnRange)) + { + from.SendLocalizedMessage(500686); // You burn the food to a crisp! It's ruined. + return; + } + + var (min, max) = source switch + { + CookableFood food => (food.CookingLevel, 100.0), + Dough => CraftRange(typeof(BreadLoaf)), + SweetDough => CraftRange(typeof(Muffins)), + _ => (0.0, 100.0) + }; + + if (!from.CheckSkill(SkillName.Cooking, min, max)) + { + from.SendLocalizedMessage(500686); // You burn the food to a crisp! It's ruined. + return; + } + + Item result = source switch + { + CookableFood food => food.Cook(), + Dough => new BreadLoaf(), + SweetDough => new Muffins(), + _ => null + }; + + if (result != null && GiveResult(from, result)) + { + from.PlaySound(0x57); + } + } + + // Falls back to the full range when the cooking craft system has no entry or is not initialized. + private static (double Min, double Max) CraftRange(Type type) + { + var skills = DefCooking.CraftSystem?.CraftItems.SearchFor(type)?.Skills; + + return skills is { Count: > 0 } ? (skills[0].MinSkill, skills[0].MaxSkill) : (0.0, 100.0); + } + + private sealed class CookTarget : Target + { + private readonly Item _source; + + public CookTarget(Item source) : base(_waterRange, false, TargetFlags.None) => _source = source; + + protected override void OnTarget(Mobile from, object targeted) + { + if (ValidateInPack(from, _source) && _source is IT2ACombinable combinable) + { + combinable.TryCombine(from, targeted); + } + } + } +} diff --git a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs index a0fff9d0f..4e50b9ce6 100644 --- a/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs +++ b/Projects/UOContent/Engines/Craft/T2A/T2ACraftSystem.cs @@ -15,6 +15,12 @@ public static class T2ACraftSystem /// public static bool Enabled { get; set; } + public static bool HasMenu(CraftSystem system) => + system == DefBlacksmithy.CraftSystem || system == DefAlchemy.CraftSystem || + system == DefBowFletching.CraftSystem || system == DefCarpentry.CraftSystem || + system == DefCartography.CraftSystem || system == DefInscription.CraftSystem || + system == DefTailoring.CraftSystem || system == DefTinkering.CraftSystem; + public static void ShowMenu(Mobile from, CraftSystem craftSystem, BaseTool tool, Item preTarget = null) { if (!Enabled) diff --git a/Projects/UOContent/Items/Food/CookableFood.cs b/Projects/UOContent/Items/Food/CookableFood.cs index e9597fd03..1c426de88 100644 --- a/Projects/UOContent/Items/Food/CookableFood.cs +++ b/Projects/UOContent/Items/Food/CookableFood.cs @@ -1,10 +1,11 @@ using ModernUO.Serialization; +using Server.Engines.Craft.T2A; using Server.Targeting; namespace Server.Items; [SerializationGenerator(0, false)] -public abstract partial class CookableFood : Item +public abstract partial class CookableFood : Item, IT2ACombinable { [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] @@ -14,6 +15,21 @@ public abstract partial class CookableFood : Item public abstract Food Cook(); + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) => + IsHeatSource(target) ? T2ACooking.BeginHeat(from, this, target) : T2ACooking.CannotCombine(from); + public static bool IsHeatSource(object targeted) { int itemID; diff --git a/Projects/UOContent/Items/Food/Cooking.cs b/Projects/UOContent/Items/Food/Cooking.cs index cd010432d..22ce41837 100644 --- a/Projects/UOContent/Items/Food/Cooking.cs +++ b/Projects/UOContent/Items/Food/Cooking.cs @@ -1,11 +1,12 @@ using System; using ModernUO.Serialization; +using Server.Engines.Craft.T2A; using Server.Targeting; namespace Server.Items; [SerializationGenerator(0, false)] -public partial class Dough : Item +public partial class Dough : Item, IT2ACombinable { [Constructible] public Dough() : base(0x103d) @@ -14,10 +15,68 @@ public partial class Dough : Item } public override double DefaultWeight => 1.0; + + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) + { + if (CookableFood.IsHeatSource(target)) + { + return T2ACooking.BeginHeat(from, this, target); + } + + if (target is not Item item) + { + return T2ACooking.CannotCombine(from); + } + + if (!T2ACooking.ValidateInPack(from, item)) + { + return false; + } + + Item result = item switch + { + JarHoney => new SweetDough(), + Pear => new UnbakedFruitPie(), + Peach => new UnbakedPeachCobbler(), + Apple => new UnbakedApplePie(), + Pumpkin or SmallPumpkin => new UnbakedPumpkinPie(), + Eggs => new UnbakedQuiche(), + Ham or Spam or CookedBird or FishSteak => new UnbakedMeatPie(), + Sausage => new UncookedSausagePizza(), + _ => null + }; + + if (result == null) + { + return T2ACooking.CannotCombine(from); + } + + return T2ACooking.Combine( + from, + this, + item, + result, + item is JarHoney + ? "You mix the dough with honey to make sweet dough." + : "You combine the dough with the ingredient." + ); + } } [SerializationGenerator(0, false)] -public partial class SweetDough : Item +public partial class SweetDough : Item, IT2ACombinable { [Constructible] public SweetDough() : base(0x103d) @@ -28,10 +87,79 @@ public partial class SweetDough : Item public override double DefaultWeight => 1.0; public override int LabelNumber => 1041340; // sweet dough + + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) + { + if (CookableFood.IsHeatSource(target)) + { + return T2ACooking.BeginHeat(from, this, target); + } + + switch (target) + { + case JarHoney honey: + { + return T2ACooking.ValidateInPack(from, honey) && T2ACooking.Combine( + from, + this, + honey, + new CookieMix(), + "You mix the sweet dough with honey to make cookie mix." + ); + } + case SackFlour sack: + { + if (!T2ACooking.ValidateInPack(from, sack)) + { + return false; + } + + if (!sack.IsOpen) + { + from.SendAsciiMessage("You must open the sack of flour first."); + return false; + } + + return T2ACooking.Combine( + from, + this, + sack, + new CakeMix(), + "You mix the flour with sweet dough to make cake mix." + ); + } + case BowlFlour bowl: + { + return T2ACooking.ValidateInPack(from, bowl) && T2ACooking.Combine( + from, + this, + bowl, + new CakeMix(), + "You mix the flour with sweet dough to make cake mix." + ); + } + default: + { + return T2ACooking.CannotCombine(from); + } + } + } } [SerializationGenerator(0, false)] -public partial class JarHoney : Item +public partial class JarHoney : Item, IT2ACombinable { [Constructible] public JarHoney() : base(0x9ec) @@ -40,10 +168,44 @@ public partial class JarHoney : Item } public override double DefaultWeight => 1.0; + + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) + { + return target switch + { + Dough dough => T2ACooking.ValidateInPack(from, dough) && T2ACooking.Combine( + from, + this, + dough, + new SweetDough(), + "You mix the dough with honey to make sweet dough." + ), + SweetDough sweetDough => T2ACooking.ValidateInPack(from, sweetDough) && T2ACooking.Combine( + from, + this, + sweetDough, + new CookieMix(), + "You mix the sweet dough with honey to make cookie mix." + ), + _ => T2ACooking.CannotCombine(from) + }; + } } [SerializationGenerator(0, false)] -public partial class BowlFlour : Item +public partial class BowlFlour : Item, IT2ACombinable { [Constructible] public BowlFlour() : base(0xa1e) @@ -51,6 +213,41 @@ public partial class BowlFlour : Item } public override double DefaultWeight => 1.0; + + public override void OnDoubleClick(Mobile from) + { + if (T2ACraftSystem.Enabled) + { + T2ACooking.Prompt(from, this); + } + else + { + base.OnDoubleClick(from); + } + } + + public bool TryCombine(Mobile from, object target) + { + return target switch + { + SweetDough sweetDough => T2ACooking.ValidateInPack(from, sweetDough) && T2ACooking.Combine( + from, + this, + sweetDough, + new CakeMix(), + "You mix the flour with sweet dough to make cake mix." + ), + Item item when T2ACooking.GetWaterSource(item) is { } water => + T2ACooking.CanReachWater(from, item) && T2ACooking.Combine( + from, + this, + water, + new Dough(), + "You mix the flour with water to make dough." + ), + _ => T2ACooking.CannotCombine(from) + }; + } } [SerializationGenerator(0, false)] @@ -66,7 +263,7 @@ public partial class WoodenBowl : Item [TypeAlias("Server.Items.SackFlourOpen")] [SerializationGenerator(0, false)] -public partial class SackFlour : Item, IHasQuantity +public partial class SackFlour : Item, IHasQuantity, IT2ACombinable { [Constructible] public SackFlour() : base(0x1039) @@ -104,7 +301,52 @@ public partial class SackFlour : Item, IHasQuantity { ++ItemID; } + + if (!T2ACraftSystem.Enabled || !T2ACooking.ValidateInPack(from, this)) + { + return; + } + + if (IsOpen) + { + T2ACooking.Prompt(from, this); + } + else + { + from.SendAsciiMessage("You must open the sack of flour first."); + } } + + public bool TryCombine(Mobile from, object target) + { + if (!IsOpen) + { + from.SendAsciiMessage("You must open the sack of flour first."); + return false; + } + + return target switch + { + SweetDough sweetDough => T2ACooking.ValidateInPack(from, sweetDough) && T2ACooking.Combine( + from, + this, + sweetDough, + new CakeMix(), + "You mix the flour with sweet dough to make cake mix." + ), + Item item when T2ACooking.GetWaterSource(item) is { } water => + T2ACooking.CanReachWater(from, item) && T2ACooking.Combine( + from, + this, + water, + new Dough(), + "You mix the flour with water to make dough." + ), + _ => T2ACooking.CannotCombine(from) + }; + } + + public bool IsOpen => ItemID is not (0x1039 or 0x1045); } [SerializationGenerator(0, false)] diff --git a/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs b/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs index 73482ccd0..074c29512 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseTool.cs @@ -159,7 +159,7 @@ public abstract partial class BaseTool : Item, IUsesRemaining, ICraftable { from.SendLocalizedMessage(num); } - else if (T2ACraftSystem.Enabled) + else if (T2ACraftSystem.Enabled && T2ACraftSystem.HasMenu(system)) { from.Target = new T2ACraftToolTarget(this, system); from.SendAsciiMessage("Target this tool to make last item, or any other target to begin crafting."); From 302f1ad98800144081e07113e28cc6c8fd020353 Mon Sep 17 00:00:00 2001 From: LoveDesignUA <68774128+LoveDesignUA@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:21:36 +0700 Subject: [PATCH 23/26] fix: Moving Shot only charges mana for a fired shot (#2726) Moving Shot charged its mana in `OnBeforeSwing`, before `BaseRanged.OnSwing` decided whether to fire. A swing that fired nothing (a paralyzed or frozen archer, no ammo, a failed harmful check) still took the mana and left the move armed. Every swing after that charged again, so inside the 3-second window you paid twice, until the archer ran out of mana. `OnBeforeSwing` now only checks the mana. Moving Shot's own `OnHit`/`OnMiss` charge it, and those only run once a shot has fired. This is how the other weapon abilities pay. A miss still costs mana. A parried shot no longer does: the parry clears the move before `OnHit` runs, the same as for every other ability. --- .../UOContent/Items/Weapons/Abilities/MovingShot.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Projects/UOContent/Items/Weapons/Abilities/MovingShot.cs b/Projects/UOContent/Items/Weapons/Abilities/MovingShot.cs index e0b543cfd..955023c8f 100644 --- a/Projects/UOContent/Items/Weapons/Abilities/MovingShot.cs +++ b/Projects/UOContent/Items/Weapons/Abilities/MovingShot.cs @@ -11,8 +11,9 @@ namespace Server.Items public override bool ValidatesDuringHit => false; + // Mana is only checked here; OnHit/OnMiss charge it, since they run only for a shot that actually fired. public override bool OnBeforeSwing(Mobile attacker, Mobile defender) => - Validate(attacker) && CheckMana(attacker, true); + Validate(attacker) && CheckMana(attacker, false); public override void OnMiss(Mobile attacker, Mobile defender) { @@ -20,7 +21,10 @@ namespace Server.Items ClearCurrentAbility(attacker); - attacker.SendLocalizedMessage(1060089); // You fail to execute your special move + if (CheckMana(attacker, true)) + { + attacker.SendLocalizedMessage(1060089); // You fail to execute your special move + } } public override void OnHit(Mobile attacker, Mobile defender, int damage, WorldLocation worldLocation) @@ -29,7 +33,10 @@ namespace Server.Items ClearCurrentAbility(attacker); - attacker.SendLocalizedMessage(1060216); // Your shot was successful + if (CheckMana(attacker, true)) + { + attacker.SendLocalizedMessage(1060216); // Your shot was successful + } } } } From ab59efea86fd9e399d261bed9d188ab4d95ad4c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:22:24 -0700 Subject: [PATCH 24/26] fix: Bump MailKit from 4.18.1 to 4.18.2 (#2727) --- updated-dependencies: - dependency-name: MailKit dependency-version: 4.18.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Projects/UOContent/UOContent.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index a5584e7ee..d6acad5bd 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -44,7 +44,7 @@ - + From 9af7dd555a803120559ced473b18ffa73d3dfe13 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:27:12 -0700 Subject: [PATCH 25/26] fix: LastMoved and stat-gain stamps stay absolute across restarts (#2729) ReadAnchoredTime shifted Item.LastMoved, Item.DecayResetTime and Mobile.LastStr/Int/DexGain forward by the downtime at load. The bytes were stable from save to save, but the first save after every restart rewrote one timestamp in nearly every item record and most mobile records, which defeats any per-record backup or delta save. - LastMoved and DecayResetTime are read as saved. The downtime credit is applied after load by Item.CreditDowntimeToDecay, which moves DecayResetTime only for items whose decay countdown was still running when the save started. Every other item keeps its bytes, and decay timing is unchanged. - World.LoadedSaveStartTime exposes the loaded save's anchor so the credit can tell which countdowns were running. - Stat-gain stamps are read raw. The cooldown is minutes long, so downtime counting toward it costs nothing. No format change: these fields were already absolute ticks. Co-authored-by: Claude Opus 5.5 --- .../AnchoredItemSerializationTests.cs | 108 ++++++++++++++++++ Projects/Server/Items/Item.cs | 39 ++++++- Projects/Server/Mobiles/Mobile.cs | 14 ++- .../Serialization/GenericEntityPersistence.cs | 1 + Projects/Server/World/World.cs | 7 ++ 5 files changed, 157 insertions(+), 12 deletions(-) diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs index 41444ea56..ff032f530 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -82,4 +82,112 @@ public class AnchoredItemSerializationTests restored?.Delete(); } } + + /// + /// LastMoved is the real time of the move, so a load after downtime must not shift it. + /// Shifting it rewrote every item record on the first save after every restart. + /// + [Fact] + public void LastMoved_IsNotShiftedByTheLoadAnchor() + { + var item = new Item(0x1F13); + var moved = Core.Now - TimeSpan.FromDays(30); + item.LastMoved = moved; + + var restored = new Item((Serial)0x7ffff124u); + restored.Deserialize(new BufferReader(SerializeItem(item)) { AnchoredTimeShift = TimeSpan.FromHours(2) }); + + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(default(DateTime), restored.DecayResetTime); + + item.Delete(); + restored.Delete(); + } + + /// + /// Downtime still does not count toward decay: a countdown running when the save started + /// resumes with the time it had left, carried by DecayResetTime. + /// + [Fact] + public void Downtime_IsCreditedToARunningDecayCountdown() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.LastMoved = start - TimeSpan.FromMinutes(10); + var deadline = item.ScheduledDecayTime; + + var downtime = TimeSpan.FromHours(2); + Core._now = start + downtime; + item.CreditDowntimeToDecay(start, downtime); + + Assert.Equal(start - TimeSpan.FromMinutes(10), item.LastMoved); + Assert.Equal(deadline + downtime, item.ScheduledDecayTime); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// The common case: an item whose countdown had already run out gets no credit, so a restart + /// leaves its record byte-identical. + /// + [Fact] + public void ItemAlreadyDueAtSave_GetsNoCredit_AndKeepsItsBytes() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.LastMoved = start - TimeSpan.FromDays(3); + var before = SerializeItem(item); + + var downtime = TimeSpan.FromMinutes(30); + Core._now = start + downtime; + item.CreditDowntimeToDecay(start, downtime); + + Assert.Equal(default(DateTime), item.DecayResetTime); + Assert.Equal(before, SerializeItem(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// Stat-gain stamps are absolute and not shifted at load either; downtime counts toward a + /// cooldown that is only minutes long. + /// + [Fact] + public void StatGainStamps_AreNotShiftedByTheLoadAnchor() + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.LastStrGain = Core.Now - TimeSpan.FromMinutes(3); + mobile.LastIntGain = Core.Now - TimeSpan.FromMinutes(2); + mobile.LastDexGain = Core.Now - TimeSpan.FromMinutes(1); + + var writer = new BufferWriter(new byte[4096], true); + mobile.Serialize(writer); + + var restored = new Mobile(World.NewMobile); + restored.Deserialize(new BufferReader(writer.Buffer[..(int)writer.Position]) { AnchoredTimeShift = TimeSpan.FromHours(2) }); + + Assert.Equal(mobile.LastStrGain, restored.LastStrGain); + Assert.Equal(mobile.LastIntGain, restored.LastIntGain); + Assert.Equal(mobile.LastDexGain, restored.LastDexGain); + + mobile.Delete(); + restored.Delete(); + } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 4226f100f..c67acdc85 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -1042,13 +1042,14 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.Write((int)flags); - // Anchored: shifted by downtime at load, so time-since-moved is preserved and the - // bytes are stable across saves while the item does not move. - writer.WriteAnchoredTime(LastMoved); + // Absolute and never shifted at load, so an item that does not move serializes to the + // same bytes across saves and across restarts. Downtime credit for a decay countdown that + // was running at save time goes into DecayResetTime (CreditDowntimeToDecay). + writer.Write(LastMoved); if (GetSaveFlag(flags, SaveFlag.DecayReset)) { - writer.WriteAnchoredTime(info.m_DecayReset); + writer.Write(info.m_DecayReset); } if (GetSaveFlag(flags, SaveFlag.Direction)) @@ -2494,6 +2495,30 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert UpdateDecayRegistration(); } + /// + /// Credits the downtime before this load to a decay countdown that was still running when the + /// save started, so the shard being down does not age the item. stays + /// the real time of the move; the credit goes into , which only + /// the items moved within their of the save ever carry. Called once + /// per item after the world loads, when is final. An item already + /// due at save time gets nothing: it is due either way. + /// + internal void CreditDowntimeToDecay(DateTime saveStartUtc, TimeSpan downtime) + { + if (downtime <= TimeSpan.Zero || !Decays) + { + return; + } + + var reset = DecayResetTime; + var start = reset > LastMoved ? reset : LastMoved; + + if (start + DecayTime > saveStartUtc) + { + DecayResetTime = start + downtime; + } + } + public void UpdateDecayRegistration() { DecayScheduler.Unregister(this); @@ -2856,7 +2881,9 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 11) { - LastMoved = reader.ReadAnchoredTime(); + // Written with WriteAnchoredTime before LastMoved stopped being shifted; + // the bytes are the same absolute ticks either way. + LastMoved = reader.ReadDateTime(); } else if (version < 7) { @@ -2878,7 +2905,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) { - var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + var reset = version >= 11 ? reader.ReadDateTime() : reader.ReadDeltaTime(); // Pre-v11 LastMoved was stored at whole-minute precision; keep the // stamp only while it still extends the deadline. diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 52628e74a..e1cb74e9b 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -2343,9 +2343,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; @@ -6179,9 +6181,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { if (version >= 38) { - LastStrGain = reader.ReadAnchoredTime(); - LastIntGain = reader.ReadAnchoredTime(); - LastDexGain = reader.ReadAnchoredTime(); + LastStrGain = reader.ReadDateTime(); + LastIntGain = reader.ReadDateTime(); + LastDexGain = reader.ReadDateTime(); } else { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 70e9c6aab..9fe12e4e9 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) 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(); } } From 5570537aa0416a350b7ace08e7e45adf6481fb48 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:44:33 -0700 Subject: [PATCH 26/26] fix: anchored times shift only while still running at save start (#2730) Follow-up to #2729. ## Problem At load, `ReadAnchoredTime` shifted **every** anchored value forward by the downtime, including values that were already in the past when the save started. That caused two problems. **An expired deadline came back.** A deadline that passed 5 minutes before a 30-minute outage loaded 25 minutes in the future. For example: - `Sheep.NextWoolTime`: a sheep that was ready to shear stopped being ready. - `PlayerVendor.NextPayTime`: an overdue payment was pushed back. - `BaseCreature.SummonEnd`, the spell fields' `End`, `TransientItem.Expiration`, and the ChampionSpawn times could move the same way. **Records were rewritten for nothing.** The first save after every restart rewrote: - stamps of things that had already happened: VirtueContext's `LastHonorUse`, `LastSacrificeGain`, the four `Last*Loss` stamps, and `Corpse.TimeOfDeath` - every deadline that had expired long ago. ## Change - **`IGenericReader.AnchoredTimeOrigin`** is the loaded save's start time. - `GenericEntityPersistence` and `GenericPersistence` set it from `World.LoadedSaveStartTime`. - `BufferReader`, `UnmanagedDataReader` and `BinaryFileReader` expose it. - **`ReadAnchoredTime` returns any value at or before the origin exactly as saved.** - A deadline still running at save start is shifted as before and keeps its remaining time. - Readers without an origin keep the old behaviour. - **This covers every anchored field, including generated ones.** Generated `[AnchoredDateTime]` fields call `reader.ReadAnchoredTime()`, so the one change covers all 26 generated fields as well as the manual reads. `[DeserializeTimer]` only restores timers that were running at save time, so it is unaffected. - **The `[AnchoredDateTime]` section of `dev-docs/serialization.md` and the `WriteAnchoredTime` doc comment describe the new rule.** ## Behaviour change Downtime now counts toward windows measured from a past stamp: | Window | Length | |---|---| | Honor use | 5 minutes | | Sacrifice gain | 1 day | | Virtue atrophy | 7 days | | Instanced corpse looting | 3 minutes | A 30-minute outage makes a 7-day atrophy fire about 0.3% sooner. No format change. ## Tests New tests in `AnchoredTimeTests`: - A deadline running at save start keeps its remaining time. - A deadline that passed before save start stays passed. - A past stamp is not shifted. - A value exactly at save start is not shifted. `Server.Tests`: 845 passed. `UOContent.Tests`: 1140 passed. --- .../Tests/Serialization/AnchoredTimeTests.cs | 57 +++++++++++++++++++ .../Server/Serialization/BinaryFileReader.cs | 6 ++ Projects/Server/Serialization/BufferReader.cs | 2 + Projects/Server/Serialization/BufferWriter.cs | 6 +- .../Serialization/GenericEntityPersistence.cs | 3 +- .../Serialization/GenericPersistence.cs | 3 +- .../Server/Serialization/IGenericReader.cs | 15 ++++- .../Serialization/UnmanagedDataReader.cs | 2 + dev-docs/serialization.md | 15 +++-- 9 files changed, 97 insertions(+), 12 deletions(-) diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs index 74edf09c3..1015e704d 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -67,6 +67,63 @@ public class AnchoredTimeTests Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime()); } + + private static readonly DateTime SaveStart = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc); + + private static IGenericReader ReadSaved(BufferWriter writer, TimeSpan downtime) => + new BufferReader(writer.Buffer) { AnchoredTimeShift = downtime, AnchoredTimeOrigin = SaveStart }; + + [Fact] + public void AnchoredTime_DeadlineRunningAtSaveStart_KeepsItsRemainingTime() + { + var writer = new BufferWriter(new byte[64], true); + var deadline = SaveStart + TimeSpan.FromMinutes(20); + var downtime = TimeSpan.FromHours(3); + + writer.WriteAnchoredTime(deadline); + + Assert.Equal(deadline + downtime, ReadSaved(writer, downtime).ReadAnchoredTime()); + } + + /// + /// A deadline that had passed before the save must stay passed. Shifting it brought an expiry + /// back into the future whenever the downtime was longer than the time since it expired. + /// + [Fact] + public void AnchoredTime_DeadlinePassedBeforeSaveStart_StaysPassed() + { + var writer = new BufferWriter(new byte[64], true); + var expired = SaveStart - TimeSpan.FromMinutes(5); + + writer.WriteAnchoredTime(expired); + + Assert.Equal(expired, ReadSaved(writer, TimeSpan.FromMinutes(30)).ReadAnchoredTime()); + } + + /// + /// A stamp of something that already happened (a last-use time) is never shifted, so its + /// bytes survive a restart and downtime counts toward its window. + /// + [Fact] + public void AnchoredTime_PastStamp_IsNotShifted() + { + var writer = new BufferWriter(new byte[64], true); + var lastUse = SaveStart - TimeSpan.FromDays(2); + + writer.WriteAnchoredTime(lastUse); + + Assert.Equal(lastUse, ReadSaved(writer, TimeSpan.FromHours(1)).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_ValueAtSaveStart_IsNotShifted() + { + var writer = new BufferWriter(new byte[64], true); + + writer.WriteAnchoredTime(SaveStart); + + Assert.Equal(SaveStart, ReadSaved(writer, TimeSpan.FromHours(1)).ReadAnchoredTime()); + } } internal class AnchoredEntity : ISerializable 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 9fe12e4e9..325789d5c 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -710,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/dev-docs/serialization.md b/dev-docs/serialization.md index 059fbff69..c1b86ab2a 100644 --- a/dev-docs/serialization.md +++ b/dev-docs/serialization.md @@ -261,14 +261,17 @@ Best for fields that are usually small values (counts, IDs, indexes). ### [AnchoredDateTime] **Target**: `DateTime` field -**Effect**: Stores the absolute UTC instant; at load it is shifted forward by the downtime -between the save and the load (using the save-start anchor in the save's index file), so -server downtime does not consume the remaining time. `DateTime.MinValue`/`MaxValue` +**Effect**: Stores the absolute UTC instant. At load, a value still in the future when the save +started is shifted forward by the downtime between the save and the load (using the save-start +anchor in the save's index file), so server downtime does not consume the remaining time. A +value at or before the save start is loaded as saved: an expired deadline stays expired, and a +"last happened at" stamp lets downtime count toward its window. `DateTime.MinValue`/`MaxValue` sentinels pass through unshifted. -Prefer this for deadlines and "elapsed while running" values. Unlike `[DeltaDateTime]`, the -stored bytes do not change on every save when the value is unchanged, keeping idle saves -byte-stable. +Use this for deadlines. A field that only records when something happened (a last-use stamp) +never shifts, so it may equally be a plain `DateTime`. Unlike `[DeltaDateTime]`, the stored +bytes do not change on every save when the value is unchanged, and only deadlines still running +at save time change across a restart. ```csharp [AnchoredDateTime]