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
-
+
-
+ allruntime; 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
-
+
-
+ allruntime; 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("