From 29a3ab1bd443b9c2a8ff6bf34f4df47d9f837895 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:57:23 -0700 Subject: [PATCH 01/31] feat(admin): replace dead ThreadPool counters with a network readout (#2663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The admin gump's **Performance** page still rendered `ThreadPool` worker / IOCP quotas — a RunUO leftover from `Socket.BeginReceive` days. Both figures are dead: IORingGroup drives RIO / io_uring / epoll / kqueue synchronously on the game loop, so the IOCP figure reads 0% forever, and the real off-loop work (serialization workers, password checks, advanced search, console input) runs on dedicated threads the worker figure never sees. This replaces the block with a read-only network readout. ## Engine - `NetState.GetNetworkStats()` — new public accessor returning a `NetworkStats` snapshot (ModernUO-owned record types in `Network/NetworkStats.cs`, so UOContent takes no dependency on transport types): - live: connected / max sockets, authenticated count, per-tier growth usage, the three loop queues (throttled, flush pending, pending disconnects) - configured: base / pre-auth / max buffer sizes, growth budget, memory ceiling, last-read available memory - `LastSweep`: everything the once-a-minute maintenance sweep saw, including the refusal counters it resets - The sweep already kept four of its results (`_lastTier*`, `_lastBaseCapacityBytes`) for change detection; those fold into one `SendBufferSweep` snapshot. Sweep logic and logging are unchanged. - The accessor never calls `Maintain()`, so opening the page cannot rotate the retention window. - Tick age is compared by subtraction; "has a sweep run" is a `bool`, not a zero-tick sentinel. ## Gump ``` Event Loop: Healthy Connections: 123 / 4096 (117 authenticated) Loop Queues: throttled 0, flush 3, closing 0 Send Buffers: [scrollable] Base: 64.0 KB recv, 256.0 KB send; pre-auth 4.0 KB / 4.0 KB Base pools: 10.2 MB Growth tiers (live): 512.0 KB: 3 / 32 in use, floor 5 1.0 MB: 0 / 0 in use, floor 0 2.0 MB: 0 / 0 in use, floor 0 Budget: 16.0 MB of 256.0 MB, max 2.0 MB per socket Ceiling: 80% of 7.8 GB available Last sweep 42s ago: released 0 tier, 0 base Refused: budget 0, at max 0, ceiling 0 ``` --- .../Tests/Network/NetworkStatsTests.cs | 80 ++++++++++++++++ .../Network/NetState/NetState.Network.cs | 72 ++++++++++++--- Projects/Server/Network/NetworkStats.cs | 91 +++++++++++++++++++ Projects/UOContent/Gumps/AdminGump.cs | 67 ++++++++++---- 4 files changed, 280 insertions(+), 30 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs create mode 100644 Projects/Server/Network/NetworkStats.cs diff --git a/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs new file mode 100644 index 000000000..5a64a61fe --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/NetworkStatsTests.cs @@ -0,0 +1,80 @@ +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/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 287fa78a4..f100f85c3 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -56,10 +56,7 @@ public partial class NetState internal static long _availableMemoryBytes; private static Timer.DelayCallTimer _maintenanceTimer; - private static long _lastTierCapacityBytes; - private static int _lastTierInUse; - private static int _lastTierRetainFloor; - private static long _lastBaseCapacityBytes; + private static SendBufferSweep _lastSweep; private static readonly Queue _disposed = []; private static readonly TimeSpan ConnectingSocketIdleLimit = TimeSpan.FromMilliseconds(5000); // 5 seconds @@ -238,14 +235,23 @@ public partial class NetState _capRefusals = 0; var stats = _socketManager.Maintain(); - var changed = stats.TierCapacityBytes != _lastTierCapacityBytes || - stats.TierInUse != _lastTierInUse || - stats.TierRetainFloor != _lastTierRetainFloor || - stats.BaseCapacityBytes != _lastBaseCapacityBytes; - _lastTierCapacityBytes = stats.TierCapacityBytes; - _lastTierInUse = stats.TierInUse; - _lastTierRetainFloor = stats.TierRetainFloor; - _lastBaseCapacityBytes = stats.BaseCapacityBytes; + var changed = stats.TierCapacityBytes != _lastSweep.TierCapacityBytes || + stats.TierInUse != _lastSweep.TierInUse || + stats.TierRetainFloor != _lastSweep.TierRetainFloor || + stats.BaseCapacityBytes != _lastSweep.BaseCapacityBytes; + _lastSweep = new SendBufferSweep( + true, + Core.TickCount, + stats.TierCapacityBytes, + stats.TierInUse, + stats.TierRetainFloor, + stats.BuffersReleased, + stats.BaseCapacityBytes, + stats.BaseBuffersReleased, + stats.GrowthRefusals, + capRefusals, + ceilingRefusals + ); // Quiet unless something moved if (changed || stats.BuffersReleased > 0 || stats.BaseBuffersReleased > 0 || stats.GrowthRefusals > 0 || @@ -266,6 +272,48 @@ public partial class NetState } } + /// + /// Read-only diagnostics snapshot; never rotates the maintenance window. Cold path: allocates. + /// + public static NetworkStats GetNetworkStats() + { + var manager = _socketManager; + var tiers = manager == null ? [] : new SendBufferTierUsage[manager.SendBufferTierCount]; + for (var i = 0; i < tiers.Length; i++) + { + var tier = manager.GetSendBufferTierStats(i); + tiers[i] = new SendBufferTierUsage(tier.BufferSize, tier.Capacity, tier.InUse, tier.RetainFloor); + } + + var authenticated = 0; + foreach (var ns in _instances) + { + if (ns.Account != null) + { + authenticated++; + } + } + + return new NetworkStats( + manager?.ConnectedCount ?? 0, + manager?.MaxSockets ?? MaxConnections, + authenticated, + RecvBufferSize, + SendBufferSize, + MaxSendBufferSize, + InitialRecvBufferSize, + InitialSendBufferSize, + _sendBufferGrowthBudget, + _memoryCeilingPercent, + _availableMemoryBytes, + tiers, + _lastSweep, + _throttled.Count + _throttledPending.Count, + _flushPending.Count, + _pendingDisconnects.Count + ); + } + /// /// Slabs each base pool is divided into, after the transport applies its minimum slab size. /// diff --git a/Projects/Server/Network/NetworkStats.cs b/Projects/Server/Network/NetworkStats.cs new file mode 100644 index 000000000..370b8b7ff --- /dev/null +++ b/Projects/Server/Network/NetworkStats.cs @@ -0,0 +1,91 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: NetworkStats.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +namespace Server.Network; + +/// One send buffer growth tier's pool usage, in buffers of bytes. +/// Physical size of every buffer in this tier. +/// Buffers the tier's allocated slabs hold. +/// Buffers currently handed out. +/// Peak usage the retention window is still holding capacity for. +public readonly record struct SendBufferTierUsage(int BufferSize, int Capacity, int InUse, int RetainFloor); + +/// +/// What the last once-a-minute send buffer maintenance sweep saw. The refusal counts cover the +/// minute before that sweep only. +/// +/// False until the first sweep; every other field is then meaningless. +/// when the sweep ran; compare by subtraction only. +/// Slab capacity allocated across the growth tiers. +/// Growth-tier buffers handed out, summed across tiers. +/// Retention floors summed across tiers, in buffers. +/// Growth-tier buffers the sweep returned to the OS. +/// Slab capacity allocated across the base and pre-auth pools. +/// Base and pre-auth buffers the sweep returned to the OS. +/// Growth refused because the tiers were at the growth budget. +/// Growth refused because the buffer was already at the maximum size. +/// Growth refused because the process was above the memory ceiling. +public readonly record struct SendBufferSweep( + bool Ran, + long Tick, + long TierCapacityBytes, + int TierInUse, + int TierRetainFloor, + int TierBuffersReleased, + long BaseCapacityBytes, + int BaseBuffersReleased, + int BudgetRefusals, + int CapRefusals, + int CeilingRefusals +); + +/// +/// Read-only snapshot of the network layer for diagnostics. Connection counts and tier usage are +/// live; everything under is up to a minute old. Cold path: allocates. +/// +/// Sockets the transport currently holds, authenticated or not. +/// Socket slots the transport was built with. +/// Connections past login, and so holding full-size buffers. +/// Base receive buffer size. +/// Base send buffer size. +/// Largest size a send buffer can grow to. +/// Pre-auth receive buffer size; 0 when pre-auth buffers are off. +/// Pre-auth send buffer size; 0 when pre-auth buffers are off. +/// Bytes the growth tiers may hold in total. +/// Growth stops once the process exceeds this share of available memory. +/// Memory available to the process as of the last sweep. +/// Live usage of each growth tier, smallest first. +/// What the last maintenance sweep saw. +/// Connections whose parser is paused on a packet throttle. +/// Connections with unsent bytes queued for the next flush. +/// Connections closing after their final flush. +public readonly record struct NetworkStats( + int Connected, + int MaxConnections, + int Authenticated, + int RecvBufferSize, + int SendBufferSize, + int MaxSendBufferSize, + int InitialRecvBufferSize, + int InitialSendBufferSize, + long SendBufferGrowthBudget, + int MemoryCeilingPercent, + long AvailableMemoryBytes, + SendBufferTierUsage[] Tiers, + SendBufferSweep LastSweep, + int Throttled, + int FlushPending, + int PendingDisconnects +); diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index a4fbe0f18..8d75f15d6 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -234,27 +234,16 @@ namespace Server.Gumps AddLabel(20, 130, LabelHue, "Event Loop:"); AddLabel(40, 150, LabelHue, loopStatus); - using var sb = ValueStringBuilder.Create(); + var net = NetState.GetNetworkStats(); - ThreadPool.GetAvailableThreads(out var curUser, out var curIOCP); - ThreadPool.GetMaxThreads(out var maxUser, out var maxIOCP); + AddLabel(20, 170, LabelHue, "Connections:"); + AddLabel(150, 170, LabelHue, $"{net.Connected} / {net.MaxConnections} ({net.Authenticated} authenticated)"); - sb.Append("Worker Threads:
Capacity: "); - sb.Append(maxUser); - sb.Append("
Available: "); - sb.Append(curUser); - sb.Append("
Usage: "); - sb.Append((maxUser - curUser) * 100 / maxUser); - sb.Append("%

IOCP Threads:
Capacity: "); - sb.Append(maxIOCP); - sb.Append("
Available: "); - sb.Append(curIOCP); - sb.Append("
Usage: "); - sb.Append((maxIOCP - curIOCP) * 100 / maxIOCP); - sb.Append('%'); + AddLabel(20, 190, LabelHue, "Loop Queues:"); + AddLabel(150, 190, LabelHue, $"throttled {net.Throttled}, flush {net.FlushPending}, closing {net.PendingDisconnects}"); - AddLabel(20, 200, LabelHue, "Pooling:"); - AddHtml(20, 220, 380, 150, sb.ToString(), true, true); + AddLabel(20, 210, LabelHue, "Send Buffers:"); + AddHtml(20, 230, 380, 180, FormatSendBuffers(net), true, true); AddPageButton(200, 20, GetButtonID(0, 0), "General", AdminGumpPage.Information_General); AddPageButton(200, 40, GetButtonID(0, 5), "Performance", AdminGumpPage.Information_Perf); @@ -1364,6 +1353,48 @@ namespace Server.Gumps public static string FormatTimeSpan(TimeSpan ts) => $"{ts.Days:D2}:{ts.Hours % 24:D2}:{ts.Minutes % 60:D2}:{ts.Seconds % 60:D2}"; + private static string FormatSendBuffers(NetworkStats net) + { + using var sb = ValueStringBuilder.Create(); + + sb.Append($"Base: {FormatByteAmount(net.RecvBufferSize)} recv, {FormatByteAmount(net.SendBufferSize)} send"); + if (net.InitialRecvBufferSize > 0) + { + sb.Append($"; pre-auth {FormatByteAmount(net.InitialRecvBufferSize)} / {FormatByteAmount(net.InitialSendBufferSize)}"); + } + + var sweep = net.LastSweep; + if (sweep.Ran) + { + sb.Append($"
Base pools: {FormatByteAmount(sweep.BaseCapacityBytes)}"); + } + + long tierBytes = 0; + sb.Append("

Growth tiers (live):"); + for (var i = 0; i < net.Tiers.Length; i++) + { + var tier = net.Tiers[i]; + tierBytes += (long)tier.Capacity * tier.BufferSize; + sb.Append($"
{FormatByteAmount(tier.BufferSize)}: {tier.InUse} / {tier.Capacity} in use, floor {tier.RetainFloor}"); + } + + sb.Append($"
Budget: {FormatByteAmount(tierBytes)} of {FormatByteAmount(net.SendBufferGrowthBudget)}, max {FormatByteAmount(net.MaxSendBufferSize)} per socket"); + sb.Append($"
Ceiling: {net.MemoryCeilingPercent}% of {FormatByteAmount(net.AvailableMemoryBytes)} available"); + + if (sweep.Ran) + { + var ageSeconds = (Core.TickCount - sweep.Tick) / 1000; + sb.Append($"

Last sweep {ageSeconds}s ago: released {sweep.TierBuffersReleased} tier, {sweep.BaseBuffersReleased} base"); + sb.Append($"
Refused: budget {sweep.BudgetRefusals}, at max {sweep.CapRefusals}, ceiling {sweep.CeilingRefusals}"); + } + else + { + sb.Append("

First sweep pending"); + } + + return sb.ToString(); + } + public static string FormatByteAmount(long totalBytes) { return totalBytes switch From 502c3f6aa4be9a87aec71da26fc714fdbce51b2b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:48:13 -0700 Subject: [PATCH 02/31] fix(ai): BaseAI.OnAggressiveAction owns the retaliation policy; familiars refuse cleanly (#2662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!IMPORTANT] > **Breaking change to API to fix NPC AI issues** ## Symptom `FamiliarAITests.HiddenCaster_FamiliarRefusesRetaliation` fails on main at `Assert.False(wolf.Warmode)`: the familiar's `Combatant` stays null as intended, but it is left in war stance. In-game the familiar of a hidden caster flips into warmode for a tick when struck, until `FamiliarAI.StandDown()` clears it on the next think. CI never saw it because the familiar tests are `SkippableFact` (they need client tiledata). ## Root cause #2644 and #2645 landed a day apart and are individually correct but conflict at one seam. Since #2645, a familiar (`Commandable == false`) is no longer "told to stand down", so `BaseCreature.AggressiveAction` continues past `AIObject.OnAggressiveAction(aggressor)` to its generic fallback: ```cs else if (Combatant == null && !BardPacified) { Warmode = true; Combatant = aggressor; } ``` `FamiliarAI.OnAggressiveAction` had already declined (caster hidden), but the hook was `void`, so the creature could not tell. The fallback raised `Warmode` before `BaseFamiliar.Combatant` vetoed the target. ## Fix This is the follow-up agreed in #2645: the retaliation policy moves into a virtual `BaseAI.OnAggressiveAction(aggressor) -> bool`. - **`BaseAI.OnAggressiveAction`** now holds the stand-down verdict, the closer-aggressor swap (`PreferCloserAggressor`), and the `IssueOrder(Attack)` vs `Combatant =` decision. Returns `false` when the creature stands down. - **`BaseCreature.AggressiveAction`** keeps only bookkeeping — `ControlMaster.Aggressors` entry, `StopFlee`, `ForceReacquire`, ethics `FinishShield` — keyed off the return, so an AI that declines is never second-guessed. - **`FamiliarAI`** overrides the whole policy: no orders to stand down on, never fights for a hidden caster or a hidden aggressor. Behaviour for every other AI is unchanged. Moving the fallback ahead of `StopFlee`/`ForceReacquire` is order-safe: both are pure field resets, `IssueAttack` does not `Think()`, and `OnCombatantChange` only derives `Warmode`. Two commits: the first is the minimal `bool` hook that alone fixes the test, the second the policy move. ## Not changed (noted for later) - `Warmode = true; Combatant = x;` (also in `FamiliarAI.Act`, `IssueAttack`) is redundant when the setter accepts — `BaseCreature.OnCombatantChange` derives `Warmode` — and is the dangling-warmode hazard whenever a `Combatant` setter vetoes. - Since #2614 a stood-down pet skips the ethics `FinishShield`; RunUO ran it unconditionally. Preserved as-is. ## Tests `HiddenCaster_FamiliarRefusesRetaliation` fails on main and passes here. Full `UOContent.Tests`: 1126 passed, 0 failed, 2 skipped (spawner fixture capture + benchmark). --- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 46 +++++++++++++++---- Projects/UOContent/Mobiles/AI/FamiliarAI.cs | 12 +++-- Projects/UOContent/Mobiles/BaseCreature.cs | 21 +-------- .../migrate-items-mobiles.md | 1 + dev-docs/content-patterns.md | 3 ++ .../09-items-mobiles-creatures.md | 43 +++++++++++++++++ .../runuo-migration-docs/11-api-reference.md | 1 + 7 files changed, 96 insertions(+), 31 deletions(-) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 9b42136a6..6e5f1c295 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -131,21 +131,51 @@ public abstract partial class BaseAI } } - public virtual void OnAggressiveAction(Mobile aggressor) + /// + /// The retaliation policy: how the creature answers . Returns false when it + /// stands down, and then skips its combat bookkeeping. + /// + public virtual bool OnAggressiveAction(Mobile aggressor) + { + // Only a creature somebody can command has been told anything; wild creatures rest on None. + var toldToStandDown = Mobile.Controlled && Mobile.ControlMaster != null && Mobile.Commandable && + IsStandDownOrder(Mobile.ControlOrder); + + if (Mobile.StandsDownOnCommand && toldToStandDown) + { + DebugSay("I'm being attacked but my master told me not to fight."); + Mobile.Warmode = false; + return false; + } + + PreferCloserAggressor(aggressor); + + if (aggressor.ChangingCombatant && toldToStandDown) + { + // With stand-down off, a resting pet answers a direct attack with a full Attack order. + Mobile.IssueOrder(OrderType.Attack, null, aggressor); + } + else if (Mobile.Combatant == null && !Mobile.BardPacified) + { + Mobile.Warmode = true; + Mobile.Combatant = aggressor; + } + + return true; + } + + // A visible aggressor closer than the current combatant takes over. + protected void PreferCloserAggressor(Mobile aggressor) { if (aggressor.Hidden) { return; } - var currentCombat = Mobile.Combatant; + var current = Mobile.Combatant; - if (currentCombat == null || currentCombat == aggressor) - { - return; - } - - if (Mobile.GetDistanceToSqrt(aggressor) < Mobile.GetDistanceToSqrt(currentCombat)) + if (current != null && current != aggressor && + Mobile.GetDistanceToSqrt(aggressor) < Mobile.GetDistanceToSqrt(current)) { Mobile.Combatant = aggressor; } diff --git a/Projects/UOContent/Mobiles/AI/FamiliarAI.cs b/Projects/UOContent/Mobiles/AI/FamiliarAI.cs index fcaf96c69..2da24dc8d 100644 --- a/Projects/UOContent/Mobiles/AI/FamiliarAI.cs +++ b/Projects/UOContent/Mobiles/AI/FamiliarAI.cs @@ -47,21 +47,25 @@ public class FamiliarAI : BaseAI return Mobile.Controlled ? OrderType.Come : OrderType.None; } - public override void OnAggressiveAction(Mobile aggressor) + // No orders to stand down on; never fights for a hidden caster. + public override bool OnAggressiveAction(Mobile aggressor) { if (!Familiar.AssistsMaster || aggressor.Hidden || Familiar.ControlMaster?.Hidden == true) { - return; + return false; } if (Mobile.Combatant == null) { Mobile.Warmode = true; Mobile.Combatant = aggressor; - return; + } + else + { + PreferCloserAggressor(aggressor); } - base.OnAggressiveAction(aggressor); + return true; } private bool Act() diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 9d7e77959..f159c70e1 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2735,18 +2735,12 @@ namespace Server.Mobiles aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true)); } - var toldToStandDown = _controlled && _controlMaster != null && Commandable && - BaseAI.IsStandDownOrder(_controlOrder); - - if (StandsDownOnCommand && toldToStandDown) + // The AI owns the retaliation policy; a creature that stands down keeps its flee and acquire state. + if (AIObject?.OnAggressiveAction(aggressor) != true) { - AIObject?.DebugSay("I'm being attacked but my master told me not to fight."); - Warmode = false; return; } - AIObject?.OnAggressiveAction(aggressor); - StopFlee(); ForceReacquire(); @@ -2760,17 +2754,6 @@ namespace Server.Mobiles pl.FinishShield(); } } - - // Only reachable when the pet does not stand down: the orders above returned early. - if (aggressor.ChangingCombatant && toldToStandDown) - { - IssueOrder(OrderType.Attack, null, aggressor); - } - else if (Combatant == null && !BardPacified) - { - Warmode = true; - Combatant = aggressor; - } } public override bool OnMoveOver(Mobile m) diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index 7310c104e..9a7c08ab4 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -31,6 +31,7 @@ description: > - Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` - AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement - `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition +- `BaseAI.OnAggressiveAction(aggressor)` is `bool`, not `void`: the AI owns the retaliation policy (`BaseCreature` no longer sets `Combatant` after it); an early `return;` that refused a fight -> `return false;`, otherwise `return true;`; swap-only overrides call `PreferCloserAggressor(aggressor)` instead of `base` -> same doc § Retaliation - `DamageEntries` is an inline `ref readonly ValueLinkList`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries ## Anti-Patterns diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 85ce6075c..e583364c6 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -231,6 +231,9 @@ class — `OnThink` is for content extras (see the excess-call contract below). creature is dispatched through `Obey()`, an uncontrolled one through `Think()`; an AI that owns both routes them into one decision (`FamiliarAI.Act`). Overriding `IssueOrder` to return a fixed order is how "command immunity" is expressed without bypassing the order machinery. +`OnAggressiveAction(aggressor) -> bool` is the retaliation policy: `BaseCreature` sets no +combatant of its own and runs `StopFlee`/`ForceReacquire` only on `true`, so an AI that +returns `false` has refused the fight outright. ### Fight Modes | FightMode | Behavior | diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index def2ecf48..389c845e5 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -497,6 +497,49 @@ An isolated step (after the creature stood for at least a walk interval) goes ou walk regardless of pace — only a continuing cadence, or a pace faster than the run interpolation, flags run. +## Retaliation: `OnAggressiveAction` Returns `bool` + +RunUO's `BaseAI.OnAggressiveAction` was a `void` hook that only swapped to a closer +aggressor; `BaseCreature.AggressiveAction` then decided the response itself and set +`Combatant` regardless of what the AI had done. ModernUO moves the whole retaliation policy +into the hook — the stand-down verdict for commanded pets, the closer-aggressor swap +(`PreferCloserAggressor`), and the `IssueOrder(Attack)` vs `Combatant =` choice — and +`BaseCreature` only runs its bookkeeping (`StopFlee`, `ForceReacquire`, ethics shield) when +the hook returns `true`: + +```csharp +// RunUO — swap only; BaseCreature picks the combatant afterwards +public override void OnAggressiveAction(Mobile aggressor) +{ + if (m_Mobile.Combatant == null) + { + m_Mobile.Combatant = aggressor; + return; + } + + base.OnAggressiveAction(aggressor); +} + +// ModernUO — the AI owns the response; false = stood down, nothing else fires +public override bool OnAggressiveAction(Mobile aggressor) +{ + if (Mobile.Combatant == null) + { + Mobile.Combatant = aggressor; + } + else + { + PreferCloserAggressor(aggressor); + } + + return true; +} +``` + +An override that used to `return;` early to refuse a fight now returns `false`; calling +`base.OnAggressiveAction` runs the full stock policy, including setting `Combatant`, so an AI +that only wants the closer-aggressor swap calls `PreferCloserAggressor` instead. + ## Target Acquisition: `AcquireOnApproach` Is a Delay RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md index b6c12bfe8..788a7cd78 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -134,6 +134,7 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same | | `PathFollower.Follow(run, range)` | `Follow(range)` | Same | | `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior | +| `BaseAI.OnAggressiveAction(m)` (`void`) | `OnAggressiveAction(m)` (`bool`) | The AI owns the retaliation policy; `false` = stood down; swap-only overrides call `PreferCloserAggressor` | | `m.DamageEntries` (`List`) | `m.DamageEntries` (`ref readonly ValueLinkList`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` | | `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | | | `GetLootingRights(List, int)` | `GetLootingRights(in ValueLinkList, int)` | Callers passing `m.DamageEntries` compile unchanged | From 28c38168bc6ea7e2e387c87b0cc399fd1644b49b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:53:06 -0700 Subject: [PATCH 03/31] test: fix order-dependent enumerator test failures; bump MailKit and Test.Sdk (#2664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `AnchoredItemSerializationTests.LastMovedAndDecayReset_RoundTripExactly` never deleted its deserialized copy. `Item.Deserialize` enters the item into its map's sectors, so the copy stayed live on Felucca at (121,100). `ItemEnumerator_FiltersByBoundsAndOrder`, `ItemByDistanceEnumerator_Bounds_FindsItemsInBounds` and `ItemByDistanceEnumerator_Bounds_MakeBoundsInclusive` scan that area and found an extra item whenever the serialization test ran before them, so they failed depending on test order. The copy is now deleted in a `finally`. - Bump MailKit 4.17.0 → 4.18.0 and Microsoft.NET.Test.Sdk 18.10.0 → 18.10.1. ## Test plan - [x] Probe test: a deserialized copy shows up in `GetItemsInBounds` (reproduced), and after the fix the area is empty - [x] Server.Tests: 907/907 pass - [x] UOContent.Tests: 1126 pass, 2 skipped - [x] Solution builds with 0 errors --- Projects/Server.Tests/Server.Tests.csproj | 2 +- .../AnchoredItemSerializationTests.cs | 35 ++++++++++++------- .../UOContent.Tests/UOContent.Tests.csproj | 2 +- Projects/UOContent/UOContent.csproj | 2 +- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index b1462ba37..8ae939ce1 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,7 +5,7 @@ Server.Tests - + diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs index 7889c57c8..41444ea56 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -53,24 +53,33 @@ public class AnchoredItemSerializationTests public void LastMovedAndDecayReset_RoundTripExactly() { var item = new Item(0x1F13); - item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); + Item restored = null; - // Sub-minute precision that the old minutes encoding would have destroyed. - var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123); - item.LastMoved = moved; + try + { + item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); - item.RestartDecay(); - var decayReset = item.DecayResetTime; - Assert.NotEqual(default(DateTime), decayReset); + // Sub-minute precision that the old minutes encoding would have destroyed. + var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123); + item.LastMoved = moved; - var bytes = SerializeItem(item); + item.RestartDecay(); + var decayReset = item.DecayResetTime; + Assert.NotEqual(default(DateTime), decayReset); - var restored = new Item((Serial)0x7ffff123u); - restored.Deserialize(new BufferReader(bytes)); + var bytes = SerializeItem(item); - Assert.Equal(moved, restored.LastMoved); - Assert.Equal(decayReset, restored.DecayResetTime); + // Deserialize enters the copy into the map's sectors, so it must be deleted too. + restored = new Item((Serial)0x7ffff123u); + restored.Deserialize(new BufferReader(bytes)); - item.Delete(); + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(decayReset, restored.DecayResetTime); + } + finally + { + item.Delete(); + restored?.Delete(); + } } } diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 1dc4440ae..b8c675127 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,7 +4,7 @@ Debug;Release;Analyze - + all diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 996ec9514..7a2800f0b 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -42,7 +42,7 @@ - + From 6b910ff0891135158d5ca8ab28dbd549874acd7b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:13:34 -0700 Subject: [PATCH 04/31] fix(ai): guarding pets never target their owner or the owner's pets (#2665) Supersedes #2657 (rebased onto #2662, which the original branch conflicted with). Credit to @MithrilHammer for the diagnosis, the fix and the regression tests; their three commits are kept as authored. Fixes #2656. ## Root cause When a guarding pet's combatant hides or becomes invalid, its AI calls `AcquireFocusMob` to find a new target. A controlled creature with no `ControlTarget` (Guard normally has none) fell out of `HandleControlled` and into the wild-creature scan, where `IsEnemy` treats any player as an enemy. With `FightMode.Closest`, the owner won. The fall-through dates to #2232. RunUO never let a controlled creature reach the wild scan. ## Changes - `AcquireFocusMob`: a controlled creature returns whatever `HandleControlled` decides. Guard uses `FindGuardTarget`, Attack uses its `ControlTarget`, and every other order acquires nothing. - `FindGuardTarget` skips guard allies: the master and creatures the master controls, including controlled summons. Uncontrolled summons (energy vortex, blade spirits) attack their caster, so a guard still defends against them, as in RunUO. - Friendly fire: `BaseAI.OnAggressiveAction` stands a guard down when an ally is the aggressor. `BaseCreature.Combatant` refuses an ally while guarding, because `Mobile.AggressiveAction` assigns the first aggressor as `Combatant` before any retaliation policy runs. The veto sits on `Combatant` rather than `CanBeHarmful`, so what a pet can harm doesn't depend on its current order. An explicit Attack order and bard provocation can still target an ally. ## Tests 16 regression cases in `GuardFriendlyFireTests` (skipped without client map data), including a real `WhiteWyrm` reproduction. `UOContent.Tests`: 1142 passed, 2 skipped, 0 failed on Windows. --- .../Mobiles/AI/GuardFriendlyFireTests.cs | 226 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 31 ++- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 15 +- Projects/UOContent/Mobiles/BaseCreature.cs | 20 ++ 4 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs new file mode 100644 index 000000000..8994bd391 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +[Collection("Sequential UOContent Tests")] +public class GuardFriendlyFireTests : IDisposable +{ + private readonly List _created = []; + + private (PlayerMobile owner, PetTestStub pet) Spawn() + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + var z = Map.Felucca.GetAverageZ(1500, 1600); + var owner = new PlayerMobile(); + owner.MoveToWorld(new Point3D(1500, 1600, z), Map.Felucca); + _created.Add(owner); + var pet = OtherPet(owner, new Point3D(1499, 1600, z)); + pet.IssueOrder(OrderType.Guard, owner); + pet.AIObject.AITimer.Stop(); + return (owner, pet); + } + + private PetTestStub OtherPet(PlayerMobile owner, Point3D location) + { + var pet = new PetTestStub(); + pet.MoveToWorld(location, owner.Map); + pet.SetControlMaster(owner); + pet.AIObject.AITimer.Stop(); + _created.Add(pet); + return pet; + } + + [SkippableFact] + public void GuardFallbackDoesNotAcquireItsOwner() + { + var (owner, pet) = Spawn(); + Assert.Null(pet.ControlTarget); // speech dispatch clears this; guard must not fall through to wild acquisition. + Assert.True(pet.CanSee(owner)); + Assert.True(pet.InLOS(owner)); + pet.NextReacquireTime = Core.TickCount - 1; + Assert.False(pet.AIObject.AcquireFocusMob(pet.RangePerception, pet.FightMode, false, false, true)); + Assert.Null(pet.FocusMob); + Assert.Null(pet.Combatant); + } + + [SkippableFact] + public void GuardDropsStaleCombatAgainstAnotherPetOfItsOwner() + { + var (owner, pet) = Spawn(); + var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + pet.IssueOrder(OrderType.Attack, owner, sibling); + Assert.Same(sibling, pet.Combatant); + pet.IssueOrder(OrderType.Guard, owner); + pet.AIObject.DoOrderGuard(); + Assert.Null(pet.Combatant); + Assert.Null(pet.FocusMob); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [SkippableFact] + public void GuardDoesNotRetaliateAgainstOwnerFriendlyDamage() + { + var (owner, pet) = Spawn(); + pet.AggressiveAction(owner, false); + Assert.Null(pet.Combatant); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [SkippableFact] + public void GuardDoesNotRetaliateAgainstSiblingFriendlyDamage() + { + var (owner, pet) = Spawn(); + var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + pet.AggressiveAction(sibling, false); + Assert.Null(pet.Combatant); + } + + [SkippableFact] + public void GuardReacquiresAnEnemyAttackingItsOwner() + { + var (owner, pet) = Spawn(); + var enemy = new PetTestStub(); + _created.Add(enemy); + enemy.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + enemy.AIObject.AITimer.Stop(); + enemy.Combatant = owner; + Assert.Same(owner, enemy.Combatant); + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(enemy, pet.FocusMob); + } + + [SkippableFact] + public void GuardStillDefendsAgainstAnotherPlayersPet() + { + var (owner, pet) = Spawn(); + var enemyOwner = new PlayerMobile(); + _created.Add(enemyOwner); + enemyOwner.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map); + var enemy = OtherPet(enemyOwner, new Point3D(1498, 1600, owner.Z)); + enemy.IssueOrder(OrderType.Attack, enemyOwner, owner); + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(enemy, pet.FocusMob); + } + + [SkippableFact] + public void GuardIgnoresSiblingInOwnersAggressorHistory() + { + var (owner, pet) = Spawn(); + var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + owner.AggressiveAction(sibling, false); + Assert.Contains(owner.Aggressors, entry => entry.Attacker == sibling); + Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Null(pet.FocusMob); + } + + [SkippableFact] + public void GuardIgnoresOwnControlledSummonAttackingOwner() + { + var (owner, pet) = Spawn(); + var summon = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + summon.Summoned = true; + summon.SummonMaster = owner; + summon.Combatant = owner; + Assert.Same(owner, summon.Combatant); + pet.AggressiveAction(summon, false); + Assert.Null(pet.Combatant); + Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Null(pet.FocusMob); + } + + [SkippableFact] + public void GuardDefendsAgainstOwnUncontrolledSummonAttackingOwner() + { + var (owner, pet) = Spawn(); + var vortex = new PetTestStub { Summoned = true, SummonMaster = owner }; + _created.Add(vortex); + vortex.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + vortex.AIObject.AITimer.Stop(); + vortex.Combatant = owner; + Assert.Same(owner, vortex.Combatant); + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(vortex, pet.FocusMob); + } + + [SkippableFact] + public void FriendlyDamageDoesNotInterruptGuardCombat() + { + var (owner, pet) = Spawn(); + var enemy = new PetTestStub(); + _created.Add(enemy); + enemy.MoveToWorld(new Point3D(1496, 1600, owner.Z), owner.Map); + enemy.AIObject.AITimer.Stop(); + pet.Combatant = enemy; + pet.AggressiveAction(owner, false); + Assert.Same(enemy, pet.Combatant); + } + + [SkippableFact] + public void ExplicitAttackStillAcquiresTheCommandedTarget() + { + var (owner, pet) = Spawn(); + var sibling = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); + pet.IssueOrder(OrderType.Attack, owner, sibling); + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(sibling, pet.FocusMob); + } + + [SkippableFact] + public void BardProvocationStillOverridesGuard() + { + var (owner, pet) = Spawn(); + pet.BardProvoked = true; + pet.BardTarget = owner; + Assert.True(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Same(owner, pet.FocusMob); + } + + [SkippableTheory] + [InlineData(OrderType.Follow)] + [InlineData(OrderType.Come)] + [InlineData(OrderType.Stay)] + public void NonCombatOrdersDoNotAcquireTheirFriendlyTarget(OrderType order) + { + var (owner, pet) = Spawn(); + pet.IssueOrder(order, owner, owner); + Assert.False(pet.AIObject.AcquireFocusMob(10, pet.FightMode, false, false, true)); + Assert.Null(pet.FocusMob); + } + + [SkippableFact] + public void ActualWhiteWyrmDoesNotTurnOnOwnerWhenEnemyDisappears() + { + var (owner, _) = Spawn(); + var wyrm = new WhiteWyrm(); + _created.Add(wyrm); + wyrm.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); + wyrm.SetControlMaster(owner); + wyrm.IssueOrder(OrderType.Guard, owner); + wyrm.AIObject.AITimer.Stop(); + var enemy = new PetTestStub(); + _created.Add(enemy); + enemy.MoveToWorld(new Point3D(1497, 1600, owner.Z), owner.Map); + enemy.AIObject.AITimer.Stop(); + wyrm.Combatant = enemy; + enemy.Hidden = true; + wyrm.NextReacquireTime = Core.TickCount - 1; + Assert.IsType(wyrm.AIObject); + wyrm.AIObject.DoActionCombat(); + Assert.NotSame(owner, wyrm.Combatant); + Assert.Null(wyrm.FocusMob); + wyrm.AIObject.DoOrderGuard(); + Assert.Null(wyrm.Combatant); + } + + public void Dispose() + { + foreach (var mobile in _created) + { + mobile.Delete(); + } + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 6e5f1c295..f894a015d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -137,6 +137,12 @@ public abstract partial class BaseAI /// public virtual bool OnAggressiveAction(Mobile aggressor) { + if (Mobile.RefusesGuardTarget(aggressor)) + { + DebugSay("Friendly fire; I keep guarding."); + return false; + } + // Only a creature somebody can command has been told anything; wild creatures rest on None. var toldToStandDown = Mobile.Controlled && Mobile.ControlMaster != null && Mobile.Commandable && IsStandDownOrder(Mobile.ControlOrder); @@ -856,7 +862,19 @@ public abstract partial class BaseAI return false; } - if (HandleBardProvoked() || HandleControlled() || HandleConstantFocus()) + if (HandleBardProvoked()) + { + return true; + } + + // A controlled creature must not fall through to wild target selection when + // its order has no target. Guard in particular normally has no ControlTarget. + if (Mobile.Controlled) + { + return HandleControlled(); + } + + if (HandleConstantFocus()) { return true; } @@ -911,8 +929,17 @@ public abstract partial class BaseAI private bool HandleControlled() { - if (!Mobile.Controlled) + if (Mobile.ControlOrder == OrderType.Guard) { + Mobile.FocusMob = FindGuardTarget(); + return Mobile.FocusMob != null; + } + + // Follow and other non-combat orders may target the owner or a friend. + // Only an explicit attack order supplies a combat target. + if (Mobile.ControlOrder != OrderType.Attack) + { + Mobile.FocusMob = null; return false; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1a166d493..dc605b976 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -769,6 +769,15 @@ public abstract partial class BaseAI } } + // Uncontrolled summons (energy vortex, blade spirits) turn on their caster, so a guard still defends against them. + internal bool IsGuardAlly(Mobile target) + { + var master = Mobile.ControlMaster; + + return target == Mobile || master != null && + (target == master || target is BaseCreature { Controlled: true } creature && creature.ControlMaster == master); + } + /// /// Selects the aggressor closest to the master. The current combatant is kept /// unless a strictly closer one exists. Never mutates order state. @@ -779,12 +788,12 @@ public abstract partial class BaseAI var anchor = controlMaster ?? Mobile; var current = Mobile.Combatant; - var best = current != controlMaster && IsValidCombatant(current) ? current : null; + var best = !IsGuardAlly(current) && IsValidCombatant(current) ? current : null; var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (aggr == best || aggr == Mobile || aggr == controlMaster || + if (aggr == best || IsGuardAlly(aggr) || aggr.IsDeadBondedPet || !aggr.Alive || aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) { @@ -808,7 +817,7 @@ public abstract partial class BaseAI { var aggressor = aggressors[i].Attacker; - if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || + if (aggressor == best || aggressor?.Deleted != false || IsGuardAlly(aggressor) || !aggressor.Alive || aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) { continue; diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f159c70e1..cf55821ca 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -3650,6 +3650,26 @@ namespace Server.Mobiles m?.InvalidateProperties(); } + // A guard never fights its master or the master's other pets. An explicit Attack order and bard + // provocation still may. + public bool RefusesGuardTarget(Mobile target) => + _controlled && _controlOrder == OrderType.Guard && !BardProvoked && AIObject?.IsGuardAlly(target) == true; + + // Mobile.AggressiveAction assigns the first aggressor as Combatant before any retaliation policy runs. + public override Mobile Combatant + { + get => base.Combatant; + set + { + if (value != null && RefusesGuardTarget(value)) + { + return; + } + + base.Combatant = value; + } + } + public override bool CanBeHarmful(Mobile target, bool message, bool ignoreOurBlessedness) { if (target is BaseFactionGuard) From 3eb3f0fb5c080b1882eba5ceb330e307c7ff5730 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:29:20 -0700 Subject: [PATCH 05/31] fix(ai): restore pre-AOS berserk energy vortexes and blade spirits (#2666) #2132 let a player's energy vortex or blade spirits in pre-AOS Felucca target their own caster, gated by `FollowsAcquireRules`. #2232's AI overhaul moved the summon checks into `IsInvalidSummonTarget`, which excluded the caster in every era, and dropped the gate. Since then `FollowsAcquireRules` has had no readers and summons have never turned on their caster. ## Changes - `IsInvalidSummonTarget` reads `FollowsAcquireRules` again. A summon that follows the acquire rules spares its caster and anyone the caster's harmful spells would spare. The vortex and blade spirits don't follow them in pre-AOS Felucca when a player summoned them. - The animated-dead restrictions (no players, no other animated dead, no pets) apply to every animated summon regardless of that setting. - `AcquireNewFocusMob` sets a berserk summon's caster aside and targets them only when nobody else is available. #2132 meant to prefer others, but its check only worked when the caster was scanned before the other candidates, and the vortex's Int-plus-Magery ranking usually puts a nearby caster first. - The AOS summon-target check no longer dereferences a missing `SummonMaster`. A summon whose caster was deleted before a reload comes back without one. Controlled summons are unaffected: since #2665 they target through `HandleControlled` and never reach this scan. ## Tests `SummonAcquireRulesTests` has 6 cases, skipped without client map data. Pre-AOS Felucca: a vortex and blade spirits each target their caster when the caster is alone, and a vortex targets a bystander even when the caster ranks higher. Pre-AOS Trammel and AOS Felucca: the caster is still spared. An AOS scan next to a summon with no master doesn't throw. `UOContent.Tests`: 1148 passed, 2 skipped, 0 failed on Windows. --- .../Mobiles/AI/SummonAcquireRulesTests.cs | 113 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 39 ++++-- Projects/UOContent/Mobiles/BaseCreature.cs | 4 + .../Monsters/Misc/Melee/BladeSpirits.cs | 3 +- .../Monsters/Misc/Melee/EnergyVortex.cs | 3 +- 5 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs new file mode 100644 index 000000000..7bd3b7c70 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +[Collection("Sequential UOContent Tests")] +public class SummonAcquireRulesTests : IDisposable +{ + private readonly List _created = []; + private readonly Expansion _previous = Core.Expansion; + + private PlayerMobile Player(Map map, int x, int intel) + { + var player = new PlayerMobile { Player = true, RawInt = intel }; + player.MoveToWorld(new Point3D(x, 1600, map.GetAverageZ(x, 1600)), map); + _created.Add(player); + return player; + } + + private BaseCreature Summon(BaseCreature summon, Mobile caster, int x) + { + _created.Add(summon); + summon.Summoned = true; + summon.SummonMaster = caster; + summon.MoveToWorld(new Point3D(x, 1600, caster.Map.GetAverageZ(x, 1600)), caster.Map); + summon.AIObject.AITimer.Stop(); + return summon; + } + + private static bool Acquire(BaseCreature creature) + { + creature.NextReacquireTime = Core.TickCount - 1; + return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true); + } + + [SkippableTheory] + [InlineData(true)] + [InlineData(false)] + public void PreAosFeluccaVortexTurnsOnItsCaster(bool bladeSpirits) + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core.Expansion = Expansion.UOR; + + var caster = Player(Map.Felucca, 1500, 100); + var summon = Summon(bladeSpirits ? new BladeSpirits() : new EnergyVortex(), caster, 1499); + + Assert.True(Acquire(summon)); + Assert.Same(caster, summon.FocusMob); + } + + [SkippableFact] + public void PreAosFeluccaVortexPrefersAnyoneOverItsCaster() + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core.Expansion = Expansion.UOR; + + // The caster outranks the bystander on the vortex's int-over-distance ranking. + var caster = Player(Map.Felucca, 1500, 100); + var bystander = Player(Map.Felucca, 1496, 10); + var vortex = Summon(new EnergyVortex(), caster, 1499); + + Assert.True(Acquire(vortex)); + Assert.Same(bystander, vortex.FocusMob); + } + + [SkippableTheory] + [InlineData(Expansion.UOR, 1)] // Trammel + [InlineData(Expansion.AOS, 0)] // Felucca + public void VortexSparesItsCasterOutsidePreAosFelucca(Expansion expansion, int mapIndex) + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core.Expansion = expansion; + + var caster = Player(Map.Maps[mapIndex], 1500, 100); + var vortex = Summon(new EnergyVortex(), caster, 1499); + + Assert.False(Acquire(vortex)); + Assert.Null(vortex.FocusMob); + } + + [SkippableFact] + public void MasterlessSummonDoesNotBreakAcquisition() + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core.Expansion = Expansion.AOS; + + var player = Player(Map.Felucca, 1500, 100); + var hunter = new PetTestStub(); + _created.Add(hunter); + hunter.MoveToWorld(new Point3D(1499, 1600, player.Z), player.Map); + hunter.AIObject.AITimer.Stop(); + + var masterless = new PetTestStub { Summoned = true }; + _created.Add(masterless); + masterless.MoveToWorld(new Point3D(1498, 1600, player.Z), player.Map); + masterless.AIObject.AITimer.Stop(); + + Assert.True(Acquire(hunter)); + } + + public void Dispose() + { + Core.Expansion = _previous; + + foreach (var mobile in _created) + { + mobile.Delete(); + } + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index f894a015d..64eecb50e 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -996,6 +996,7 @@ public abstract partial class BaseAI Mobile enemySummonMob = null; var val = double.MinValue; var enemySummonVal = double.MinValue; + Mobile summonMaster = null; foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) { @@ -1005,14 +1006,24 @@ public abstract partial class BaseAI } var bc = m as BaseCreature; - var pm = m as PlayerMobile; - if (IsInvalidSummonTarget(m, bc, pm) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe) - || IsInvalidFightModeTarget(m, acqType, bc)) + if (IsInvalidSummonTarget(m, bc) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe) + || IsInvalidFightModeTarget(m, acqType, bc)) { continue; } + // Only a summon that ignores the acquire rules gets here with its caster; anyone else comes first. + if (m == Mobile.SummonMaster) + { + if (Mobile.InLOS(m)) + { + summonMaster = m; + } + + continue; + } + var theirVal = Mobile.GetFightModeRanking(m, acqType, bPlayerOnly); if (theirVal > val && Mobile.InLOS(m)) @@ -1020,15 +1031,14 @@ public abstract partial class BaseAI newFocusMob = m; val = theirVal; } - else if (Core.AOS && theirVal > enemySummonVal - && Mobile.InLOS(m) && bc?.Summoned == true && bc.Controlled != true) + else if (Core.AOS && theirVal > enemySummonVal && Mobile.InLOS(m) && bc is { Summoned: true, Controlled: false }) { enemySummonMob = m; enemySummonVal = theirVal; } } - Mobile.FocusMob = newFocusMob ?? enemySummonMob; + Mobile.FocusMob = newFocusMob ?? enemySummonMob ?? summonMaster; return Mobile.FocusMob != null; } @@ -1036,21 +1046,28 @@ public abstract partial class BaseAI m.Deleted || m.Blessed || m == Mobile || m is BaseFamiliar || !m.Alive || m.IsDeadBondedPet || m.AccessLevel > AccessLevel.Player || bPlayerOnly && !m.Player || !Mobile.CanSee(m); - private bool IsInvalidSummonTarget(Mobile m, BaseCreature bc, PlayerMobile pm) + private bool IsInvalidSummonTarget(Mobile m, BaseCreature bc) { + // A summon whose caster was deleted before a reload comes back with no master. if (Core.AOS && bc?.Summoned == true && - (bc.SummonMaster == Mobile || !bc.SummonMaster.Player && IsHostile(bc.SummonMaster))) + (bc.SummonMaster == Mobile || bc.SummonMaster is { Player: false } summonMaster && IsHostile(summonMaster))) { return true; } - if (!Mobile.Summoned || Mobile.SummonMaster == null) + var master = Mobile.SummonMaster; + + if (!Mobile.Summoned || master == null) { return false; } - return m == Mobile.SummonMaster || !SpellHelper.ValidIndirectTarget(Mobile.SummonMaster, m) || - Mobile.IsAnimatedDead && (pm != null || bc?.IsAnimatedDead == true || bc?.Controlled == true); + if (Mobile.IsAnimatedDead && (m.Player || bc?.IsAnimatedDead == true || bc?.Controlled == true)) + { + return true; + } + + return Mobile.FollowsAcquireRules && (m == master || !SpellHelper.ValidIndirectTarget(master, m)); } private bool IsInvalidFactionTarget(Mobile m, bool bFacFriend, bool bFacFoe) => diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index cf55821ca..119446f27 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -920,6 +920,10 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public bool IsPrisoner { get; set; } + /// + /// When false, a summon may target its own caster and anyone the caster's harmful spells would spare. + /// It still prefers any other target over the caster. + /// public virtual bool FollowsAcquireRules => true; public virtual Faction FactionAllegiance => null; diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs index a0966eaf4..455418563 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs @@ -55,7 +55,8 @@ namespace Server.Mobiles public override bool BleedImmune => true; public override Poison PoisonImmune => Poison.Lethal; - public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || Map != Map.Felucca; + public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || + (Map?.Rules & MapRules.HarmfulRestrictions) != 0; public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) => (m.Str + m.Skills.Tactics.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0); diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs index 3bb2e69fa..efa78c6ba 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs @@ -63,7 +63,8 @@ namespace Server.Mobiles public override bool BleedImmune => true; public override Poison PoisonImmune => Poison.Lethal; - public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || Map != Map.Felucca; + public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || + (Map?.Rules & MapRules.HarmfulRestrictions) != 0; public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) => (m.Int + m.Skills.Magery.Value) / Math.Max(this.GetDistanceToSqrt(m), 1.0); From 00688a04cf1394c4e579acd63d771e78c72d10c3 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:42:38 -0700 Subject: [PATCH 06/31] fix(ai): guard zero-int detection cooldown and duplicate pet friends (#2667) Supersedes #2658 (rebased onto main; edits by maintainers were not enabled on the original branch). Credit to @MithrilHammer for both fixes and the regression tests; their three commits are kept as authored. Addresses two items from #2627: - The detect-hidden cooldown clamps its divisor to at least one. A creature whose effective intelligence reaches zero now uses the existing capped cooldown instead of throwing. - Pet friends are unique. `AddPetFriend` ignores an existing friend, so one removal revokes the membership. On top of that, `AddPetFriend` and `RemovePetFriend` now go through the generated `AddToFriends` / `RemoveFromFriends`, which mark the creature dirty. `Friends` goes back to null when the last friend is removed, and removing a non-friend no longer touches the list. Duplicates already stored in saves are not migrated. ## Tests `DetectHiddenTimerTests` (7 cases) drives the real AI timer and checks cooldown bounds and retry timing. `PetFriendMembershipTests` (3 cases) covers duplicate adds, removal, and re-adding without disturbing other friends. `UOContent.Tests`: 1158 passed, 2 skipped, 0 failed on Windows. --- .../Mobiles/AI/DetectHiddenTimerTests.cs | 95 +++++++++++++++++++ .../Mobiles/AI/PetFriendMembershipTests.cs | 82 ++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 19 +++- 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs new file mode 100644 index 000000000..383e2e103 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/DetectHiddenTimerTests.cs @@ -0,0 +1,95 @@ +using System; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +[Collection("Sequential UOContent Tests")] +public class DetectHiddenTimerTests +{ + private sealed class DetectingCreature : PetTestStub + { + public override bool PlayerRangeSensitive => false; + } + + private sealed class DetectingAI : BaseAI + { + public int Detections { get; private set; } + + public DetectingAI(BaseCreature creature) : base(creature) + { + } + + public override bool Think() => true; + + public override void DetectHidden() => Detections++; + } + + [SkippableTheory] + [InlineData(0, 108000, 132000)] + [InlineData(1, 108000, 132000)] + [InlineData(100, 108000, 132000)] + [InlineData(250, 108000, 132000)] + [InlineData(251, 107100, 130900)] + [InlineData(1000, 27000, 33000)] + [InlineData(30000, 900, 1100)] + public void DetectHidden_SchedulesBoundedCooldown_AndWaitsBeforeRetry( + int intelligence, long minDelay, long maxDelay) + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + Core._tickCount = 0; + Timer.Init(0); + var creature = new DetectingCreature(); + DetectingAI ai = null; + + try + { + creature.Int = Math.Max(1, intelligence); + if (intelligence == 0) + { + creature.AddStatMod(new StatMod(StatType.Int, "DetectHiddenZeroInt", -1, TimeSpan.Zero)); + } + + Assert.Equal(intelligence, creature.Int); + creature.Skills.DetectHidden.Base = 100; + creature.MoveToWorld(new Point3D(1500, 1600, Map.Felucca.GetAverageZ(1500, 1600)), Map.Felucca); + creature.AIObject.AITimer.Stop(); + ai = new DetectingAI(creature); + ai.AITimer.Activate(); + + AdvanceUntil(() => ai.Detections > 0, 1024); + Assert.Equal(1, ai.Detections); + Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay); + Assert.True(ai.AITimer.Running); + + var deadline = ai._nextDetectHidden; + while (Core.TickCount + 8 < deadline) + { + Core._tickCount += 8; + Timer.Slice(Core.TickCount); + } + + Assert.Equal(1, ai.Detections); + AdvanceUntil(() => ai.Detections > 1, 1024); + Assert.Equal(2, ai.Detections); + Assert.InRange(ai._nextDetectHidden - Core.TickCount, minDelay, maxDelay); + } + finally + { + ai?.AITimer.Stop(); + creature.Delete(); + } + } + + private static void AdvanceUntil(Func condition, int milliseconds) + { + for (var elapsed = 0; !condition() && elapsed < milliseconds; elapsed += 8) + { + Core._tickCount += 8; + Timer.Slice(Core.TickCount); + } + + Assert.True(condition(), "The AI timer did not reach the expected detection."); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs new file mode 100644 index 000000000..ddebd79f8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetFriendMembershipTests.cs @@ -0,0 +1,82 @@ +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +[Collection("Sequential UOContent Tests")] +public class PetFriendMembershipTests +{ + [Fact] + public void AddPetFriend_Twice_StoresOneMembership() + { + var pet = new PetTestStub(); + var friend = new PlayerMobile(); + + try + { + pet.AddPetFriend(friend); + pet.AddPetFriend(friend); + + Assert.Single(pet.Friends); + Assert.True(pet.IsPetFriend(friend)); + } + finally + { + pet.Delete(); + friend.Delete(); + } + } + + [Fact] + public void RemovePetFriend_AfterRepeatedAdd_RevokesMembership() + { + var pet = new PetTestStub(); + var friend = new PlayerMobile(); + + try + { + pet.AddPetFriend(friend); + pet.AddPetFriend(friend); + pet.RemovePetFriend(friend); + + Assert.False(pet.IsPetFriend(friend)); + Assert.Null(pet.Friends); + } + finally + { + pet.Delete(); + friend.Delete(); + } + } + + [Fact] + public void RemoveAndReadd_LeavesOtherFriendsUnchanged() + { + var pet = new PetTestStub(); + var first = new PlayerMobile(); + var second = new PlayerMobile(); + + try + { + pet.AddPetFriend(first); + pet.AddPetFriend(second); + pet.RemovePetFriend(first); + + Assert.False(pet.IsPetFriend(first)); + Assert.True(pet.IsPetFriend(second)); + Assert.Single(pet.Friends); + + pet.AddPetFriend(first); + Assert.True(pet.IsPetFriend(first)); + Assert.True(pet.IsPetFriend(second)); + Assert.Equal(2, pet.Friends.Count); + } + finally + { + pet.Delete(); + first.Delete(); + second.Delete(); + } + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index e11268e87..f2cfceb0e 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -225,7 +225,7 @@ public sealed class AITimer : Timer private void CacheDetectHiddenDelays() { - var delay = Math.Min(30000 / _owner.Mobile.Int, 120); + var delay = Math.Min(30000 / Math.Max(1, _owner.Mobile.Int), 120); _detectHiddenMinDelay = delay * 900; // 26s to 108s _detectHiddenMaxDelay = delay * 1100; // 32s to 132s } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 119446f27..6d8c22d0d 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -4304,16 +4304,27 @@ namespace Server.Mobiles public virtual void AddPetFriend(Mobile m) { + if (IsPetFriend(m)) + { + return; + } + Friends ??= []; - Friends.Add(m); - this.MarkDirty(); + AddToFriends(m); } public virtual void RemovePetFriend(Mobile m) { - if (Friends?.Remove(m) == true) + if (Friends == null) { - this.MarkDirty(); + return; + } + + RemoveFromFriends(m); + + if (Friends.Count == 0) + { + Friends = null; } } From 08dab47413b566252837b0e388a4f7789e5c657e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:45:48 -0700 Subject: [PATCH 07/31] fix: check Controlled before reading ControlMaster (#2668) Readers of `ControlMaster` that act on "this creature is a player's pet" now also require `Controlled`. Nothing sets a master on an uncontrolled creature today, so behavior does not change, except that the ML notoriety branch was the one read with nothing in front of it. The guard keeps these readers correct if a creature ever keeps a master without being controlled (a suspended-control state such as a charm or turn-pet ability, or folding SummonMaster into one master field). - Notoriety (ML): a creature only takes its master's notoriety while controlled - BaseCreature.AggressiveAction: the aggressor link to the master only applies to controlled pets - PlayerMobile.AutoStablePets: uncontrolled summons (blade spirits, energy vortexes) are skipped - CrystalCaveBarrier: only a controlled pet passes on its owner's quest progress - Discordance: the own-pet exception only applies to a controlled pet - Dismount (ML): the remount block only goes to the owner of a controlled pet Also removes the "Summons from monsters can attack players" branch in Mobile_AllowHarmful. It has been unreachable since #2000: a creature with no player master returns from the NPC check above it. Tests: UOContent.Tests 1158 passed. --- .../Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs | 2 +- Projects/UOContent/Items/Weapons/Abilities/Dismount.cs | 2 +- Projects/UOContent/Misc/Notoriety.cs | 7 +------ Projects/UOContent/Mobiles/BaseCreature.cs | 2 +- Projects/UOContent/Mobiles/PlayerMobile.cs | 2 +- Projects/UOContent/Skills/Discordance.cs | 2 +- 6 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Projects/UOContent/Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs b/Projects/UOContent/Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs index d3f7d4ef5..170cbcf91 100644 --- a/Projects/UOContent/Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs +++ b/Projects/UOContent/Engines/Quests/Dark Tides/Items/CrystalCaveBarrier.cs @@ -20,7 +20,7 @@ public partial class CrystalCaveBarrier : Item if (m is BaseCreature creature) { - mob = creature.ControlMaster; + mob = creature.Controlled ? creature.ControlMaster : null; } if (mob is not PlayerMobile pm) diff --git a/Projects/UOContent/Items/Weapons/Abilities/Dismount.cs b/Projects/UOContent/Items/Weapons/Abilities/Dismount.cs index 3121b9516..df50f1449 100644 --- a/Projects/UOContent/Items/Weapons/Abilities/Dismount.cs +++ b/Projects/UOContent/Items/Weapons/Abilities/Dismount.cs @@ -102,7 +102,7 @@ public class Dismount : WeaponAbility { playerMobile.SetMountBlock(BlockMountType.DismountRecovery, RemountDelay, true); } - else if (Core.ML && attacker is BaseCreature { ControlMaster: PlayerMobile pm }) + else if (Core.ML && attacker is BaseCreature { Controlled: true, ControlMaster: PlayerMobile pm }) { pm.SetMountBlock(BlockMountType.DismountRecovery, RemountDelay, false); } diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs index 4416b09b5..27f54c18f 100644 --- a/Projects/UOContent/Misc/Notoriety.cs +++ b/Projects/UOContent/Misc/Notoriety.cs @@ -233,11 +233,6 @@ namespace Server.Misc return false; // Cannot harm other controlled mobiles from players } - if (pmFrom == null && bcFrom != null && bcFrom.Summoned && target.Player) - { - return true; // Summons from monsters can attack players - } - if (target.Player) { return false; // Cannot harm other players @@ -422,7 +417,7 @@ namespace Server.Misc return Notoriety.CanBeAttacked; } - master = bcTarg.ControlMaster; + master = bcTarg.Controlled ? bcTarg.ControlMaster : null; if (Core.ML && master != null) { diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 6d8c22d0d..18743579d 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2734,7 +2734,7 @@ namespace Server.Mobiles { base.AggressiveAction(aggressor, criminal); - if (ControlMaster != null && NotorietyHandlers.CheckAggressor(ControlMaster.Aggressors, aggressor)) + if (Controlled && ControlMaster != null && NotorietyHandlers.CheckAggressor(ControlMaster.Aggressors, aggressor)) { aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true)); } diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 39be0e10a..cf7fc000b 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -3517,7 +3517,7 @@ namespace Server.Mobiles foreach (var follower in allFollowers) { - if (follower is not BaseCreature pet || pet.ControlMaster == null) + if (follower is not BaseCreature { Controlled: true } pet || pet.ControlMaster == null) { continue; } diff --git a/Projects/UOContent/Skills/Discordance.cs b/Projects/UOContent/Skills/Discordance.cs index 15b7ed118..14c5a7ee7 100644 --- a/Projects/UOContent/Skills/Discordance.cs +++ b/Projects/UOContent/Skills/Discordance.cs @@ -141,7 +141,7 @@ namespace Server.SkillHandlers { if (targ == from || targ is BaseCreature bc && (bc.BardImmune || !from.CanBeHarmful(bc, false)) && - bc.ControlMaster != from) + !(bc.Controlled && bc.ControlMaster == from)) { from.SendLocalizedMessage(1049535); // A song of discord would have no effect on that. } From 92ed9c5456e4b38c6d48f3204904877098ff0f7b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:43:37 -0700 Subject: [PATCH 08/31] fix: player corpse notoriety ignores the owner's body at death (#2669) CorpseNotoriety returned Murderer for a player corpse whenever the corpse body was a monster body. RunUO's check was `body.IsMonster && IsSummoned(owner)`, which is never true for a player; the second half was lost when the branch was simplified (#134). Players who died while polymorphed or in a necromancer form left a corpse with the wrong notoriety. Player corpses also check murderer and criminal status before guild standing again, matching RunUO and MobileNotoriety. Tests: UOContent.Tests 1162 passed (4 new CorpseNotorietyTests). --- .../Tests/Misc/CorpseNotorietyTests.cs | 40 +++++++++++++++++++ Projects/UOContent/Misc/Notoriety.cs | 36 +++++++++-------- 2 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs diff --git a/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs new file mode 100644 index 000000000..a46c56003 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Misc/CorpseNotorietyTests.cs @@ -0,0 +1,40 @@ +using Server; +using Server.Items; +using Server.Misc; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class CorpseNotorietyTests +{ + [Theory] + [InlineData(0, Notoriety.Innocent)] // Human body + [InlineData(746, Notoriety.Innocent)] // Horrific Beast, a monster body + [InlineData(0, Notoriety.Murderer, 5)] + [InlineData(746, Notoriety.Murderer, 5)] + public void PlayerCorpseNotorietyIgnoresTransformedBody(int bodyMod, int expected, int kills = 0) + { + var victim = new PlayerMobile(World.NewMobile); + victim.DefaultMobileInit(); + victim.Kills = kills; + victim.BodyMod = bodyMod; + + var looter = new PlayerMobile(World.NewMobile); + looter.DefaultMobileInit(); + + var corpse = new Corpse(victim, []); + + try + { + Assert.Equal(expected, NotorietyHandlers.CorpseNotoriety(looter, corpse)); + } + finally + { + corpse.Delete(); + victim.Delete(); + looter.Delete(); + } + } +} diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs index 27f54c18f..fc57c3462 100644 --- a/Projects/UOContent/Misc/Notoriety.cs +++ b/Projects/UOContent/Misc/Notoriety.cs @@ -270,7 +270,24 @@ namespace Server.Misc return Notoriety.CanBeAttacked; } - Body body = target.Amount; + // BaseCreatures are deleted on death, so target.Owner is null after a server restart. + // The OwnerWasBaseCreature flag is the persisted snapshot that survives the live mobile. + var creature = target.Owner as BaseCreature; + var ownerWasCreature = target.OwnerWasBaseCreature || creature != null; + + // A player's murderer and criminal status outrank guild standing, as on the live mobile. + if (!ownerWasCreature) + { + if (target.Murderer) + { + return Notoriety.Murderer; + } + + if (target.Criminal && (target.Map?.Rules & MapRules.HarmfulRestrictions) == 0) + { + return Notoriety.Criminal; + } + } var sourceGuild = GetGuildFor(source.Guild as Guild, source); var targetGuild = GetGuildFor(target.Guild, target.Owner); @@ -292,11 +309,7 @@ namespace Server.Misc } } - // BaseCreatures are deleted on death, so target.Owner is null after a server restart. - // The OwnerWasBaseCreature flag is the persisted snapshot that survives the live mobile. - var creature = target.Owner as BaseCreature; - - if (target.OwnerWasBaseCreature || creature != null) + if (ownerWasCreature) { if (srcFaction != null && trgFaction != null && srcFaction != trgFaction && source.Map == Faction.Facet) { @@ -309,6 +322,7 @@ namespace Server.Misc } var actual = Notoriety.CanBeAttacked; + Body body = target.Amount; if (target.Murderer || body.IsMonster && target.OwnerWasSummoned || target.OwnerWasAnimatedDead) { @@ -333,16 +347,6 @@ namespace Server.Misc return Notoriety.Innocent; } - if (target.Murderer || body.IsMonster) - { - return Notoriety.Murderer; - } - - if (target.Criminal && (target.Map?.Rules & MapRules.HarmfulRestrictions) == 0) - { - return Notoriety.Criminal; - } - if (srcFaction != null && trgFaction != null && srcFaction != trgFaction && source.Map == Faction.Facet) { for (var i = 0; i < list.Count; ++i) From 77bd0d9a64c71c554155448a743f71c7f72c5ed9 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:01:40 -0700 Subject: [PATCH 09/31] fix: Adds nullability support and fixes crash bugs in serialization generator (#2671) ### Summary Bumps serialization generator to add nullability support and to fix various bugs. --- .config/dotnet-tools.json | 2 +- Projects/Server/Server.csproj | 4 ++-- Projects/UOContent/UOContent.csproj | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3e22e2e66..3f6d9ae52 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "4.1.0", + "version": "4.2.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index ad1a069c1..3730f1dc8 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -39,8 +39,8 @@ - - + + diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 7a2800f0b..5789eabc6 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -50,8 +50,8 @@ - - + + From 05b6ca9d5d29b05bcc9b5e6e3de46b8e3dd3c160 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:37:28 -0700 Subject: [PATCH 10/31] refactor: one master reference behind ControlMaster and SummonMaster (#2670) ## Summary `BaseCreature` stored the pet owner (`ControlMaster`) and the summoner (`SummonMaster`) in two fields. Every flow that manages pets kept them in step by hand: summon, transfer, stable, claim, ball of summoning and GM obey. They now share one `_master` field, and the two properties read it through the Controlled and Summoned flags: ```csharp ControlMaster => Controlled ? master : null SummonMaster => Controlled && !Summoned ? null : master ``` Every existing reader keeps its meaning: - `ControlMaster` is still "the owner". - `SummonMaster` is still "the summoner, controlled or not". Blade spirits, energy vortexes, clones, undead minions and enraged creatures (a summon master without `Summoned`) never become visible through `ControlMaster`, so the ungated readers (ML notoriety, aggressor entries and similar) behave as before. - Setting `SummonMaster` on a plain pet does nothing, instead of quietly replacing the owner. - `SetControlMaster` now keeps both masters in step, so the hand-written `SummonMaster` copies are removed from ball of summoning, GM obey, pet transfer, auto-stable and claim, the animal trainer's stable and claim, and jail stabling. ## Serialization BaseCreature moves to v24 with one serialized master (slot 15); the old `SummonMaster` slot is dropped. - `MigrateFrom(V23Content)` resolves absent fields through the same default-value methods the live reader uses. - A save that carried both masters (a summon traded before ownership followed transfers) resolves to the owner. Legacy pre-codegen streams follow the same rule. Requires `ModernUO.Serialization.Generator` 4.2.0 (#2671). `UOContent.Tests` was still on 4.1.0 and is bumped to match. ## Test plan - [x] `MasterShapes_RoundTrip`: controlled pet, controlled summon, energy vortex, enraged creature - [x] `SummonMaster_OnPlainPet_KeepsTheOwner`: owner and follower counts unchanged - [x] `LegacyV22Stream_DifferingMasters_OwnerWins` - [x] `V23Stream_MigratesToOneMaster_AndDefaultsAbsentFields`: both master slots -> owner; flags past bit 32; absent fields take class defaults - [x] UOContent.Tests (1168 passed, 2 skipped) and Server.Tests (907) green --- .../Mobiles/BaseCreatureSerializationTests.cs | 125 ++++- .../UOContent.Tests/UOContent.Tests.csproj | 2 +- .../Special/Solen Items/BallOfSummoning.cs | 5 - .../Server.Mobiles.BaseCreature.v24.json | 465 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/OnSpeech.cs | 5 - .../Mobiles/AI/BaseAI/TransferItem.cs | 5 - Projects/UOContent/Mobiles/BaseCreature.cs | 236 +++++---- Projects/UOContent/Mobiles/PlayerMobile.cs | 6 - .../Mobiles/Vendors/NPC/AnimalTrainer.cs | 6 - .../Systems/JailSystem/JailSystem.cs | 1 - 10 files changed, 723 insertions(+), 133 deletions(-) create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v24.json diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs index 51a4de573..3b95f452e 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -182,22 +182,50 @@ public class BaseCreatureSerializationTests : IDisposable Assert.Equal(master, copy.LastOwner); } - [Fact] - public void UncontrolledSummon_KeepsItsSummonMaster() + // One serialized master; the Controlled and Summoned flags decide which views expose it. + [Theory] + [InlineData(true, false)] // controlled pet + [InlineData(true, true)] // controlled summon + [InlineData(false, true)] // energy vortex: summoned, never controlled + [InlineData(false, false)] // EnragedCreature: a summon master without Summoned + public void MasterShapes_RoundTrip(bool controlled, bool summoned) { var bc = NewCreature(); var master = NewMaster(); - // Energy vortex-style: summoned with a master, never controlled. - bc.Summoned = true; - bc.SummonMaster = master; + bc.Summoned = summoned; + + if (controlled) + { + bc.SetControlMaster(master); + } + else + { + bc.SummonMaster = master; + } var copy = Load(Snapshot(bc)); - Assert.True(copy.Summoned); - Assert.False(copy.Controlled); - Assert.Equal(master, copy.SummonMaster); - Assert.Null(copy.ControlMaster); + Assert.Equal(controlled, copy.Controlled); + Assert.Equal(summoned, copy.Summoned); + Assert.Equal(controlled ? master : null, copy.ControlMaster); + Assert.Equal(controlled && !summoned ? null : master, copy.SummonMaster); + } + + [Fact] + public void SummonMaster_OnPlainPet_KeepsTheOwner() + { + var bc = NewCreature(); + var owner = NewMaster(); + var other = NewMaster(); + + bc.SetControlMaster(owner); + bc.SummonMaster = other; + + Assert.Equal(owner, bc.ControlMaster); + Assert.Null(bc.SummonMaster); + Assert.Equal(bc.ControlSlots, owner.Followers); + Assert.Equal(0, other.Followers); } [Fact] @@ -418,8 +446,8 @@ public class BaseCreatureSerializationTests : IDisposable Assert.Equal(0.6, copy.CurrentMoveSpeed); // passive mode, inheriting } - // ControlMaster and SummonMaster are independent references: a summon master can - // exist without Summoned (EnragedCreature), and a controlled summon carries both. + // A summon master can exist without Summoned (EnragedCreature), and a controlled summon + // reports the same mobile as both. [Theory] [InlineData(true, true, false, false)] // controlled pet [InlineData(true, true, true, true)] // controlled summon, SummonEnd on the wire @@ -452,4 +480,79 @@ public class BaseCreatureSerializationTests : IDisposable Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1)); } } + + // Before summon ownership followed transfers, a traded summon kept its caster as summon master. + [Fact] + public void LegacyV22Stream_DifferingMasters_OwnerWins() + { + var owner = NewMaster(); + var caster = NewMaster(); + + var copy = LoadLegacyV22(true, owner, true, caster, Core.Now + TimeSpan.FromMinutes(5)); + + Assert.Equal(owner, copy.ControlMaster); + Assert.Equal(owner, copy.SummonMaster); + } + + // v23 bit positions: every save-flagged field in schema order (DefaultAI is not flagged). + private const int V23Controlled = 13; + private const int V23ControlMaster = 14; + private const int V23Summoned = 20; + private const int V23SummonEnd = 21; + private const int V23SummonMaster = 22; + private const int V23RemoveStep = 50; + private const int V23CorpseNameOverride = 52; + + // v23 wrote the owner and the summoner to separate slots; v24 keeps one master. + [Fact] + public void V23Stream_MigratesToOneMaster_AndDefaultsAbsentFields() + { + var owner = NewMaster(); + var caster = NewMaster(); + var summonEnd = Core.Now + TimeSpan.FromMinutes(5); + + var donor = new MobileStub(); + donor.DefaultMobileInit(); + _created.Add(donor); + + var writer = new BufferWriter(true); + donor.Serialize(writer); + + writer.Write(23); // version + writer.Write( + 1UL << V23Controlled | 1UL << V23ControlMaster | 1UL << V23Summoned | 1UL << V23SummonEnd | + 1UL << V23SummonMaster | 1UL << V23RemoveStep | 1UL << V23CorpseNameOverride + ); + writer.WriteEncodedInt((int)AIType.AI_Melee); // DefaultAI + writer.Write(owner); // ControlMaster + writer.WriteAnchoredTime(summonEnd); // SummonEnd + writer.Write(caster); // SummonMaster + writer.WriteEncodedInt(3); // RemoveStep + writer.Write("a migrated corpse"); // CorpseNameOverride + + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var copy = Load(buffer); + + Assert.True(copy.Controlled); + Assert.True(copy.Summoned); + Assert.Equal(owner, copy.ControlMaster); + Assert.Equal(owner, copy.SummonMaster); + Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1)); + Assert.Equal(3, copy.RemoveStep); + Assert.Equal("a migrated corpse", copy.CorpseNameOverride); + + // Absent fields take the class defaults, not default(T). + Assert.Equal(AIType.AI_Melee, copy.AI); + Assert.Equal(-1, copy.HitsMaxSeed); + Assert.Equal(1, copy.ControlSlots); + Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty); + Assert.Equal(100, copy.PhysicalDamage); + Assert.Equal(10, copy.RangeHome); + Assert.Equal(FightMode.Closest, copy.FightMode); + Assert.Equal(0.3, copy.ActiveSpeed); + Assert.Equal(0.6, copy.PassiveSpeed); + Assert.NotNull(copy.Owners); + } } diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index b8c675127..8b83f7992 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -11,7 +11,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index bf0953041..2a881d97f 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -228,11 +228,6 @@ public partial class BallOfSummoning : Item, TranslocationItem { pet.SetControlMaster(from); - if (pet.SummonMaster != null) - { - pet.SummonMaster = from; - } - pet.ControlTarget = from; pet.ControlOrder = OrderType.Follow; diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v24.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v24.json new file mode 100644 index 000000000..b1f4fe2fd --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v24.json @@ -0,0 +1,465 @@ +{ + "version": 24, + "type": "Server.Mobiles.BaseCreature", + "properties": [ + { + "name": "DefaultAI", + "type": "Server.Mobiles.AIType", + "rule": "EnumMigrationRule" + }, + { + "name": "CurrentAI", + "type": "Server.Mobiles.AIType", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "RangePerception", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "RangeFight", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "RangeHome", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Team", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FightMode", + "type": "Server.Mobiles.FightMode", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "ActiveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "PassiveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "CurrentSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActiveMoveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "PassiveMoveSpeed", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Home", + "type": "Server.Point3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "HomeMap", + "type": "Server.Map", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Map" + ] + }, + { + "name": "Controlled", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Master", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ControlTarget", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ControlDest", + "type": "Server.Point3D", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "ControlOrder", + "type": "Server.Mobiles.OrderType", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "MinTameSkill", + "type": "double", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Tamable", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Summoned", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SummonEnd", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "ControlSlots", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Loyalty", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "CurrentWayPoint", + "type": "Server.Items.WayPoint", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HitsMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StamMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ManaMaxSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DamageMin", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DamageMax", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PhysicalResistanceSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyResistSeed", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PhysicalDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Owners", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "usesSaveFlag": true, + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "IsDeadPet", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "IsBonded", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "BondingBegin", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "OwnerAbandonTime", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HasGeneratedLoot", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "IsParagon", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Friends", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "usesSaveFlag": true, + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RemoveIfUntamed", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RemoveStep", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PendingDeleteTimer", + "type": "Server.Timer", + "usesSaveFlag": true, + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "CorpseNameOverride", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs index 7ce0994f6..deda86702 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/OnSpeech.cs @@ -451,11 +451,6 @@ public abstract partial class BaseAI Mobile.SetControlMaster(e.Mobile); - if (Mobile.SummonMaster != null) - { - Mobile.SummonMaster = e.Mobile; - } - return true; } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs index 7eac17ae5..08dcda911 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/TransferItem.cs @@ -156,11 +156,6 @@ internal sealed partial class TransferItem : Item private void TransferPetOwnership(Mobile from, Mobile to) { - if (_creature.SummonMaster != null) - { - _creature.SummonMaster = to; - } - _creature.ClearPetFriends(); _creature.IssueOrder(OrderType.Follow, null, to); _creature.BondingBegin = DateTime.MinValue; diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 18743579d..d37465796 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; -using ModernUO.CodeGeneratedEvents; using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; @@ -135,7 +134,7 @@ namespace Server.Mobiles public int CompareTo(DamageStore ds) => (ds?.m_Damage ?? 0).CompareTo(m_Damage); } - [SerializationGenerator(23, false)] + [SerializationGenerator(24, false)] public abstract partial class BaseCreature : Mobile, IHonorTarget, IQuestGiver { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseCreature)); @@ -446,10 +445,12 @@ namespace Server.Mobiles InvalidateProperties(); } - // Follower bookkeeping brackets the assignment, so the property is hand-written. - private Mobile _controlMaster; + // ControlMaster and SummonMaster are views of this one reference, gated by Controlled and Summoned. + [SerializableField(15, getter: "private", setter: "private")] + [SaveFlag(nameof(ShouldSerializeMaster))] + private Mobile _master; - private bool ShouldSerializeControlMaster() => _controlMaster != null; + private bool ShouldSerializeMaster() => _master != null; [SerializableField(16)] [SaveFlag(nameof(ShouldSerializeControlTarget))] @@ -503,13 +504,8 @@ namespace Server.Mobiles private bool ShouldSerializeSummonEnd() => _summoned; - // Follower bookkeeping brackets the assignment, so the property is hand-written. - private Mobile _summonMaster; - - private bool ShouldSerializeSummonMaster() => _summonMaster != null; - [EncodedInt] - [SerializableField(24)] + [SerializableField(23)] [SaveFlag(nameof(ShouldSerializeControlSlots), nameof(ControlSlotsDefaultValue))] [SerializedCommandProperty(AccessLevel.Administrator)] private int _controlSlots = 1; @@ -519,7 +515,7 @@ namespace Server.Mobiles private int ControlSlotsDefaultValue() => 1; [EncodedInt] - [SerializableField(25, allowFieldChange: nameof(ClampLoyalty))] + [SerializableField(24, allowFieldChange: nameof(ClampLoyalty))] [SaveFlag(nameof(ShouldSerializeLoyalty), nameof(LoyaltyDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _loyalty; @@ -534,7 +530,7 @@ namespace Server.Mobiles return true; } - [SerializableField(26)] + [SerializableField(25)] [SaveFlag(nameof(ShouldSerializeCurrentWayPoint))] [SerializedCommandProperty(AccessLevel.GameMaster)] private WayPoint _currentWayPoint; @@ -542,7 +538,7 @@ namespace Server.Mobiles private bool ShouldSerializeCurrentWayPoint() => _currentWayPoint != null; [EncodedInt] - [SerializableField(27)] + [SerializableField(26)] [SaveFlag(nameof(ShouldSerializeHitsMaxSeed), nameof(HitsMaxSeedDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _hitsMaxSeed = -1; @@ -552,7 +548,7 @@ namespace Server.Mobiles private int HitsMaxSeedDefaultValue() => -1; [EncodedInt] - [SerializableField(28)] + [SerializableField(27)] [SaveFlag(nameof(ShouldSerializeStamMaxSeed), nameof(StamMaxSeedDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _stamMaxSeed = -1; @@ -562,7 +558,7 @@ namespace Server.Mobiles private int StamMaxSeedDefaultValue() => -1; [EncodedInt] - [SerializableField(29)] + [SerializableField(28)] [SaveFlag(nameof(ShouldSerializeManaMaxSeed), nameof(ManaMaxSeedDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _manaMaxSeed = -1; @@ -572,7 +568,7 @@ namespace Server.Mobiles private int ManaMaxSeedDefaultValue() => -1; [EncodedInt] - [SerializableField(30)] + [SerializableField(29)] [SaveFlag(nameof(ShouldSerializeDamageMin), nameof(DamageMinDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _damageMin = -1; @@ -582,7 +578,7 @@ namespace Server.Mobiles private int DamageMinDefaultValue() => -1; [EncodedInt] - [SerializableField(31)] + [SerializableField(30)] [SaveFlag(nameof(ShouldSerializeDamageMax), nameof(DamageMaxDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _damageMax = -1; @@ -592,7 +588,7 @@ namespace Server.Mobiles private int DamageMaxDefaultValue() => -1; [EncodedInt] - [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))] + [SerializableField(31, fieldChanged: nameof(OnResistanceSeedChange))] [SaveFlag(nameof(ShouldSerializePhysicalResistanceSeed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _physicalResistanceSeed; @@ -602,7 +598,7 @@ namespace Server.Mobiles private void OnResistanceSeedChange(int oldValue, int newValue) => UpdateResistances(); [EncodedInt] - [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))] + [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))] [SaveFlag(nameof(ShouldSerializeFireResistSeed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _fireResistSeed; @@ -610,7 +606,7 @@ namespace Server.Mobiles private bool ShouldSerializeFireResistSeed() => _fireResistSeed != 0; [EncodedInt] - [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))] + [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))] [SaveFlag(nameof(ShouldSerializeColdResistSeed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _coldResistSeed; @@ -618,7 +614,7 @@ namespace Server.Mobiles private bool ShouldSerializeColdResistSeed() => _coldResistSeed != 0; [EncodedInt] - [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))] + [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))] [SaveFlag(nameof(ShouldSerializePoisonResistSeed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonResistSeed; @@ -626,7 +622,7 @@ namespace Server.Mobiles private bool ShouldSerializePoisonResistSeed() => _poisonResistSeed != 0; [EncodedInt] - [SerializableField(36, fieldChanged: nameof(OnResistanceSeedChange))] + [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))] [SaveFlag(nameof(ShouldSerializeEnergyResistSeed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _energyResistSeed; @@ -634,7 +630,7 @@ namespace Server.Mobiles private bool ShouldSerializeEnergyResistSeed() => _energyResistSeed != 0; [EncodedInt] - [SerializableField(37)] + [SerializableField(36)] [SaveFlag(nameof(ShouldSerializePhysicalDamage), nameof(PhysicalDamageDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _physicalDamage = 100; @@ -644,7 +640,7 @@ namespace Server.Mobiles private int PhysicalDamageDefaultValue() => 100; [EncodedInt] - [SerializableField(38)] + [SerializableField(37)] [SaveFlag(nameof(ShouldSerializeFireDamage))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _fireDamage; @@ -652,7 +648,7 @@ namespace Server.Mobiles private bool ShouldSerializeFireDamage() => _fireDamage != 0; [EncodedInt] - [SerializableField(39)] + [SerializableField(38)] [SaveFlag(nameof(ShouldSerializeColdDamage))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _coldDamage; @@ -660,7 +656,7 @@ namespace Server.Mobiles private bool ShouldSerializeColdDamage() => _coldDamage != 0; [EncodedInt] - [SerializableField(40)] + [SerializableField(39)] [SaveFlag(nameof(ShouldSerializePoisonDamage))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonDamage; @@ -668,7 +664,7 @@ namespace Server.Mobiles private bool ShouldSerializePoisonDamage() => _poisonDamage != 0; [EncodedInt] - [SerializableField(41)] + [SerializableField(40)] [SaveFlag(nameof(ShouldSerializeEnergyDamage))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _energyDamage; @@ -676,7 +672,7 @@ namespace Server.Mobiles private bool ShouldSerializeEnergyDamage() => _energyDamage != 0; [Tidy] - [SerializableField(42, setter: "private")] + [SerializableField(41, setter: "private")] [SaveFlag(nameof(ShouldSerializeOwners), nameof(OwnersDefaultValue))] private List _owners; @@ -688,13 +684,13 @@ namespace Server.Mobiles private List OwnersDefaultValue() => new(); - [SerializableField(43)] + [SerializableField(42)] [SaveFlag(nameof(ShouldSerializeIsDeadPet))] private bool _isDeadPet; private bool ShouldSerializeIsDeadPet() => _isDeadPet; - [SerializableField(44, fieldChanged: nameof(OnBondedChange))] + [SerializableField(43, fieldChanged: nameof(OnBondedChange))] [SaveFlag(nameof(ShouldSerializeIsBonded))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _isBonded; @@ -703,21 +699,21 @@ namespace Server.Mobiles private void OnBondedChange(bool oldValue, bool newValue) => InvalidateProperties(); - [SerializableField(45)] + [SerializableField(44)] [SaveFlag(nameof(ShouldSerializeBondingBegin))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _bondingBegin; private bool ShouldSerializeBondingBegin() => _bondingBegin != DateTime.MinValue; - [SerializableField(46)] + [SerializableField(45)] [SaveFlag(nameof(ShouldSerializeOwnerAbandonTime))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _ownerAbandonTime; private bool ShouldSerializeOwnerAbandonTime() => _ownerAbandonTime != DateTime.MinValue; - [SerializableField(47)] + [SerializableField(46)] [SaveFlag(nameof(ShouldSerializeHasGeneratedLoot))] private bool _hasGeneratedLoot; @@ -729,7 +725,7 @@ namespace Server.Mobiles private bool ShouldSerializeIsParagon() => _isParagon; [Tidy] - [SerializableField(49, setter: "private")] + [SerializableField(48, setter: "private")] [SaveFlag(nameof(ShouldSerializeFriends))] private List _friends; @@ -739,7 +735,7 @@ namespace Server.Mobiles return _friends?.Count > 0; } - [SerializableField(50)] + [SerializableField(49)] [SaveFlag(nameof(ShouldSerializeRemoveIfUntamed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _removeIfUntamed; @@ -747,21 +743,21 @@ namespace Server.Mobiles private bool ShouldSerializeRemoveIfUntamed() => _removeIfUntamed; [EncodedInt] - [SerializableField(51)] + [SerializableField(50)] [SaveFlag(nameof(ShouldSerializeRemoveStep))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _removeStep; private bool ShouldSerializeRemoveStep() => _removeStep != 0; - [SerializableField(52, setter: "private")] + [SerializableField(51, setter: "private")] [SaveFlag(nameof(ShouldSerializePendingDeleteTimer))] [DeserializeTimer(nameof(DeserializePendingDeleteTimer))] private Timer _pendingDeleteTimer; // Stabled and controlled pets never resume a delete countdown. private bool ShouldSerializePendingDeleteTimer() => - _pendingDeleteTimer?.Running == true && !IsStabled && !(_controlled && _controlMaster != null); + _pendingDeleteTimer?.Running == true && !IsStabled && ControlMaster == null; private void DeserializePendingDeleteTimer(TimeSpan delay) { @@ -769,7 +765,7 @@ namespace Server.Mobiles _pendingDeleteTimer.Start(); } - [SerializableField(53)] + [SerializableField(52)] [SaveFlag(nameof(ShouldSerializeCorpseNameOverride))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _corpseNameOverride; @@ -847,7 +843,6 @@ namespace Server.Mobiles Debug = false; _controlled = false; - _controlMaster = null; ControlTarget = null; _controlOrder = OrderType.None; @@ -931,7 +926,7 @@ namespace Server.Mobiles public virtual double WeaponAbilityChance => 0.4; - [SerializableProperty(48, useField: nameof(_isParagon))] + [SerializableProperty(47, useField: nameof(_isParagon))] [SaveFlag(nameof(ShouldSerializeIsParagon))] [CommandProperty(AccessLevel.GameMaster)] public bool IsParagon @@ -1028,8 +1023,8 @@ namespace Server.Mobiles } public virtual bool IsNecroFamiliar => - Summoned && _controlMaster != null && - SummonFamiliarSpell.Table.TryGetValue(_controlMaster, out var bc) && bc == this; + Summoned && ControlMaster != null && + SummonFamiliarSpell.Table.TryGetValue(ControlMaster, out var bc) && bc == this; public virtual bool DeleteCorpseOnDeath => !Core.AOS && _summoned; @@ -1157,54 +1152,53 @@ namespace Server.Mobiles } } - [SerializableProperty(15, useField: nameof(_controlMaster))] - [SaveFlag(nameof(ShouldSerializeControlMaster))] + // Assigned before Controlled is raised (SetControlMaster), so the setter cannot be gated. [CommandProperty(AccessLevel.GameMaster)] public Mobile ControlMaster { - get => _controlMaster; + get => _controlled ? _master : null; set { - if (_controlMaster == value || this == value) + if (ControlMaster == value || this == value) { return; } - RemoveFollowers(); - _controlMaster = value; - AddFollowers(); - if (_controlMaster != null) + SetMaster(value); + + if (value != null) { StopDeleteTimer(); } - - Delta(MobileDelta.Noto); - this.MarkDirty(); } } - [SerializableProperty(23, useField: nameof(_summonMaster))] - [SaveFlag(nameof(ShouldSerializeSummonMaster))] + // The summoner, controlled or not. Enraged creatures have one without being Summoned. + // A plain pet has no summoner, so assigning one would overwrite its owner. [CommandProperty(AccessLevel.GameMaster)] public Mobile SummonMaster { - get => _summonMaster; + get => _controlled && !_summoned ? null : _master; set { - if (_summonMaster == value || this == value) + if (SummonMaster == value || this == value || _controlled && !_summoned) { return; } - RemoveFollowers(); - _summonMaster = value; - AddFollowers(); - - Delta(MobileDelta.Noto); - this.MarkDirty(); + SetMaster(value); } } + private void SetMaster(Mobile value) + { + RemoveFollowers(); + Master = value; + AddFollowers(); + + Delta(MobileDelta.Noto); + } + // Fires on every assignment, not only changes: a reissued order is a command (retarget, re-anchor). // A raw assignment is system-issued; player commands go through IssueOrder. [SerializableProperty(18, useField: nameof(_controlOrder))] @@ -1271,7 +1265,7 @@ namespace Server.Mobiles } InvalidateProperties(); - _controlMaster?.InvalidateProperties(); + ControlMaster?.InvalidateProperties(); this.MarkDirty(); } @@ -2195,9 +2189,75 @@ namespace Server.Mobiles } } + // Absent fields resolve through the same default-value methods the live reader uses. + private void MigrateFrom(V23Content content) + { + _defaultAI = content.DefaultAI; + _currentAI = content.CurrentAI ?? CurrentAIDefaultValue(); + _rangePerception = content.RangePerception ?? RangePerceptionDefaultValue(); + _rangeFight = content.RangeFight ?? RangeFightDefaultValue(); + _rangeHome = content.RangeHome ?? RangeHomeDefaultValue(); + _team = content.Team ?? 0; + _fightMode = content.FightMode ?? FightModeDefaultValue(); + _activeSpeed = content.ActiveSpeed ?? ActiveSpeedDefaultValue(); + _passiveSpeed = content.PassiveSpeed ?? PassiveSpeedDefaultValue(); + _currentSpeed = content.CurrentSpeed ?? CurrentSpeedDefaultValue(); + _activeMoveSpeed = content.ActiveMoveSpeed ?? ActiveMoveSpeedDefaultValue(); + _passiveMoveSpeed = content.PassiveMoveSpeed ?? PassiveMoveSpeedDefaultValue(); + _home = content.Home ?? Point3D.Zero; + _homeMap = content.HomeMap; + _controlled = content.Controlled; + // A traded summon could still carry its caster as summon master; the owner wins. + _master = content.ControlMaster ?? content.SummonMaster; + _controlTarget = content.ControlTarget; + _controlDest = content.ControlDest ?? Point3D.Zero; + _controlOrder = content.ControlOrder ?? OrderType.None; + _minTameSkill = content.MinTameSkill ?? 0; + _tamable = content.Tamable; + _summoned = content.Summoned; + _summonEnd = content.SummonEnd ?? DateTime.MinValue; + _controlSlots = content.ControlSlots ?? ControlSlotsDefaultValue(); + _loyalty = content.Loyalty ?? LoyaltyDefaultValue(); + _currentWayPoint = content.CurrentWayPoint; + _hitsMaxSeed = content.HitsMaxSeed ?? HitsMaxSeedDefaultValue(); + _stamMaxSeed = content.StamMaxSeed ?? StamMaxSeedDefaultValue(); + _manaMaxSeed = content.ManaMaxSeed ?? ManaMaxSeedDefaultValue(); + _damageMin = content.DamageMin ?? DamageMinDefaultValue(); + _damageMax = content.DamageMax ?? DamageMaxDefaultValue(); + _physicalResistanceSeed = content.PhysicalResistanceSeed ?? 0; + _fireResistSeed = content.FireResistSeed ?? 0; + _coldResistSeed = content.ColdResistSeed ?? 0; + _poisonResistSeed = content.PoisonResistSeed ?? 0; + _energyResistSeed = content.EnergyResistSeed ?? 0; + _physicalDamage = content.PhysicalDamage ?? PhysicalDamageDefaultValue(); + _fireDamage = content.FireDamage ?? 0; + _coldDamage = content.ColdDamage ?? 0; + _poisonDamage = content.PoisonDamage ?? 0; + _energyDamage = content.EnergyDamage ?? 0; + _owners = content.Owners ?? OwnersDefaultValue(); + _isDeadPet = content.IsDeadPet; + _isBonded = content.IsBonded; + _bondingBegin = content.BondingBegin ?? DateTime.MinValue; + _ownerAbandonTime = content.OwnerAbandonTime ?? DateTime.MinValue; + _hasGeneratedLoot = content.HasGeneratedLoot; + _isParagon = content.IsParagon; + _friends = content.Friends; + _removeIfUntamed = content.RemoveIfUntamed; + _removeStep = content.RemoveStep ?? 0; + + if (content.PendingDeleteTimerDelay != TimeSpan.MinValue) + { + DeserializePendingDeleteTimer(content.PendingDeleteTimerDelay); + } + + _corpseNameOverride = content.CorpseNameOverride; + } + // Pre-codegen loads only (versions 0-22); post-codegen bumps use MigrateFrom. private void Deserialize(IGenericReader reader, int version) { + Mobile controlMaster = null; + _currentAI = (AIType)reader.ReadInt(); _defaultAI = (AIType)reader.ReadInt(); @@ -2245,7 +2305,7 @@ namespace Server.Mobiles _fightMode = (FightMode)reader.ReadInt(); _controlled = reader.ReadBool(); - _controlMaster = reader.ReadEntity(); + controlMaster = reader.ReadEntity(); _controlTarget = reader.ReadEntity(); _controlDest = reader.ReadPoint3D(); _controlOrder = (OrderType)reader.ReadInt(); @@ -2273,7 +2333,6 @@ namespace Server.Mobiles _fightMode = FightMode.Closest; _controlled = false; - _controlMaster = null; _controlTarget = null; _controlOrder = OrderType.None; } @@ -2294,9 +2353,12 @@ namespace Server.Mobiles if (version >= 5) { - _summonMaster = reader.ReadEntity(); + _master = reader.ReadEntity(); } + // A transferred summon kept its caster as summon master; the owner wins. + _master = controlMaster ?? _master; + if (version >= 6) { _hitsMaxSeed = reader.ReadInt(); @@ -2457,7 +2519,7 @@ namespace Server.Mobiles if (IsAnimatedDead) { - AnimateDeadSpell.Register(_summonMaster, this); + AnimateDeadSpell.Register(SummonMaster, this); } } @@ -2566,7 +2628,7 @@ namespace Server.Mobiles public void RemoveFollowers() { - var master = _controlMaster ?? _summonMaster; + var master = _master; if (master != null) { master.Followers -= Math.Min(ControlSlots, master.Followers); @@ -2580,7 +2642,7 @@ namespace Server.Mobiles public void AddFollowers() { - var master = _controlMaster ?? _summonMaster; + var master = _master; if (master != null) { master.Followers += ControlSlots; @@ -2661,7 +2723,7 @@ namespace Server.Mobiles if (IsAnimatedDead) { - AnimateDeadSpell.Unregister(_summonMaster, this); + AnimateDeadSpell.Unregister(SummonMaster, this); } if (Summoned && SummonMaster != null) @@ -2842,7 +2904,7 @@ namespace Server.Mobiles } public override bool IsHarmfulCriminal(Mobile target) => - (!Controlled || target != _controlMaster) && (!Summoned || target != _summonMaster) && + target != ControlMaster && (!Summoned || target != SummonMaster) && (target is not BaseCreature { InitialInnocent: true } creature || creature.Controlled) && (target is not PlayerMobile mobile || mobile.PermaFlags.Count <= 0) && base.IsHarmfulCriminal(target); @@ -2850,16 +2912,9 @@ namespace Server.Mobiles { base.CriminalAction(message); - if (Controlled || Summoned) + if ((Controlled || Summoned) && _master?.Player == true) { - if (_controlMaster?.Player == true) - { - _controlMaster.CriminalAction(false); - } - else if (_summonMaster?.Player == true) - { - _summonMaster.CriminalAction(false); - } + _master.CriminalAction(false); } } @@ -2867,7 +2922,7 @@ namespace Server.Mobiles { base.DoHarmful(target, indirect); - if (target == this || target == _controlMaster || target == _summonMaster || !Controlled && !Summoned) + if (target == this || target == _master || !Controlled && !Summoned) { return; } @@ -3644,7 +3699,7 @@ namespace Server.Mobiles { CreatureEvents.CreatureDeletedEvent(this); - var m = _controlMaster; + var m = ControlMaster; SetControlMaster(null); SummonMaster = null; @@ -3951,14 +4006,9 @@ namespace Server.Mobiles return BardMaster; } - if (_controlled && _controlMaster != null) + if ((_controlled || _summoned) && _master != null) { - return _controlMaster; - } - - if (_summoned && _summonMaster != null) - { - return _summonMaster; + return _master; } return base.GetDamageMaster(damagee); @@ -4549,7 +4599,7 @@ namespace Server.Mobiles if (IsBondable && !IsBonded) { - var master = _controlMaster; + var master = ControlMaster; if (master != null && master == from) // So friends can't start the bonding process { diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index cf7fc000b..f1a81a53a 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -3553,7 +3553,6 @@ namespace Server.Mobiles pet.Internalize(); pet.SetControlMaster(null); - pet.SummonMaster = null; pet.IsStabled = true; pet.StabledBy = this; @@ -3602,11 +3601,6 @@ namespace Server.Mobiles { pet.SetControlMaster(this); - if (pet.SummonMaster != null) - { - pet.SummonMaster = this; - } - pet.ControlTarget = this; pet.ControlOrder = OrderType.Follow; diff --git a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs index b3fb4d721..81a000710 100644 --- a/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs +++ b/Projects/UOContent/Mobiles/Vendors/NPC/AnimalTrainer.cs @@ -255,7 +255,6 @@ namespace Server.Mobiles pet.Internalize(); pet.SetControlMaster(null); - pet.SummonMaster = null; pet.IsStabled = true; pet.StabledBy = from; @@ -357,11 +356,6 @@ namespace Server.Mobiles { pet.SetControlMaster(from); - if (pet.SummonMaster != null) - { - pet.SummonMaster = from; - } - pet.ControlTarget = from; pet.ControlOrder = OrderType.Follow; diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs index 8da7319c9..8802d0487 100644 --- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs +++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs @@ -144,7 +144,6 @@ public class JailSystem : GenericPersistence bc.Internalize(); bc.SetControlMaster(null); - bc.SummonMaster = null; bc.IsStabled = true; bc.StabledBy = from; From 37970a481102579fd586811b5bc232bbaf83fe8d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:42:40 -0700 Subject: [PATCH 11/31] fix(ai): meer mages and their enraged creatures no longer fight each other (#2673) ## Summary A meer mage fights `FightMode.Evil` and its enraged creatures carry -1000 karma, so `BaseCreature.IsEnemy` made each an enemy of the other: - The meer acquired its own enraged creatures as ordinary targets. - An enraged creature fell back to attacking its meer when nothing else was in range. It isn't `Summoned`, so it reached the meer through `AcquireNewFocusMob`'s summon-master fallback. `BaseEnraged` and `MeerMage` now override `IsEnemy` to exclude each other. Another meer's creatures stay enemies. `BaseEnraged` also pulled its meer into fights against any creature with a `SummonMaster`, which included another meer's enraged creatures. It now checks `GetMaster()`, matching the "controlled/summoned" OSI note in that method. ## Test plan - [x] `MeerAndItsEnragedCreature_AreNotEnemies`: fails before the fix - [x] `AnotherMeersEnragedCreature_StaysAnEnemy` - [x] `MeerAndItsEnragedCreature_NeverAcquireEachOther`: fails before the fix (the meer acquired its own creature) - [x] UOContent.Tests (1171 passed, 2 skipped) and Server.Tests (907) green --- .../Tests/Mobiles/AI/MeerEnragedTests.cs | 77 +++++++++++++++++++ .../Monsters/LBR/Meers/EnragedCreatures.cs | 5 +- .../Mobiles/Monsters/LBR/Meers/MeerMage.cs | 3 + 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs new file mode 100644 index 000000000..3ced177b5 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MeerEnragedTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// A meer mage fights FightMode.Evil and its enraged creatures carry -1000 karma, so the karma rules alone +// make each an enemy of the other. +[Collection("Sequential UOContent Tests")] +public class MeerEnragedTests : IDisposable +{ + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private T Track(T m) where T : Mobile + { + _created.Add(m); + return m; + } + + private static void Place(BaseCreature bc, int x) + { + var map = Map.Felucca; + bc.MoveToWorld(new Point3D(x, 1600, map.GetAverageZ(x, 1600)), map); // flat, clear line of sight both ways + bc.AIObject.AITimer.Stop(); + } + + private static bool Acquire(BaseCreature creature) + { + creature.NextReacquireTime = Core.TickCount - 1; + return creature.AIObject.AcquireFocusMob(creature.RangePerception, creature.FightMode, false, false, true); + } + + [Fact] + public void MeerAndItsEnragedCreature_AreNotEnemies() + { + var meer = Track(new MeerMage()); + var enraged = Track(new EnragedRabbit(meer)); + + Assert.False(enraged.IsEnemy(meer)); + Assert.False(meer.IsEnemy(enraged)); + } + + [Fact] + public void AnotherMeersEnragedCreature_StaysAnEnemy() + { + var meer = Track(new MeerMage()); + var other = Track(new MeerMage()); + var enraged = Track(new EnragedRabbit(other)); + + Assert.True(enraged.IsEnemy(meer)); + Assert.True(meer.IsEnemy(enraged)); + } + + [SkippableFact] + public void MeerAndItsEnragedCreature_NeverAcquireEachOther() + { + Skip.If(!Server.Tests.TestServerInitializer.TileDataLoaded, "Requires UO client map data."); + + var meer = Track(new MeerMage()); + var enraged = Track(new EnragedBlackBear(meer)); + Place(meer, 1600); + Place(enraged, 1601); + + Assert.False(Acquire(enraged)); + Assert.False(Acquire(meer)); + } +} diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs index ce4f3526d..a7afa1b9e 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs @@ -95,6 +95,9 @@ namespace Server.Mobiles SummonMaster = summoner; } + // The meer's FightMode.Evil against our negative karma would otherwise make us enemies. + public override bool IsEnemy(Mobile m) => m != SummonMaster && base.IsEnemy(m); + public override void OnThink() { if (SummonMaster?.Deleted != false) @@ -118,7 +121,7 @@ namespace Server.Mobiles */ else if (!Combat(SummonMaster)) { - if (Combatant.Player || Combatant is BaseCreature bc && (bc.Controlled || bc.SummonMaster != null)) + if (Combatant.Player || Combatant is BaseCreature bc && bc.GetMaster() != null) { SummonMaster.Combatant = Combatant; } diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs index fca41ec25..e96e8cd1d 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs @@ -59,6 +59,9 @@ namespace Server.Mobiles public override bool InitialInnocent => true; + // FightMode.Evil would otherwise target our own enraged creatures for their negative karma. + public override bool IsEnemy(Mobile m) => (m as BaseEnraged)?.SummonMaster != this && base.IsEnemy(m); + public override void GenerateLoot() { AddLoot(LootPack.FilthyRich); From 843a29e7dc7b402ee9405e7055a384fd597ee1b3 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:45:24 -0700 Subject: [PATCH 12/31] refactor: master views over BaseCreature's one master reference (#2674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary After #2670, `BaseCreature` stores one master. Callers still reached it through three differently gated names plus hand-rolled combinations of the `Controlled`/`Summoned` flags. Each gate is now one named view: | Member | Definition | Meaning | |---|---|---| | `Master` | the reference; its setter does the follower bookkeeping; serialized as before | whoever the creature answers to | | `ControlMaster` | `Controlled ? Master : null` | the owner | | `SummonMaster` | `Summoned ? Master : null` | the summoner | | `GetMaster()` | `ControlMaster ?? SummonMaster` | who answers for the creature | `ControlMaster` and `SummonMaster` stay settable so custom code keeps compiling: both setters assign `Master`. There is no guard, so a write through either one can read back as null until the matching flag is set. An enraged creature is the one shape with a master and neither flag. It keeps its meer through `Master`, while `GetMaster()` stays null, so the meer never answers for it (notoriety, kill credit). Readers move onto the view they mean: - "Owner or summoner" pairs and `ControlMaster ?? SummonMaster` collapse into `GetMaster()`: pack instinct, familiars, golem, house access, mounts, pack horse, Solen friendship, spell target and guild checks, and the `[` pet command. - Checks such as `Controlled && ControlMaster == from` drop the flag the view already applies. Checks that don't reduce keep it: `Controlled && ControlMaster != from`, a possibly-null `from`, and `MageAI.CanDispel`'s `Summoned &&`. **No behavior change, save format unchanged** (the schema generator produces no diff). Edge cases for custom code and shard operators: - `SummonMaster = x` on a plain pet now replaces its owner (#2670 made that a no-op), and `ControlMaster = null` on an uncontrolled summon now clears its caster (it did nothing before). - `SpellHelper.GetGuildFor` no longer falls back to an enraged creature's meer. Meer mages have no guild. ## Docs - `dev-docs/content-patterns.md` § Masters: which view answers which question, how to set a master, and which flag checks are redundant. - `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § Masters: RunUO's two-field reads mapped onto the views, plus the gotcha for creatures that set `SummonMaster` without `Summoned` (the write compiles, the read now returns null; use `Master`). - The `modernuo-content-patterns` and `migrate-items-mobiles` skills point at both. ## Test plan - [x] `MasterViewsTests`: all four views across wild, pet, controlled summon, uncontrolled summon and enraged shapes; reassigning `Master` moves follower slots; `SetControlMaster(null)` keeps an uncontrolled summon's caster; deleting a summon returns the caster's slots - [x] Serialization tests updated to the new views (round-trip, legacy v22, v23 migration) - [x] UOContent.Tests (1178 passed, 2 skipped) and Server.Tests (907) green --- .../Mobiles/AI/GuardFriendlyFireTests.cs | 4 +- .../Tests/Mobiles/AI/PetOrderTests.cs | 2 +- .../Tests/Mobiles/AI/PetRetaliationTests.cs | 2 +- .../Mobiles/AI/SummonAcquireRulesTests.cs | 2 +- .../Mobiles/BaseCreatureSerializationTests.cs | 28 +--- .../Tests/Mobiles/MasterViewsTests.cs | 150 ++++++++++++++++++ Projects/UOContent/Commands/Handlers.cs | 2 +- Projects/UOContent/Engines/ConPVP/Arena.cs | 2 +- Projects/UOContent/Engines/ConPVP/SafeZone.cs | 2 +- .../Doom/LeverPuzzle/LeverPuzzleRegions.cs | 2 +- .../UOContent/Gumps/ConfirmReleaseGump.cs | 2 +- .../UOContent/Items/Containers/Container.cs | 2 +- .../Items/Weapons/Abilities/Dismount.cs | 2 +- .../UOContent/Items/Weapons/BaseWeapon.cs | 11 +- Projects/UOContent/Misc/Notoriety.cs | 2 +- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 8 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 2 +- Projects/UOContent/Mobiles/AI/FamiliarAI.cs | 2 +- .../Summon Undead/SummonUndeadCounter.cs | 2 +- .../Mobiles/Animals/Misc/PackHorse.cs | 2 +- .../Mobiles/Animals/Mounts/BaseMount.cs | 3 +- .../Mobiles/Animals/Mounts/Beetle.cs | 4 +- Projects/UOContent/Mobiles/BaseCreature.cs | 103 ++++++------ .../Mobiles/Familiars/BaseFamiliar.cs | 2 +- .../UOContent/Mobiles/Familiars/DarkWolf.cs | 2 +- .../UOContent/Mobiles/Familiars/ShadowWisp.cs | 2 +- .../Mobiles/Monsters/Ants/SolenHelper.cs | 34 +--- .../Monsters/LBR/Meers/EnragedCreatures.cs | 12 +- .../Mobiles/Monsters/LBR/Meers/MeerMage.cs | 4 +- .../Monsters/Misc/Melee/BladeSpirits.cs | 2 +- .../Monsters/Misc/Melee/EnergyVortex.cs | 2 +- .../Mobiles/Monsters/Misc/Melee/Golem.cs | 23 ++- .../Mobiles/Monsters/SE/FireBeetle.cs | 4 +- Projects/UOContent/Multis/Houses/BaseHouse.cs | 7 +- Projects/UOContent/Skills/Discordance.cs | 2 +- Projects/UOContent/Spells/Base/SpellHelper.cs | 48 +----- .../UOContent/Spells/Ninjitsu/MirrorImage.cs | 2 +- .../migrate-items-mobiles.md | 1 + .../modernuo-content-patterns.md | 6 + dev-docs/content-patterns.md | 31 ++++ .../09-items-mobiles-creatures.md | 34 ++++ 41 files changed, 346 insertions(+), 213 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs index 8994bd391..ec11a3ba5 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFriendlyFireTests.cs @@ -123,7 +123,7 @@ public class GuardFriendlyFireTests : IDisposable var (owner, pet) = Spawn(); var summon = OtherPet(owner, new Point3D(1498, 1600, owner.Z)); summon.Summoned = true; - summon.SummonMaster = owner; + summon.Master = owner; summon.Combatant = owner; Assert.Same(owner, summon.Combatant); pet.AggressiveAction(summon, false); @@ -136,7 +136,7 @@ public class GuardFriendlyFireTests : IDisposable public void GuardDefendsAgainstOwnUncontrolledSummonAttackingOwner() { var (owner, pet) = Spawn(); - var vortex = new PetTestStub { Summoned = true, SummonMaster = owner }; + var vortex = new PetTestStub { Summoned = true, Master = owner }; _created.Add(vortex); vortex.MoveToWorld(new Point3D(1498, 1600, owner.Z), owner.Map); vortex.AIObject.AITimer.Stop(); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs index ca30ee7d9..7d12ed744 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs @@ -762,7 +762,7 @@ public class PetOrderTests : IDisposable { var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); pet.Summoned = true; - pet.SummonMaster = master; + pet.Master = master; pet.ControlOrder = OrderType.Release; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs index d5074704c..41b52a189 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetRetaliationTests.cs @@ -197,7 +197,7 @@ public class PetRetaliationTests : IDisposable var summon = new StandDownPet(); summon.Summoned = true; - summon.SummonMaster = caster; + summon.Master = caster; summon.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); summon.AIObject.AITimer?.Stop(); _created.Add(summon); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs index 7bd3b7c70..28b311186 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/SummonAcquireRulesTests.cs @@ -24,7 +24,7 @@ public class SummonAcquireRulesTests : IDisposable { _created.Add(summon); summon.Summoned = true; - summon.SummonMaster = caster; + summon.Master = caster; summon.MoveToWorld(new Point3D(x, 1600, caster.Map.GetAverageZ(x, 1600)), caster.Map); summon.AIObject.AITimer.Stop(); return summon; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs index 3b95f452e..ba106218c 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -201,31 +201,16 @@ public class BaseCreatureSerializationTests : IDisposable } else { - bc.SummonMaster = master; + bc.Master = master; } var copy = Load(Snapshot(bc)); Assert.Equal(controlled, copy.Controlled); Assert.Equal(summoned, copy.Summoned); + Assert.Equal(master, copy.Master); Assert.Equal(controlled ? master : null, copy.ControlMaster); - Assert.Equal(controlled && !summoned ? null : master, copy.SummonMaster); - } - - [Fact] - public void SummonMaster_OnPlainPet_KeepsTheOwner() - { - var bc = NewCreature(); - var owner = NewMaster(); - var other = NewMaster(); - - bc.SetControlMaster(owner); - bc.SummonMaster = other; - - Assert.Equal(owner, bc.ControlMaster); - Assert.Null(bc.SummonMaster); - Assert.Equal(bc.ControlSlots, owner.Followers); - Assert.Equal(0, other.Followers); + Assert.Equal(summoned ? master : null, copy.SummonMaster); } [Fact] @@ -446,8 +431,8 @@ public class BaseCreatureSerializationTests : IDisposable Assert.Equal(0.6, copy.CurrentMoveSpeed); // passive mode, inheriting } - // A summon master can exist without Summoned (EnragedCreature), and a controlled summon - // reports the same mobile as both. + // A master survives without Summoned (EnragedCreature), and a controlled summon reports the + // same mobile as both. [Theory] [InlineData(true, true, false, false)] // controlled pet [InlineData(true, true, true, true)] // controlled summon, SummonEnd on the wire @@ -473,7 +458,8 @@ public class BaseCreatureSerializationTests : IDisposable Assert.Equal(controlled, copy.Controlled); Assert.Equal(summoned, copy.Summoned); Assert.Equal(hasControlMaster ? master : null, copy.ControlMaster); - Assert.Equal(hasSummonMaster ? master : null, copy.SummonMaster); + Assert.Equal(master, copy.Master); + Assert.Equal(summoned ? master : null, copy.SummonMaster); if (summoned) { diff --git a/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs new file mode 100644 index 000000000..20578ecfc --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/MasterViewsTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles; + +// One stored master, read through gates on Controlled and Summoned. +[Collection("Sequential UOContent Tests")] +public class MasterViewsTests : IDisposable +{ + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class Stub : BaseCreature + { + public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + + public Stub(Serial serial) : base(serial) + { + } + } + + private Stub NewCreature() + { + var bc = new Stub(); + _created.Add(bc); + return bc; + } + + private PlayerMobile NewPlayer() + { + var pm = new PlayerMobile(World.NewMobile); + pm.DefaultMobileInit(); + World.AddEntity(pm); + _created.Add(pm); + return pm; + } + + public enum Shape + { + Wild, + Pet, + ControlledSummon, + UncontrolledSummon, + Enraged + } + + [Theory] + [InlineData(Shape.Wild)] + [InlineData(Shape.Pet)] + [InlineData(Shape.ControlledSummon)] + [InlineData(Shape.UncontrolledSummon)] + [InlineData(Shape.Enraged)] + public void Views_ByShape(Shape shape) + { + var bc = NewCreature(); + var master = NewPlayer(); + + switch (shape) + { + case Shape.Pet: + { + bc.SetControlMaster(master); + break; + } + case Shape.ControlledSummon: + { + bc.SetControlMaster(master); + bc.Summoned = true; + break; + } + case Shape.UncontrolledSummon: + { + bc.Summoned = true; + bc.Master = master; + break; + } + case Shape.Enraged: + { + bc.Master = master; + break; + } + } + + var controlled = shape is Shape.Pet or Shape.ControlledSummon; + var summoned = shape is Shape.ControlledSummon or Shape.UncontrolledSummon; + + Assert.Equal(shape == Shape.Wild ? null : master, bc.Master); + Assert.Equal(controlled ? master : null, bc.ControlMaster); + Assert.Equal(summoned ? master : null, bc.SummonMaster); + Assert.Equal(controlled || summoned ? master : null, bc.GetMaster()); + } + + [Fact] + public void Master_Reassignment_MovesFollowerSlots() + { + var bc = NewCreature(); + bc.ControlSlots = 2; + var first = NewPlayer(); + var second = NewPlayer(); + + bc.Master = first; + Assert.Equal(2, first.Followers); + + bc.Master = second; + Assert.Equal(0, first.Followers); + Assert.Equal(2, second.Followers); + Assert.Null(bc.ControlMaster); // not Controlled + + bc.Master = null; + Assert.Equal(0, second.Followers); + } + + [Fact] + public void SetControlMasterNull_KeepsAnUncontrolledSummonsCaster() + { + var bc = NewCreature(); + var caster = NewPlayer(); + bc.Summoned = true; + bc.Master = caster; + + bc.SetControlMaster(null); + + Assert.Equal(caster, bc.SummonMaster); + Assert.Equal(bc.ControlSlots, caster.Followers); + } + + [Fact] + public void Delete_ReturnsTheCastersFollowerSlots() + { + var bc = NewCreature(); + var caster = NewPlayer(); + bc.Summoned = true; + bc.Master = caster; + + bc.Delete(); + + Assert.Equal(0, caster.Followers); + Assert.Null(bc.Master); + } +} diff --git a/Projects/UOContent/Commands/Handlers.cs b/Projects/UOContent/Commands/Handlers.cs index d3a9d3350..d1a20707e 100644 --- a/Projects/UOContent/Commands/Handlers.cs +++ b/Projects/UOContent/Commands/Handlers.cs @@ -213,7 +213,7 @@ namespace Server.Commands { if (m is BaseCreature bc) { - if (bc.Controlled && bc.ControlMaster == master || bc.Summoned && bc.SummonMaster == master) + if (bc.GetMaster() == master) { pets.Add(bc); } diff --git a/Projects/UOContent/Engines/ConPVP/Arena.cs b/Projects/UOContent/Engines/ConPVP/Arena.cs index aebfed7b0..d31820339 100644 --- a/Projects/UOContent/Engines/ConPVP/Arena.cs +++ b/Projects/UOContent/Engines/ConPVP/Arena.cs @@ -637,7 +637,7 @@ namespace Server.Engines.ConPVP foreach (var mob in facet.GetMobilesInBounds(m_Bounds)) { - if (mob is BaseCreature pet && pet.Controlled && pet.ControlMaster != null && + if (mob is BaseCreature pet && pet.ControlMaster != null && Players.Contains(pet.ControlMaster)) { pets.Add(pet); diff --git a/Projects/UOContent/Engines/ConPVP/SafeZone.cs b/Projects/UOContent/Engines/ConPVP/SafeZone.cs index 69ba11a66..e800fdd34 100644 --- a/Projects/UOContent/Engines/ConPVP/SafeZone.cs +++ b/Projects/UOContent/Engines/ConPVP/SafeZone.cs @@ -31,7 +31,7 @@ namespace Server.Engines.ConPVP } var pm = m as PlayerMobile ?? - (m is BaseCreature bc && bc.Summoned ? bc.SummonMaster as PlayerMobile : null); + (m as BaseCreature)?.SummonMaster as PlayerMobile; if (pm?.DuelContext?.StartedBeginCountdown == true) { diff --git a/Projects/UOContent/Engines/Doom/LeverPuzzle/LeverPuzzleRegions.cs b/Projects/UOContent/Engines/Doom/LeverPuzzle/LeverPuzzleRegions.cs index 6c983e0e9..55cb60ea1 100644 --- a/Projects/UOContent/Engines/Doom/LeverPuzzle/LeverPuzzleRegions.cs +++ b/Projects/UOContent/Engines/Doom/LeverPuzzle/LeverPuzzleRegions.cs @@ -47,7 +47,7 @@ namespace Server.Engines.Doom return; } } - else if (m is BaseCreature bc && (bc.Controlled && bc.ControlMaster == m_Controller.Successful || + else if (m is BaseCreature bc && (bc.ControlMaster == m_Controller.Successful || bc.Summoned)) { return; diff --git a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs index aa3bdc4bc..b3f4efc35 100644 --- a/Projects/UOContent/Gumps/ConfirmReleaseGump.cs +++ b/Projects/UOContent/Gumps/ConfirmReleaseGump.cs @@ -36,7 +36,7 @@ public class ConfirmReleaseGump : StaticGump public override void OnResponse(NetState sender, in RelayInfo info) { if (info.ButtonID != 2 || _pet.Deleted || - !(_pet.Controlled && _from == _pet.ControlMaster && + !(_from == _pet.ControlMaster && _from.CheckAlive() && _pet.Map == _from.Map && _pet.InRange(_from, 14))) { return; diff --git a/Projects/UOContent/Items/Containers/Container.cs b/Projects/UOContent/Items/Containers/Container.cs index 164c285c4..77620db3c 100644 --- a/Projects/UOContent/Items/Containers/Container.cs +++ b/Projects/UOContent/Items/Containers/Container.cs @@ -237,7 +237,7 @@ public partial class StrongBackpack : Backpack // Used on Pack animals base.CheckHold(m, item, false, checkItems, plusItems, plusWeight); public override bool CheckContentDisplay(Mobile from) => - RootParent is BaseCreature creature && creature.Controlled && creature.ControlMaster == from || + RootParent is BaseCreature creature && creature.ControlMaster == from || base.CheckContentDisplay(from); } diff --git a/Projects/UOContent/Items/Weapons/Abilities/Dismount.cs b/Projects/UOContent/Items/Weapons/Abilities/Dismount.cs index df50f1449..3121b9516 100644 --- a/Projects/UOContent/Items/Weapons/Abilities/Dismount.cs +++ b/Projects/UOContent/Items/Weapons/Abilities/Dismount.cs @@ -102,7 +102,7 @@ public class Dismount : WeaponAbility { playerMobile.SetMountBlock(BlockMountType.DismountRecovery, RemountDelay, true); } - else if (Core.ML && attacker is BaseCreature { Controlled: true, ControlMaster: PlayerMobile pm }) + else if (Core.ML && attacker is BaseCreature { ControlMaster: PlayerMobile pm }) { pm.SetMountBlock(BlockMountType.DismountRecovery, RemountDelay, false); } diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index df5361a13..e4f0d4def 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -1698,12 +1698,12 @@ public abstract partial class BaseWeapon return 0; } - if (attacker is not BaseCreature bc || bc.PackInstinct == PackInstinct.None || !bc.Controlled && !bc.Summoned) + if (attacker is not BaseCreature bc || bc.PackInstinct == PackInstinct.None) { return 0; } - var master = bc.ControlMaster ?? bc.SummonMaster; + var master = bc.GetMaster(); if (master == null) { @@ -1713,8 +1713,7 @@ public abstract partial class BaseWeapon var inPack = 1; foreach (var m in defender.GetMobilesInRange(1)) { - if (m != attacker && (m.PackInstinct & bc.PackInstinct) != 0 && (m.Controlled || m.Summoned) && - master == (m.ControlMaster ?? m.SummonMaster) && m.Combatant == defender) + if (m != attacker && (m.PackInstinct & bc.PackInstinct) != 0 && master == m.GetMaster() && m.Combatant == defender) { inPack++; } @@ -1736,7 +1735,7 @@ public abstract partial class BaseWeapon { foreach (var m in defender.GetMobilesInRange(4)) { - if (m?.Summoned == true && m.SummonMaster == defender) + if (m?.SummonMaster == defender) { // Your attack has been diverted to a nearby mirror image of your target! attacker.SendLocalizedMessage(1063141); @@ -2100,7 +2099,7 @@ public abstract partial class BaseWeapon if (attacker is VampireBatFamiliar bc) { - var caster = bc.ControlMaster ?? bc.SummonMaster; + var caster = bc.GetMaster(); if (caster != null && caster.Map == bc.Map) { diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs index fc57c3462..f31ad3450 100644 --- a/Projects/UOContent/Misc/Notoriety.cs +++ b/Projects/UOContent/Misc/Notoriety.cs @@ -228,7 +228,7 @@ namespace Server.Misc } if (bcTarg?.Controlled == true - || bcTarg?.Summoned == true && bcTarg.SummonMaster != from && bcTarg.SummonMaster?.Player == true) + || bcTarg?.SummonMaster is { Player: true } summoner && summoner != from) { return false; // Cannot harm other controlled mobiles from players } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 64eecb50e..0c5b9423b 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -144,7 +144,7 @@ public abstract partial class BaseAI } // Only a creature somebody can command has been told anything; wild creatures rest on None. - var toldToStandDown = Mobile.Controlled && Mobile.ControlMaster != null && Mobile.Commandable && + var toldToStandDown = Mobile.ControlMaster != null && Mobile.Commandable && IsStandDownOrder(Mobile.ControlOrder); if (Mobile.StandsDownOnCommand && toldToStandDown) @@ -1049,15 +1049,15 @@ public abstract partial class BaseAI private bool IsInvalidSummonTarget(Mobile m, BaseCreature bc) { // A summon whose caster was deleted before a reload comes back with no master. - if (Core.AOS && bc?.Summoned == true && - (bc.SummonMaster == Mobile || bc.SummonMaster is { Player: false } summonMaster && IsHostile(summonMaster))) + if (Core.AOS && + (bc?.SummonMaster == Mobile || bc?.SummonMaster is { Player: false } summonMaster && IsHostile(summonMaster))) { return true; } var master = Mobile.SummonMaster; - if (!Mobile.Summoned || master == null) + if (master == null) { return false; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index dc605b976..e3224ea93 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -775,7 +775,7 @@ public abstract partial class BaseAI var master = Mobile.ControlMaster; return target == Mobile || master != null && - (target == master || target is BaseCreature { Controlled: true } creature && creature.ControlMaster == master); + (target == master || target is BaseCreature creature && creature.ControlMaster == master); } /// diff --git a/Projects/UOContent/Mobiles/AI/FamiliarAI.cs b/Projects/UOContent/Mobiles/AI/FamiliarAI.cs index 2da24dc8d..2b5b22c29 100644 --- a/Projects/UOContent/Mobiles/AI/FamiliarAI.cs +++ b/Projects/UOContent/Mobiles/AI/FamiliarAI.cs @@ -152,7 +152,7 @@ public class FamiliarAI : BaseAI var combatant = target.Combatant; return combatant == Mobile || combatant == master || - combatant is BaseCreature { Controlled: true } pet && pet.ControlMaster == master; + combatant is BaseCreature pet && pet.ControlMaster == master; } // Closest mobile in a fight with the caster's side. diff --git a/Projects/UOContent/Mobiles/Abilities/Summon Undead/SummonUndeadCounter.cs b/Projects/UOContent/Mobiles/Abilities/Summon Undead/SummonUndeadCounter.cs index 3666d21ef..bfbf7be55 100644 --- a/Projects/UOContent/Mobiles/Abilities/Summon Undead/SummonUndeadCounter.cs +++ b/Projects/UOContent/Mobiles/Abilities/Summon Undead/SummonUndeadCounter.cs @@ -48,7 +48,7 @@ public abstract class SummonUndeadCounter : MonsterAbility summon = CreateSummon(source); summon.Team = source.Team; summon.Summoned = true; - summon.SummonMaster = source; + summon.Master = source; summon.FightMode = FightMode.Closest; summon.Combatant = target; summon.Home = source.Home; diff --git a/Projects/UOContent/Mobiles/Animals/Misc/PackHorse.cs b/Projects/UOContent/Mobiles/Animals/Misc/PackHorse.cs index ca2d4e3dd..822027184 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/PackHorse.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/PackHorse.cs @@ -137,7 +137,7 @@ namespace Server.Mobiles public static bool CheckAccess(BaseCreature animal, Mobile from) => from == animal || from.AccessLevel >= AccessLevel.GameMaster || from.Alive && animal.Controlled && !animal.IsDeadPet && - (from == animal.ControlMaster || from == animal.SummonMaster || animal.IsPetFriend(from)); + (from == animal.GetMaster() || animal.IsPetFriend(from)); public static void CombineBackpacks(BaseCreature animal) { diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs b/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs index 43e7a598c..f4b516447 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs @@ -239,8 +239,7 @@ public abstract partial class BaseMount : BaseCreature, IMount if (from.InRange(this, 1)) { var canAccess = from.AccessLevel >= AccessLevel.GameMaster - || Controlled && ControlMaster == from - || Summoned && SummonMaster == from; + || GetMaster() == from; if (canAccess) { diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Beetle.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Beetle.cs index bba194d3d..ffef6350b 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/Beetle.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/Beetle.cs @@ -71,7 +71,7 @@ namespace Server.Mobiles public override void OnHarmfulSpell(Mobile from) { - if (!Controlled && ControlMaster == null) + if (!Controlled) { CurrentSpeed = BoostedSpeed; } @@ -79,7 +79,7 @@ namespace Server.Mobiles public override void OnCombatantChange() { - if (Combatant == null && !Controlled && ControlMaster == null) + if (Combatant == null && !Controlled) { CurrentSpeed = PassiveSpeed; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index d37465796..e1b5dbdcf 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -445,9 +445,7 @@ namespace Server.Mobiles InvalidateProperties(); } - // ControlMaster and SummonMaster are views of this one reference, gated by Controlled and Summoned. - [SerializableField(15, getter: "private", setter: "private")] - [SaveFlag(nameof(ShouldSerializeMaster))] + // Follower bookkeeping brackets the assignment, so Master is hand-written. private Mobile _master; private bool ShouldSerializeMaster() => _master != null; @@ -1152,51 +1150,49 @@ namespace Server.Mobiles } } - // Assigned before Controlled is raised (SetControlMaster), so the setter cannot be gated. + // Whoever this creature answers to. ControlMaster, SummonMaster and GetMaster() gate it by + // Controlled and Summoned; an enraged creature has a master with neither flag. + [SerializableProperty(15, useField: nameof(_master))] + [SaveFlag(nameof(ShouldSerializeMaster))] [CommandProperty(AccessLevel.GameMaster)] - public Mobile ControlMaster + public Mobile Master { - get => _controlled ? _master : null; + get => _master; set { - if (ControlMaster == value || this == value) + if (_master == value || this == value) { return; } - SetMaster(value); + RemoveFollowers(); + _master = value; + AddFollowers(); if (value != null) { StopDeleteTimer(); } + + Delta(MobileDelta.Noto); + this.MarkDirty(); } } - // The summoner, controlled or not. Enraged creatures have one without being Summoned. - // A plain pet has no summoner, so assigning one would overwrite its owner. + /// The owner. Setting it sets . + [CommandProperty(AccessLevel.GameMaster)] + public Mobile ControlMaster + { + get => _controlled ? _master : null; + set => Master = value; + } + + /// The summoner, controlled or not. Setting it sets . [CommandProperty(AccessLevel.GameMaster)] public Mobile SummonMaster { - get => _controlled && !_summoned ? null : _master; - set - { - if (SummonMaster == value || this == value || _controlled && !_summoned) - { - return; - } - - SetMaster(value); - } - } - - private void SetMaster(Mobile value) - { - RemoveFollowers(); - Master = value; - AddFollowers(); - - Delta(MobileDelta.Noto); + get => _summoned ? _master : null; + set => Master = value; } // Fires on every assignment, not only changes: a reissued order is a command (retarget, re-anchor). @@ -2726,7 +2722,7 @@ namespace Server.Mobiles AnimateDeadSpell.Unregister(SummonMaster, this); } - if (Summoned && SummonMaster != null) + if (SummonMaster != null) { SummonFamiliarSpell.Unregister(SummonMaster, this); } @@ -2796,7 +2792,7 @@ namespace Server.Mobiles { base.AggressiveAction(aggressor, criminal); - if (Controlled && ControlMaster != null && NotorietyHandlers.CheckAggressor(ControlMaster.Aggressors, aggressor)) + if (ControlMaster != null && NotorietyHandlers.CheckAggressor(ControlMaster.Aggressors, aggressor)) { aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true)); } @@ -2904,7 +2900,7 @@ namespace Server.Mobiles } public override bool IsHarmfulCriminal(Mobile target) => - target != ControlMaster && (!Summoned || target != SummonMaster) && + target != GetMaster() && (target is not BaseCreature { InitialInnocent: true } creature || creature.Controlled) && (target is not PlayerMobile mobile || mobile.PermaFlags.Count <= 0) && base.IsHarmfulCriminal(target); @@ -2912,9 +2908,9 @@ namespace Server.Mobiles { base.CriminalAction(message); - if ((Controlled || Summoned) && _master?.Player == true) + if (GetMaster() is { Player: true } master) { - _master.CriminalAction(false); + master.CriminalAction(false); } } @@ -2922,7 +2918,7 @@ namespace Server.Mobiles { base.DoHarmful(target, indirect); - if (target == this || target == _master || !Controlled && !Summoned) + if (target == this || GetMaster() is not { } owner || target == owner) { return; } @@ -3343,20 +3339,8 @@ namespace Server.Mobiles return bonus; } - public Mobile GetMaster() - { - if (Controlled && ControlMaster != null) - { - return ControlMaster; - } - - if (Summoned && SummonMaster != null) - { - return SummonMaster; - } - - return null; - } + // Who answers for this creature: its owner or its summoner. Never an enraged creature's meer. + public Mobile GetMaster() => ControlMaster ?? SummonMaster; public virtual bool IsMonster => !Controlled || (GetMaster() as BaseCreature)?.IsMonster == true; @@ -3702,7 +3686,7 @@ namespace Server.Mobiles var m = ControlMaster; SetControlMaster(null); - SummonMaster = null; + Master = null; ReceivedHonorContext?.Cancel(); base.OnDelete(); @@ -3757,14 +3741,19 @@ namespace Server.Mobiles } public override bool CanBeRenamedBy(Mobile from) => - Controlled && from == ControlMaster && !from.Region.IsPartOf() || + from == ControlMaster && !from.Region.IsPartOf() || base.CanBeRenamedBy(from); public bool SetControlMaster(Mobile m) { if (m == null) { - ControlMaster = null; + // An uncontrolled summon keeps its caster. + if (_controlled) + { + Master = null; + } + Controlled = false; ControlTarget = null; ControlOrder = OrderType.None; @@ -3787,7 +3776,7 @@ namespace Server.Mobiles Home = Point3D.Zero; - ControlMaster = m; + Master = m; Controlled = true; ControlTarget = null; ControlOrder = OrderType.Come; @@ -3849,7 +3838,7 @@ namespace Server.Mobiles creature.RangeHome = 10; creature.Summoned = true; - creature.SummonMaster = caster; + creature.Master = caster; var pack = creature.Backpack; @@ -4006,9 +3995,11 @@ namespace Server.Mobiles return BardMaster; } - if ((_controlled || _summoned) && _master != null) + var master = GetMaster(); + + if (master != null) { - return _master; + return master; } return base.GetDamageMaster(damagee); diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs index 6da24edfe..917e928d3 100644 --- a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs +++ b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs @@ -94,7 +94,7 @@ public abstract partial class BaseFamiliar : BaseCreature { base.GetContextMenuEntries(from, ref list); - if (from.Alive && Controlled && from == ControlMaster && from.InRange(this, 14)) + if (from.Alive && from == ControlMaster && from.InRange(this, 14)) { list.Add(new ReleaseEntry()); } diff --git a/Projects/UOContent/Mobiles/Familiars/DarkWolf.cs b/Projects/UOContent/Mobiles/Familiars/DarkWolf.cs index 32e321011..a39c54d44 100644 --- a/Projects/UOContent/Mobiles/Familiars/DarkWolf.cs +++ b/Projects/UOContent/Mobiles/Familiars/DarkWolf.cs @@ -52,7 +52,7 @@ public partial class DarkWolfFamiliar : BaseFamiliar m_NextRestore = Core.Now + TimeSpan.FromSeconds(2.0); - var caster = ControlMaster ?? SummonMaster; + var caster = GetMaster(); if (caster != null) { diff --git a/Projects/UOContent/Mobiles/Familiars/ShadowWisp.cs b/Projects/UOContent/Mobiles/Familiars/ShadowWisp.cs index 004d9cb9f..1af1dc204 100644 --- a/Projects/UOContent/Mobiles/Familiars/ShadowWisp.cs +++ b/Projects/UOContent/Mobiles/Familiars/ShadowWisp.cs @@ -62,7 +62,7 @@ public partial class ShadowWispFamiliar : BaseFamiliar private void Flare() { - var caster = ControlMaster ?? SummonMaster; + var caster = GetMaster(); if (caster == null) { diff --git a/Projects/UOContent/Mobiles/Monsters/Ants/SolenHelper.cs b/Projects/UOContent/Mobiles/Monsters/Ants/SolenHelper.cs index ad97905dd..5fb37aec6 100644 --- a/Projects/UOContent/Mobiles/Monsters/Ants/SolenHelper.cs +++ b/Projects/UOContent/Mobiles/Monsters/Ants/SolenHelper.cs @@ -21,14 +21,9 @@ namespace Server.Mobiles { if (m is BaseCreature bc) { - if (bc.Controlled && bc.ControlMaster is PlayerMobile) + if (bc.GetMaster() is PlayerMobile master) { - return CheckRedFriendship(bc.ControlMaster); - } - - if (bc.Summoned && bc.SummonMaster is PlayerMobile) - { - return CheckRedFriendship(bc.SummonMaster); + return CheckRedFriendship(master); } } @@ -39,14 +34,9 @@ namespace Server.Mobiles { if (m is BaseCreature bc) { - if (bc.Controlled && bc.ControlMaster is PlayerMobile) + if (bc.GetMaster() is PlayerMobile master) { - return CheckBlackFriendship(bc.ControlMaster); - } - - if (bc.Summoned && bc.SummonMaster is PlayerMobile) - { - return CheckBlackFriendship(bc.SummonMaster); + return CheckBlackFriendship(master); } } @@ -57,13 +47,9 @@ namespace Server.Mobiles { if (from is BaseCreature bc) { - if (bc.Controlled && bc.ControlMaster is PlayerMobile) + if (bc.GetMaster() is PlayerMobile master) { - OnRedDamage(bc.ControlMaster); - } - else if (bc.Summoned && bc.SummonMaster is PlayerMobile) - { - OnRedDamage(bc.SummonMaster); + OnRedDamage(master); } } @@ -83,13 +69,9 @@ namespace Server.Mobiles { if (from is BaseCreature bc) { - if (bc.Controlled && bc.ControlMaster is PlayerMobile) + if (bc.GetMaster() is PlayerMobile master) { - OnBlackDamage(bc.ControlMaster); - } - else if (bc.Summoned && bc.SummonMaster is PlayerMobile) - { - OnBlackDamage(bc.SummonMaster); + OnBlackDamage(master); } } diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs index a7afa1b9e..cc6eb8cd0 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs @@ -92,15 +92,15 @@ namespace Server.Mobiles Karma = -1000; Tamable = false; - SummonMaster = summoner; + Master = summoner; } // The meer's FightMode.Evil against our negative karma would otherwise make us enemies. - public override bool IsEnemy(Mobile m) => m != SummonMaster && base.IsEnemy(m); + public override bool IsEnemy(Mobile m) => m != Master && base.IsEnemy(m); public override void OnThink() { - if (SummonMaster?.Deleted != false) + if (Master?.Deleted != false) { Delete(); } @@ -111,7 +111,7 @@ namespace Server.Mobiles */ else if (!Combat(this)) { - AIObject?.MoveTo(SummonMaster, 5); + AIObject?.MoveTo(Master, 5); } /* On OSI, if the summon attacks a mobile, the summoner meer also @@ -119,11 +119,11 @@ namespace Server.Mobiles is a player or controlled/summoned, and the summoner is not already engaged in combat. */ - else if (!Combat(SummonMaster)) + else if (!Combat(Master)) { if (Combatant.Player || Combatant is BaseCreature bc && bc.GetMaster() != null) { - SummonMaster.Combatant = Combatant; + Master.Combatant = Combatant; } } else diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs index e96e8cd1d..fe0a2e2d5 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs @@ -60,7 +60,7 @@ namespace Server.Mobiles public override bool InitialInnocent => true; // FightMode.Evil would otherwise target our own enraged creatures for their negative karma. - public override bool IsEnemy(Mobile m) => (m as BaseEnraged)?.SummonMaster != this && base.IsEnemy(m); + public override bool IsEnemy(Mobile m) => (m as BaseEnraged)?.Master != this && base.IsEnemy(m); public override void GenerateLoot() { @@ -88,7 +88,7 @@ namespace Server.Mobiles if (combatant is BaseCreature bc) { - if (bc.Controlled && bc.ControlMaster?.Deleted == false && bc.ControlMaster.Alive) + if (bc.ControlMaster is { Deleted: false, Alive: true }) { if (bc.ControlMaster.Map == Map && bc.ControlMaster.InRange(this, 12) && !UnderEffect(bc.ControlMaster)) diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs index 455418563..4d6dcf251 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/BladeSpirits.cs @@ -55,7 +55,7 @@ namespace Server.Mobiles public override bool BleedImmune => true; public override Poison PoisonImmune => Poison.Lethal; - public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || + public override bool FollowsAcquireRules => Core.AOS || SummonMaster?.Player != true || (Map?.Rules & MapRules.HarmfulRestrictions) != 0; public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) => diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs index efa78c6ba..c68ecee8a 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/EnergyVortex.cs @@ -63,7 +63,7 @@ namespace Server.Mobiles public override bool BleedImmune => true; public override Poison PoisonImmune => Poison.Lethal; - public override bool FollowsAcquireRules => Core.AOS || !Summoned || SummonMaster?.Player != true || + public override bool FollowsAcquireRules => Core.AOS || SummonMaster?.Player != true || (Map?.Rules & MapRules.HarmfulRestrictions) != 0; public override double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) => diff --git a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs index ff40211e8..21927419b 100644 --- a/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs +++ b/Projects/UOContent/Mobiles/Monsters/Misc/Melee/Golem.cs @@ -142,22 +142,17 @@ namespace Server.Mobiles public override void OnDamage(int amount, Mobile from, bool willKill) { - if (Controlled || Summoned) + if (GetMaster() is { Player: true } master && master.Map == Map && master.InRange(Location, 20)) { - var master = ControlMaster ?? SummonMaster; - - if (master?.Player == true && master.Map == Map && master.InRange(Location, 20)) + if (master.Mana >= amount) { - if (master.Mana >= amount) - { - master.Mana -= amount; - } - else - { - amount -= master.Mana; - master.Mana = 0; - master.Damage(amount); - } + master.Mana -= amount; + } + else + { + amount -= master.Mana; + master.Mana = 0; + master.Damage(amount); } } diff --git a/Projects/UOContent/Mobiles/Monsters/SE/FireBeetle.cs b/Projects/UOContent/Mobiles/Monsters/SE/FireBeetle.cs index f45f7a7d7..53c13fefe 100644 --- a/Projects/UOContent/Mobiles/Monsters/SE/FireBeetle.cs +++ b/Projects/UOContent/Mobiles/Monsters/SE/FireBeetle.cs @@ -59,7 +59,7 @@ namespace Server.Mobiles public override void OnHarmfulSpell(Mobile from) { - if (!Controlled && ControlMaster == null) + if (!Controlled) { CurrentSpeed = BoostedSpeed; } @@ -67,7 +67,7 @@ namespace Server.Mobiles public override void OnCombatantChange() { - if (Combatant == null && !Controlled && ControlMaster == null) + if (Combatant == null && !Controlled) { CurrentSpeed = PassiveSpeed; } diff --git a/Projects/UOContent/Multis/Houses/BaseHouse.cs b/Projects/UOContent/Multis/Houses/BaseHouse.cs index 212a60386..587f9907e 100644 --- a/Projects/UOContent/Multis/Houses/BaseHouse.cs +++ b/Projects/UOContent/Multis/Houses/BaseHouse.cs @@ -3660,12 +3660,7 @@ namespace Server.Multis return true; } - if (!(bc.Controlled || bc.Summoned)) - { - return false; - } - - m = bc.ControlMaster ?? bc.SummonMaster; + m = bc.GetMaster(); return m != null && (m.AccessLevel > AccessLevel.Player || IsFriend(m) || Access?.Contains(m) == true); } diff --git a/Projects/UOContent/Skills/Discordance.cs b/Projects/UOContent/Skills/Discordance.cs index 14c5a7ee7..15b7ed118 100644 --- a/Projects/UOContent/Skills/Discordance.cs +++ b/Projects/UOContent/Skills/Discordance.cs @@ -141,7 +141,7 @@ namespace Server.SkillHandlers { if (targ == from || targ is BaseCreature bc && (bc.BardImmune || !from.CanBeHarmful(bc, false)) && - !(bc.Controlled && bc.ControlMaster == from)) + bc.ControlMaster != from) { from.SendLocalizedMessage(1049535); // A song of discord would have no effect on that. } diff --git a/Projects/UOContent/Spells/Base/SpellHelper.cs b/Projects/UOContent/Spells/Base/SpellHelper.cs index e373bc6d8..537c56f1d 100644 --- a/Projects/UOContent/Spells/Base/SpellHelper.cs +++ b/Projects/UOContent/Spells/Base/SpellHelper.cs @@ -407,32 +407,8 @@ namespace Server.Spells }; } - public static Guild GetGuildFor(Mobile m) - { - var g = m.Guild as Guild; - - if (g == null && m is BaseCreature c) - { - m = c.ControlMaster; - - if (m != null) - { - g = m.Guild as Guild; - } - - if (g == null) - { - m = c.SummonMaster; - - if (m != null) - { - g = m.Guild as Guild; - } - } - } - - return g; - } + public static Guild GetGuildFor(Mobile m) => + m.Guild as Guild ?? (m as BaseCreature)?.GetMaster()?.Guild as Guild; public static bool ValidIndirectTarget(Mobile from, Mobile to) { @@ -473,29 +449,17 @@ namespace Server.Spells return false; } - if (bcTarg != null && (bcTarg.Controlled || bcTarg.Summoned)) + if (bcTarg?.GetMaster() is { } targMaster) { - if (bcTarg.ControlMaster == from || bcTarg.SummonMaster == from) - { - return false; - } - - if (p != null && (p.Contains(bcTarg.ControlMaster) || p.Contains(bcTarg.SummonMaster))) + if (targMaster == from || p?.Contains(targMaster) == true) { return false; } } - if (bcFrom != null && (bcFrom.Controlled || bcFrom.Summoned)) + if (bcFrom?.GetMaster() is { } fromMaster) { - if (bcFrom.ControlMaster == to || bcFrom.SummonMaster == to) - { - return false; - } - - p = Party.Get(to); - - if (p != null && (p.Contains(bcFrom.ControlMaster) || p.Contains(bcFrom.SummonMaster))) + if (fromMaster == to || Party.Get(to)?.Contains(fromMaster) == true) { return false; } diff --git a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs index 63788aa69..5ca42f997 100644 --- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs +++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs @@ -158,7 +158,7 @@ namespace Server.Mobiles Warmode = true; Summoned = true; - SummonMaster = caster; + Master = caster; ControlOrder = OrderType.Follow; ControlTarget = caster; diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index 9a7c08ab4..bd994b983 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -33,6 +33,7 @@ description: > - `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition - `BaseAI.OnAggressiveAction(aggressor)` is `bool`, not `void`: the AI owns the retaliation policy (`BaseCreature` no longer sets `Combatant` after it); an early `return;` that refused a fight -> `return false;`, otherwise `return true;`; swap-only overrides call `PreferCloserAggressor(aggressor)` instead of `base` -> same doc § Retaliation - `DamageEntries` is an inline `ref readonly ValueLinkList`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries +- `ControlMaster`/`SummonMaster` are views of one `Master`, gated by `Controlled`/`Summoned`; assignments still compile (they set `Master`). `ControlMaster ?? SummonMaster` and controlled-or-summoned pairs -> `GetMaster()`; a creature that sets `SummonMaster` without `Summoned` (RunUO's `BaseEnraged`) must read `Master`, because `SummonMaster` now reads null -> same doc § Masters ## Anti-Patterns - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 55aa32605..ae83888af 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -40,6 +40,12 @@ description: > trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see `dev-docs/content-patterns.md` § OnThink: the excess-call contract +9. **One master, read through views** -- `ControlMaster` (owner, only while `Controlled`), + `SummonMaster` (summoner, only while `Summoned`), `GetMaster()` (either: who answers for the + creature), `Master` (the stored reference, flags ignored; both setters assign it). Tame with + `SetControlMaster`, summon with `BaseCreature.Summon`. Don't repeat the view's flag: + `Controlled && ControlMaster == x` -> `ControlMaster == x`, but `!=` does not reduce -- see + `dev-docs/content-patterns.md` § Masters ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index e583364c6..b138ce9f0 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -267,6 +267,37 @@ public override int TreasureMapLevel => 3; // Drops treasure map public override double WeaponAbilityChance => 0.4; // Weapon ability chance ``` +### Masters: owner, summoner, responsible party + +A creature stores one master. Read it through the view that matches the question: + +| Read | Returns | Use it for | +|---|---|---| +| `ControlMaster` | the master while `Controlled`, else null | the owner: commands, bonding, friends, stabling | +| `SummonMaster` | the master while `Summoned`, else null | the summoner: dispel, summon acquire rules | +| `GetMaster()` | `ControlMaster ?? SummonMaster` | whoever answers for the creature: notoriety, kill credit, guild and party checks | +| `Master` | the stored reference, flags ignored | a creature's own AI following or defending a master it has no flag for | + +A master with neither flag is legal. A meer mage's enraged creatures carry their meer in `Master` +without being `Controlled` or `Summoned`, so `GetMaster()` is null and the meer never answers for them. + +To set a master, tame through `SetControlMaster(m)` (it raises `Controlled`, resets the order and +checks follower slots) and summon through `BaseCreature.Summon(...)`. Assign `Master` directly only +for a master with no flag. The `ControlMaster` and `SummonMaster` setters are aliases for `Master`: +they don't set `Controlled` or `Summoned`, so the value reads back as null until the flag is set. +Every assignment moves the creature's follower slots from the old master to the new one. + +The view already checks its flag, so don't repeat it: + +```csharp +// Redundant: ControlMaster is null unless Controlled +if (Controlled && ControlMaster == from) { ... } +if (ControlMaster == from) { ... } + +// Not equivalent: without the flag this is true for every wild creature +if (Controlled && ControlMaster != from) { ... } +``` + ### Creature Speeds (think vs move clocks) All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks: diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index 389c845e5..7bde0a898 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -540,6 +540,40 @@ An override that used to `return;` early to refuse a fight now returns `false`; `base.OnAggressiveAction` runs the full stock policy, including setting `Combatant`, so an AI that only wants the closer-aggressor swap calls `PreferCloserAggressor` instead. +## Masters: One Reference, Not Two + +RunUO kept `ControlMaster` and `SummonMaster` as two independent fields. ModernUO stores one `Master`. +`ControlMaster` and `SummonMaster` are views of it gated by `Controlled` and `Summoned`, and +`GetMaster()` is `ControlMaster ?? SummonMaster` (see `dev-docs/content-patterns.md` § Masters). + +`ControlMaster != null` implies `Controlled`, so a bare `ControlMaster` check needs no `Controlled` in front of it. + +Assignments compile unchanged, because both setters assign `Master`. Reads need a look: + +| RunUO | ModernUO | +|---|---| +| `bc.ControlMaster ?? bc.SummonMaster` | `bc.GetMaster()` | +| `bc.Controlled && bc.ControlMaster == m \|\| bc.Summoned && bc.SummonMaster == m` | `bc.GetMaster() == m` | +| `Controlled && ControlMaster == m` (`m` non-null) | `ControlMaster == m` | +| `SummonMaster` on a creature that sets it without `Summoned = true` (RunUO's `BaseEnraged`) | `Master` | + +Gotchas: + +- **A summon master without `Summoned` reads back null.** A RunUO creature that assigned + `SummonMaster = caster;` without `Summoned = true` and later read `SummonMaster` now reads null. + The write still compiles; only the read changes. Use `Master` for both, and override `IsEnemy` if the + creature must never target its master (see `BaseEnraged`). +- **One master per creature.** `SummonMaster = x` on a tamed pet replaces its owner, and + `ControlMaster = null` on an uncontrolled summon clears its caster. Code that tracked a different + owner and summoner keeps only the last assignment. +- **`ControlMaster = x;` is not a tame.** The setter only moves follower slots. It skips everything + `SetControlMaster(x)` does: raising `Controlled`, checking the follower cap, resetting the order + and `ControlTarget`, unlinking from the spawner, clearing the waypoint, home and guild. On its own it + leaves a creature that uses up `x`'s follower slots, can't be commanded and still reads as wild. + Use `SetControlMaster(x)`. +- **`SetControlMaster(null)` keeps an uncontrolled summon's caster.** It clears the master only + while the creature is `Controlled`. + ## Target Acquisition: `AcquireOnApproach` Is a Delay RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now From 95177fb64761095a634457128a57280bd9c140b0 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:01:40 -0700 Subject: [PATCH 13/31] fix: a deleted character's followers, and familiars that never unregistered (#2675) ## Summary **Followers of a deleted character.** `PlayerMobile.OnDelete` deleted stabled pets and left every active follower pointing at the deleted mobile. Pets stayed controlled by nobody, and after a restart they loaded as owned pets with no owner. Now, when a character is deleted: - bonded or dead pets and summons (controlled or not) are deleted; - unbonded pets are released: they go wild and despawn on the abandoned-pet timer; - escorts and hirelings are left alone, because they already notice a deleted master and walk off on their own. **Familiars and animated dead that never unregistered.** `BaseCreature` unregistered them in `OnAfterDelete`, after `OnDelete` had already cleared the master. Both calls did nothing, so `SummonFamiliarSpell.Table` and the animated-dead registry kept deleted creatures until the caster's next cast. The unregisters now run in `OnDelete`, before the master is cleared. ## Test plan - [x] `DeletedMasterTests` (6 cases, all failing on main): unbonded pet released; bonded and bonded-dead pets deleted; controlled and uncontrolled summons deleted; familiar unregistered on delete - [x] UOContent.Tests (1184 passed, 2 skipped) and Server.Tests (907) green --- .../Tests/Mobiles/DeletedMasterTests.cs | 114 ++++++++++++++++++ Projects/UOContent/Mobiles/BaseCreature.cs | 21 ++-- Projects/UOContent/Mobiles/PlayerMobile.cs | 29 +++++ 3 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs new file mode 100644 index 000000000..f01e5c7bd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/DeletedMasterTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Server.Spells.Necromancy; +using Xunit; + +namespace UOContent.Tests.Mobiles; + +// A deleted master must not leave creatures pointing at it. +[Collection("Sequential UOContent Tests")] +public class DeletedMasterTests : IDisposable +{ + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class Stub : BaseCreature + { + public Stub() : base(AIType.AI_Melee) => Body = 0xC9; + + public Stub(Serial serial) : base(serial) + { + } + } + + private Stub NewCreature() + { + var bc = new Stub(); + _created.Add(bc); + return bc; + } + + private PlayerMobile NewPlayer() + { + var pm = new PlayerMobile(World.NewMobile); + pm.DefaultMobileInit(); + World.AddEntity(pm); + _created.Add(pm); + return pm; + } + + [Fact] + public void DeletingAPlayer_ReleasesAnUnbondedPet() + { + var owner = NewPlayer(); + var pet = NewCreature(); + pet.SetControlMaster(owner); + + owner.Delete(); + + Assert.False(pet.Deleted); + Assert.False(pet.Controlled); + Assert.Null(pet.Master); + Assert.True(pet.PendingDeleteTimer?.Running); + } + + [Theory] + [InlineData(false)] // bonded + [InlineData(true)] // bonded and dead + public void DeletingAPlayer_DeletesABondedPet(bool dead) + { + var owner = NewPlayer(); + var pet = NewCreature(); + pet.SetControlMaster(owner); + pet.IsBonded = true; + pet.IsDeadPet = dead; + + owner.Delete(); + + Assert.True(pet.Deleted); + } + + [Theory] + [InlineData(false)] // energy vortex + [InlineData(true)] // controlled summon + public void DeletingAPlayer_DeletesItsSummons(bool controlled) + { + var caster = NewPlayer(); + var summon = NewCreature(); + + if (controlled) + { + summon.SetControlMaster(caster); + } + + summon.Summoned = true; + summon.Master = caster; + + caster.Delete(); + + Assert.True(summon.Deleted); + } + + [Fact] + public void DeletingAFamiliar_UnregistersIt() + { + var caster = NewPlayer(); + var familiar = NewCreature(); + familiar.SetControlMaster(caster); + familiar.Summoned = true; + SummonFamiliarSpell.Table[caster] = familiar; + + familiar.Delete(); + + Assert.False(SummonFamiliarSpell.Table.ContainsKey(caster)); + } +} diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index e1b5dbdcf..f0210d4f6 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2717,16 +2717,6 @@ namespace Server.Mobiles FocusMob = null; - if (IsAnimatedDead) - { - AnimateDeadSpell.Unregister(SummonMaster, this); - } - - if (SummonMaster != null) - { - SummonFamiliarSpell.Unregister(SummonMaster, this); - } - if (MLQuestSystem.Enabled) { MLQuestSystem.HandleDeletion(this); @@ -3683,6 +3673,17 @@ namespace Server.Mobiles { CreatureEvents.CreatureDeletedEvent(this); + // Both registries are keyed by the master, which is cleared below. + if (IsAnimatedDead) + { + AnimateDeadSpell.Unregister(SummonMaster, this); + } + + if (SummonMaster != null) + { + SummonFamiliarSpell.Unregister(SummonMaster, this); + } + var m = ControlMaster; SetControlMaster(null); diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index f1a81a53a..a138371ba 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -3835,6 +3835,35 @@ namespace Server.Mobiles Stabled = null; } + + if (_allFollowers?.Count > 0) + { + // Releasing or deleting a follower removes it from _allFollowers. + using var followers = PooledRefQueue.Create(_allFollowers.Count); + + foreach (var follower in _allFollowers) + { + // Escorts and hirelings notice a deleted master and walk off on their own. + if (follower is BaseCreature bc and not (BaseEscortable or BaseHire)) + { + followers.Enqueue(bc); + } + } + + while (followers.Count > 0) + { + var bc = followers.Dequeue(); + if (bc.Summoned || bc.IsBonded || bc.IsDeadPet) + { + bc.Delete(); + } + else + { + // An unbonded pet goes wild and despawns on the abandoned-pet timer. + bc.ControlOrder = OrderType.Release; + } + } + } } public override int ComputeMovementSpeed(Direction dir, bool checkTurning = true) From 37a80e344739ff3a06e64848eb0d133211de9eeb Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:50:10 +0200 Subject: [PATCH 14/31] fix: Begging's failed-roll refusal (500404) goes to the beggar, not the target (#2678) ### Summary Fixes an issue where beggars that fail their skill check don't get the error message saying the NPC is unwilling to give them any money. --- Projects/UOContent/Skills/Begging.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Skills/Begging.cs b/Projects/UOContent/Skills/Begging.cs index 83180baca..7bc2e1d85 100644 --- a/Projects/UOContent/Skills/Begging.cs +++ b/Projects/UOContent/Skills/Begging.cs @@ -158,7 +158,7 @@ namespace Server.SkillHandlers } else { - _target.SendLocalizedMessage(500404); // They seem unwilling to give you any money. + _from.SendLocalizedMessage(500404); // They seem unwilling to give you any money. } const int TargeterCooldown = 30000; // 30s From 225c634bf798e545972c16e3465d7896412eefcd Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:55:07 -0700 Subject: [PATCH 15/31] test: stop the 1 ms promotion window leaking out of StepCacheLifecycleTests (#2679) ## Summary `Find_TwoPathfindsOverlappingChunks_PromoteToBuildOnSecondFind` fails intermittently in CI with `second Find through overlapping chunks must promote (got 0 builds)`. **Cause:** `SecondTouchAfterWindow_RestartsTheCount` and `TwoFindsAcrossTheWindow_DoNotPromote` set `StepCache.Instance.MissPromotionWindowMs = 1` and never restore it, and `Clear()` does not reset it. Both classes share the `Sequential Pathfinding Tests` collection, so when a lifecycle test runs first, the promotion test's two Finds have to land within 1 ms of each other. On Linux, `Environment.TickCount` moves in coarse steps of several ms, so even a sub-millisecond test can see the window lapse. The second Find then restarts the miss count instead of promoting. On Windows the ~15.6 ms tick nearly always reads the same value for both Finds, which is why it didn't reproduce locally. **Fix (tests only):** - `StepCacheLifecycleTests` implements `IDisposable` and restores the window after each test. - The promotion test sets the window it depends on (30 s) instead of inheriting it. ## Test plan - [x] Reproduced deterministically: with a temporary 20 ms sleep between the two Finds, the test fails whenever the lifecycle tests run first and passes when run alone - [x] With the sleep still in, the test passes with the Dispose restore alone and with both fixes - [x] All pathfinding tests pass (123/123) with the sleep removed --- .../Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs | 3 +++ .../Tests/Engines/Pathing/StepCacheLifecycleTests.cs | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs index 20d729c0f..667995278 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/BitmapAStarAlgorithmTests.cs @@ -371,6 +371,9 @@ public class BitmapAStarAlgorithmTests { StepCache.Instance.Clear(); StepCache.Instance.MissPromotionThreshold = 2; + // Pinned: promotion needs both Finds inside the window, and Linux's coarse TickCount can + // step several ms between two back-to-back Finds. + StepCache.Instance.MissPromotionWindowMs = 30_000; var map = Map.Maps[1]; var stub = new DefaultWalkerStub(); diff --git a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs index 7b630173c..b4257f112 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Pathing/StepCacheLifecycleTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Reflection; using System.Threading; @@ -14,8 +15,14 @@ namespace Server.Tests.Pathfinding; /// layers, and LRU eviction. /// [Collection("Sequential Pathfinding Tests")] -public class StepCacheLifecycleTests +public class StepCacheLifecycleTests : IDisposable { + // The window is a knob on the singleton and Clear() leaves it alone, so a test that narrows + // it would otherwise leak the narrow window into every later test in the collection. + private readonly uint _savedWindowMs = StepCache.Instance.MissPromotionWindowMs; + + public void Dispose() => StepCache.Instance.MissPromotionWindowMs = _savedWindowMs; + /// Resets to a known state and returns the singleton. private static StepCache FreshCache(int promotionThreshold) { From e440c6fafa09ce7b34ba2e63f3e36a6ccbb45316 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:32:01 +0200 Subject: [PATCH 16/31] fix: stealing no longer takes items out of a locked container (#2681) `TryStealItem` doesn't check for a locked container, so an item that can still be targeted inside one (the gump stays open when it gets locked, or the client targets by serial) is stolen without picking the lock. It now refuses with 501747 ("It appears to be locked."); GMs are exempt, and stealing the locked container itself is unchanged. It checks the lock directly rather than calling `CheckLift`, which would also refuse items in deco containers. --- Projects/UOContent/Skills/Stealing.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Projects/UOContent/Skills/Stealing.cs b/Projects/UOContent/Skills/Stealing.cs index 99c92a4c9..01c530c79 100644 --- a/Projects/UOContent/Skills/Stealing.cs +++ b/Projects/UOContent/Skills/Stealing.cs @@ -45,6 +45,9 @@ public static class Stealing public static bool IsEmptyHanded(Mobile from) => from.FindItemOnLayer(Layer.OneHanded) == null && from.FindItemOnLayer(Layer.TwoHanded) == null; + private static bool IsInLockedContainer(Item item) => + item.Parent is Item parent && (parent is LockableContainer { Locked: true } || IsInLockedContainer(parent)); + public static TimeSpan OnUse(Mobile m) { if (!IsEmptyHanded(m)) @@ -249,6 +252,10 @@ public static class Stealing { _thief.SendLocalizedMessage(502710); // You can't steal that! } + else if (_thief.AccessLevel < AccessLevel.GameMaster && IsInLockedContainer(toSteal)) + { + _thief.SendLocalizedMessage(501747); // It appears to be locked. + } else { var w = toSteal.Weight + toSteal.TotalWeight; From edd7553c6f35420df33543e0d24fb1c7f785b427 Mon Sep 17 00:00:00 2001 From: Reetus <6239195+Reetus@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:32:44 +0700 Subject: [PATCH 17/31] fix: a juka mage buff no longer permanently raises a juka lord (#2684) ## Summary Fixes Juka buff so that restored values are correct. --- Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs index a0d486535..10c6b078c 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Jukas/JukaMage.cs @@ -126,6 +126,10 @@ namespace Server.Mobiles SpellHelper.Turn(this, toBuff); + var maxHits = toBuff.HitsMaxSeed; + var rawStr = toBuff.RawStr; + var rawDex = toBuff.RawDex; + var toScale = toBuff.HitsMaxSeed; if (toScale > 0) @@ -154,9 +158,6 @@ namespace Server.Mobiles toBuff.FixedParticles(0x375A, 10, 15, 5017, EffectLayer.Waist); toBuff.PlaySound(0x1EE); - var maxHits = toBuff.HitsMaxSeed; - var rawStr = toBuff.RawStr; - var rawDex = toBuff.RawDex; Timer.StartTimer( TimeSpan.FromSeconds(20.0), From d4531cd94b739613155225c234900de9f47d2c88 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:24:44 -0700 Subject: [PATCH 18/31] fix: enumerators that end early, repeat entries, or never end (#2685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2682. Thanks @DatMoshu for the detailed report and repro. While fixing it I audited the other map, item and multi enumerators for the same class of defect. None repeats #2682 itself, but four others turned up. All are fixed here. ## Fixes - **`StaticTileEnumerator` (#2682)**: `SetMulti` returned `SetTile()` for the first multi whose bounds contained the point. If that multi had no tile at the cell, the enumeration ended and every later multi in the sector was skipped. Movement and line of sight lost a floor that stood inside another multi's bounding box. It now moves on to the next multi. - **`MultiBoundsEnumerator`**: deduplication used one static `HashSet` that every new query cleared. A query run inside another made the outer query skip multis or return them twice. Each multi is now yielded only from the sector that holds its `Location`; the bounds filter guarantees that sector is visited. A per-enumerator set covers a multi that isn't registered in that sector (components changed after placement). Normal queries allocate nothing. - **`Item`/`Mobile`/`ClientDistanceEnumerator`**: ring sectors were bounded only by `sx >= _sectorStartX`. Ring cells past the map edge resolved to the shared invalid sector, once per cell. Entities at off-map coordinates were returned more than once, and sectors outside the query were created lazily. The check now covers all four sides. Results match the bounds enumerators, which never returned off-map entities. - **`BaseBoat.MovingEntitiesEnumerator`**: `GetMovingEntities` handles a null map, but the enumerator constructor still called `boat.Map.GetMobilesInBounds` and threw. A boat with no map now gets empty enumerators. - **`BaseDoor.ChainEnumerator`**: the walk stopped only on returning to the start door. Links are set in pairs, so `[ChainLink A B C` followed by `[Link B C` leaves A→B→C→B, and enumerating that chain never ended. The walk now stops on any door it has already yielded. Only reachable when `UseChainedFunctionality` is true, which is off in base and in all shipped content. ## Testing - A regression test for each fix except the boat null guard. Each failed before its fix and passes after. - Server.Tests 910/910. UOContent.Tests 1185 passed, 2 skipped. - Test note: the `MultiComponentList(List)` constructor drops entries after the first whose `Flags` are 0. The new test multis use non-zero flags. --- .../Maps/ItemByDistanceEnumeratorTests.cs | 33 +++++++++++ .../Tests/Maps/MultiEnumeratorTests.cs | 46 +++++++++++++++ .../Tests/Maps/StaticTileEnumeratorTests.cs | 58 +++++++++++++++++-- .../Maps/Map.ClientByDistanceEnumerator.cs | 18 ++++-- .../Maps/Map.ItemByDistanceEnumerator.cs | 18 ++++-- .../Maps/Map.MobileByDistanceEnumerator.cs | 18 ++++-- Projects/Server/Maps/Map.MultiEnumerator.cs | 37 ++++++++---- .../Server/Maps/Map.StaticTileEnumerator.cs | 7 ++- .../Tests/Items/Doors/DoorChainTests.cs | 40 +++++++++++++ .../Items/Construction/Doors/BaseDoor.cs | 26 ++++++++- Projects/UOContent/Multis/Boats/BaseBoat.cs | 14 ++++- 11 files changed, 279 insertions(+), 36 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs diff --git a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs index 458409705..fb12d0ab6 100644 --- a/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/ItemByDistanceEnumeratorTests.cs @@ -436,6 +436,39 @@ public class ItemByDistanceEnumeratorTests } } + [Fact] + public void ItemByDistanceEnumerator_MatchesBoundsEnumeratorAtMapEdge() + { + var map = Map.Felucca; + var center = new Point3D(5, 5, 0); + const int range = 18; + + // Off-map items live in the shared invalid sector, which ring cells past the map edge resolve to + var items = new TestItem[1]; + try + { + items[0] = CreateItem(map, new Point3D(3, -2, 0)); + + var byDistance = new List(); + foreach (var (item, _) in map.GetItemsInRangeByDistance(center, range)) + { + byDistance.Add(item); + } + + var inRange = new List(); + foreach (var item in map.GetItemsInRange(center, range)) + { + inRange.Add(item); + } + + Assert.Equal(inRange, byDistance); + } + finally + { + DeleteAll(items); + } + } + private static TestItem CreateItem(Map map, Point3D location) { var Item = new TestItem(World.NewItem); diff --git a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs index cb06fe48d..c064623bf 100644 --- a/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/MultiEnumeratorTests.cs @@ -386,6 +386,35 @@ public class MultiEnumeratorTests } } + [Fact] + public void MultiEnumerator_NestedQueryDoesNotDisturbOuterDeduplication() + { + var map = Map.Felucca; + + // Components reach two tiles either side, so at x = 1024 the multi spans sectors 63 and 64 + var spanning = new SpanningMulti(); + try + { + spanning.MoveToWorld(new Point3D(1024, 1000, 0), map); + + var found = new List(); + foreach (var multi in map.GetMultisInBounds(new Rectangle2D(1000, 990, 48, 20))) + { + found.Add(multi); + + foreach (var _ in map.GetMultisInBounds(new Rectangle2D(3000, 3000, 4, 4))) + { + } + } + + Assert.Equal([spanning], found); + } + finally + { + spanning.Delete(); + } + } + private static TestMulti CreateMulti(Map map, Point3D location) { var multi = new TestMulti(); @@ -408,5 +437,22 @@ public class MultiEnumeratorTests { } } + + private class SpanningMulti : BaseMulti + { + // Entries after the first need non-zero flags or MultiComponentList drops them + private static readonly MultiComponentList _components = new( + [ + new MultiTileEntry(0x1, -2, 0, 0, TileFlag.Background), + new MultiTileEntry(0x1, 2, 0, 0, TileFlag.Background) + ] + ); + + public SpanningMulti() : base(0x1) + { + } + + public override MultiComponentList Components => _components; + } } diff --git a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs index 1ac335f7a..edce65c89 100644 --- a/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs +++ b/Projects/Server.Tests/Tests/Maps/StaticTileEnumeratorTests.cs @@ -293,6 +293,55 @@ public class StaticTileEnumeratorTests } } + [Fact] + public void StaticTileEnumerator_MultiWithEmptyCellDoesNotHideLaterMultis() + { + var map = Map.Felucca; + var location = new Point2D(1101, 1100); + + TestMulti outer = null; + TestMulti inner = null; + try + { + // Outer's bounds cover the probe cell but it has no tile there; placing it first puts it + // ahead of inner in the sector's multi list + outer = new TestMulti(World.NewItem, GappedComponents); + outer.MoveToWorld(new Point3D(1100, 1100, 0), map); + + inner = new TestMulti(World.NewItem, SingleTileComponents); + inner.MoveToWorld(new Point3D(location.X, location.Y, 20), map); + + var tiles = new List(); + foreach (var tile in new Map.StaticTileEnumerable(map, location, includeStatics: false, includeMultis: true)) + { + tiles.Add(tile); + } + + // Only inner sits at z 20; item ids are masked by tiledata, which CI does not have + var single = Assert.Single(tiles); + Assert.Equal(20, single.Z); + } + finally + { + outer?.Delete(); + inner?.Delete(); + } + } + + // Entries after the first need non-zero flags or MultiComponentList drops them + private static readonly MultiComponentList GappedComponents = new( + [ + new MultiTileEntry(0x1, 0, 0, 0, TileFlag.Background), + new MultiTileEntry(0x2, 2, 0, 0, TileFlag.Background) + ] + ); + + private static readonly MultiComponentList SingleTileComponents = new( + [ + new MultiTileEntry(0x3, 0, 0, 0, TileFlag.Background) + ] + ); + private static TestMulti CreateMultiWithComponents(Map map, Point3D location) { var multi = new TestMulti(World.NewItem); @@ -302,11 +351,12 @@ public class StaticTileEnumeratorTests private class TestMulti : BaseMulti { - public TestMulti(Serial serial) : base(serial) - { - } + private readonly MultiComponentList _components; - public override MultiComponentList Components => DefaultComponents; + public TestMulti(Serial serial, MultiComponentList components = null) : base(serial) => + _components = components ?? DefaultComponents; + + public override MultiComponentList Components => _components; private static readonly MultiComponentList DefaultComponents = new( [ diff --git a/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs b/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs index eb79410ae..54d61797c 100644 --- a/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs +++ b/Projects/Server/Maps/Map.ClientByDistanceEnumerator.cs @@ -85,6 +85,9 @@ public partial class Map private Rectangle2D _bounds; private int _sectorStartX; + private int _sectorStartY; + private int _sectorEndX; + private int _sectorEndY; private int _maxRing; private int _ring; // -1 = uninitialized, then 0.._maxRing @@ -115,11 +118,11 @@ public partial class Map var centerSectorX = center.m_X / SectorSize; var centerSectorY = center.m_Y / SectorSize; - map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY); + map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY); // Calculate max ring based on bounds - var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX); - var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY); + var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX); + var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY); _maxRing = Math.Max(dx, dy); } @@ -202,7 +205,7 @@ public partial class Map _ringIndex = 0; sx = _center.m_X / SectorSize; sy = _center.m_Y / SectorSize; - return sx >= _sectorStartX; + return IsSectorInBounds(sx, sy); } sx = sy = 0; @@ -225,7 +228,7 @@ public partial class Map _ringIndex = nextIndex; CalculatePositionFromIndex(nextIndex, out sx, out sy); - if (sx >= _sectorStartX) + if (IsSectorInBounds(sx, sy)) { return true; } @@ -233,6 +236,11 @@ public partial class Map } + // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsSectorInBounds(int sx, int sy) => + sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY; + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CalculatePositionFromIndex(int index, out int x, out int y) { diff --git a/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs b/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs index 207928f68..91da4969f 100644 --- a/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs +++ b/Projects/Server/Maps/Map.ItemByDistanceEnumerator.cs @@ -113,6 +113,9 @@ public partial class Map private Rectangle2D _bounds; private int _sectorStartX; + private int _sectorStartY; + private int _sectorEndX; + private int _sectorEndY; private int _maxRing; private int _ring; // -1 = uninitialized, then 0.._maxRing @@ -143,11 +146,11 @@ public partial class Map var centerSectorX = center.m_X / SectorSize; var centerSectorY = center.m_Y / SectorSize; - map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY); + map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY); // Calculate max ring based on bounds - var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX); - var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY); + var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX); + var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY); _maxRing = Math.Max(dx, dy); } @@ -228,7 +231,7 @@ public partial class Map _ringIndex = 0; sx = _center.m_X / SectorSize; sy = _center.m_Y / SectorSize; - return sx >= _sectorStartX; + return IsSectorInBounds(sx, sy); } sx = sy = 0; @@ -251,7 +254,7 @@ public partial class Map _ringIndex = nextIndex; CalculatePositionFromIndex(nextIndex, out sx, out sy); - if (sx >= _sectorStartX) + if (IsSectorInBounds(sx, sy)) { return true; } @@ -259,6 +262,11 @@ public partial class Map } + // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsSectorInBounds(int sx, int sy) => + sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY; + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CalculatePositionFromIndex(int index, out int x, out int y) { diff --git a/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs b/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs index e5a8d0b91..92e9dcbb2 100644 --- a/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs +++ b/Projects/Server/Maps/Map.MobileByDistanceEnumerator.cs @@ -108,6 +108,9 @@ public partial class Map private Rectangle2D _bounds; private int _sectorStartX; + private int _sectorStartY; + private int _sectorEndX; + private int _sectorEndY; private int _maxRing; private int _ring; // -1 = uninitialized, then 0.._maxRing @@ -138,11 +141,11 @@ public partial class Map var centerSectorX = center.m_X / SectorSize; var centerSectorY = center.m_Y / SectorSize; - map.CalculateSectors(_bounds, out _sectorStartX, out var sectorStartY, out var sectorEndX, out var sectorEndY); + map.CalculateSectors(_bounds, out _sectorStartX, out _sectorStartY, out _sectorEndX, out _sectorEndY); // Calculate max ring based on bounds - var dx = Math.Max(centerSectorX - _sectorStartX, sectorEndX - centerSectorX); - var dy = Math.Max(centerSectorY - sectorStartY, sectorEndY - centerSectorY); + var dx = Math.Max(centerSectorX - _sectorStartX, _sectorEndX - centerSectorX); + var dy = Math.Max(centerSectorY - _sectorStartY, _sectorEndY - centerSectorY); _maxRing = Math.Max(dx, dy); } @@ -223,7 +226,7 @@ public partial class Map _ringIndex = 0; sx = _center.m_X / SectorSize; sy = _center.m_Y / SectorSize; - return sx >= _sectorStartX; + return IsSectorInBounds(sx, sy); } sx = sy = 0; @@ -246,7 +249,7 @@ public partial class Map _ringIndex = nextIndex; CalculatePositionFromIndex(nextIndex, out sx, out sy); - if (sx >= _sectorStartX) + if (IsSectorInBounds(sx, sy)) { return true; } @@ -254,6 +257,11 @@ public partial class Map } + // Ring cells outside the clamped sector range would resolve to the shared invalid sector, once per cell + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsSectorInBounds(int sx, int sy) => + sx >= _sectorStartX && sx <= _sectorEndX && sy >= _sectorStartY && sy <= _sectorEndY; + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CalculatePositionFromIndex(int index, out int x, out int y) { diff --git a/Projects/Server/Maps/Map.MultiEnumerator.cs b/Projects/Server/Maps/Map.MultiEnumerator.cs index b0e069a75..bbd04a53c 100644 --- a/Projects/Server/Maps/Map.MultiEnumerator.cs +++ b/Projects/Server/Maps/Map.MultiEnumerator.cs @@ -85,8 +85,6 @@ public partial class Map public MultiBoundsEnumerable GetMultisInBounds(Rectangle2D bounds, bool makeBoundsInclusive = false) where T : BaseMulti => new(this, bounds, makeBoundsInclusive); - private static readonly HashSet _sharedDupes = []; - public ref struct MultiSectorEnumerable(Map map, Point2D loc) where T : BaseMulti { public static MultiSectorEnumerable Empty @@ -193,13 +191,11 @@ public partial class Map private int _currentVersion; private Sector _currentSector; private T _current; - + private HashSet _unanchoredSeen; [MethodImpl(MethodImplOptions.AggressiveInlining)] public MultiBoundsEnumerator(Map map, Rectangle2D bounds, bool makeBoundsInclusive) { - _sharedDupes.Clear(); - _map = map; _bounds = bounds; @@ -255,14 +251,10 @@ public partial class Map while (++_currentIndex < _currentList.Length) { var item = _currentList[_currentIndex]; - if (item is T { Deleted: false } o && bounds.Contains(o.Location)) + if (item is T { Deleted: false } o && bounds.Contains(o.Location) && ShouldYieldFromCurrentSector(map, o)) { - // Multis can span multiple sectors, so we need to deduplicate - if (_sharedDupes.Add(o.Serial)) - { - _current = o; - return true; - } + _current = o; + return true; } } } @@ -290,6 +282,27 @@ public partial class Map } } + // A multi is listed in every sector it spans, but its location is in exactly one sector, which the bounds + // filter guarantees is visited. Yield it from that sector only. A multi missing from its location's sector + // (components changed after it was placed) falls back to a per-enumerator set, so nested queries stay independent. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool ShouldYieldFromCurrentSector(Map map, T multi) + { + var home = map.GetSector(map.Bound(multi.Location)); + + if (home == _currentSector) + { + return true; + } + + if (home.Multis.Contains(multi)) + { + return false; + } + + return (_unanchoredSeen ??= []).Add(multi.Serial); + } + public T Current { [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/Server/Maps/Map.StaticTileEnumerator.cs b/Projects/Server/Maps/Map.StaticTileEnumerator.cs index 27075b59d..73fec0684 100644 --- a/Projects/Server/Maps/Map.StaticTileEnumerator.cs +++ b/Projects/Server/Maps/Map.StaticTileEnumerator.cs @@ -99,7 +99,12 @@ public partial class Map { _tiles = multi.Components.Tiles[offsetX][offsetY]; _index = -1; - return SetTile(); + + // A multi can have no tile at this cell; later multis in the sector still may + if (SetTile()) + { + return true; + } } } diff --git a/Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs b/Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs new file mode 100644 index 000000000..ed399714d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Doors/DoorChainTests.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using Server.Items; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class DoorChainTests +{ + [Fact] + public void GetChain_LoopNotThroughStart_YieldsEachDoorOnce() + { + var a = new MetalDoor(DoorFacing.WestCW); + var b = new MetalDoor(DoorFacing.WestCW); + var c = new MetalDoor(DoorFacing.WestCW); + + try + { + // A -> B -> C -> B: the chain never returns to A + a.Link = b; + b.Link = c; + c.Link = b; + + var chain = new List(); + foreach (var door in a.GetChain()) + { + chain.Add(door); + Assert.True(chain.Count <= 3, "Chain enumeration did not terminate"); + } + + Assert.Equal([a, b, c], chain); + } + finally + { + a.Delete(); + b.Delete(); + c.Delete(); + } + } +} diff --git a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs index ef779760b..e95e87c13 100644 --- a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs +++ b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs @@ -484,8 +484,9 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable } else { - _current = _current.Link; - valid = _current?.Deleted == false && _current != _door; + var next = _current.Link; + valid = next?.Deleted == false && !IsVisited(next); + _current = next; } if (!valid) @@ -497,6 +498,27 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable return valid; } + // Links are set pairwise by GMs, so a chain can loop back to a door other than the start + private readonly bool IsVisited(BaseDoor door) + { + var visited = _door; + + while (true) + { + if (visited == door) + { + return true; + } + + if (visited == _current || visited == null) + { + return false; + } + + visited = visited.Link; + } + } + public BaseDoor Current { [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index ed618b91a..743790a1e 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -1924,8 +1924,18 @@ namespace Server.Multis _includeBoat = includeBoat; _boat = boat; - _mobiles = boat.Map.GetMobilesInBounds(bounds).GetEnumerator(); - _items = boat.Map.GetItemsInBounds(bounds).GetEnumerator(); + var map = boat.Map; + + if (map == null) + { + _mobiles = Map.MobileBoundsEnumerable.Empty.GetEnumerator(); + _items = Map.ItemBoundsEnumerable.Empty.GetEnumerator(); + } + else + { + _mobiles = map.GetMobilesInBounds(bounds).GetEnumerator(); + _items = map.GetItemsInBounds(bounds).GetEnumerator(); + } _iterateMobiles = true; _iterateItems = true; } From d26ae63e94db3553cfa99d47451b818c9da3eb07 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:48:10 +0200 Subject: [PATCH 19/31] fix: duel teams start on their own arena points, in formation (#2686) * fix: duel teams start on their own arena points, in formation MoveInside clamped the team index with Math.Min instead of Math.Max, so every team started on the west edge point. The formation rotation also reused the already-rotated X when computing Y, stacking players on the north and south edges, and the SW/NE corners swapped axes, which leaves the corner formation pointing out of the arena. * refactor: one start point index clamp for GetBaseStartPoint and MoveInside The clamp was written twice, and only one copy was correct. * refactor: leave the placement loop as it was Only the rotation lines in the loop belong to the fix. --- Projects/UOContent/Engines/ConPVP/Arena.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Projects/UOContent/Engines/ConPVP/Arena.cs b/Projects/UOContent/Engines/ConPVP/Arena.cs index d31820339..5c0c3d6f0 100644 --- a/Projects/UOContent/Engines/ConPVP/Arena.cs +++ b/Projects/UOContent/Engines/ConPVP/Arena.cs @@ -226,8 +226,8 @@ namespace Server.Engines.ConPVP new[,] { { 0, -1 }, { -1, 0 } }, // south new[,] { { +1, 0 }, { 0, +1 } }, // nw new[,] { { -1, 0 }, { 0, -1 } }, // se - new[,] { { 0, +1 }, { +1, 0 } }, // sw - new[,] { { 0, -1 }, { -1, 0 } } // ne + new[,] { { +1, 0 }, { 0, -1 } }, // sw + new[,] { { -1, 0 }, { 0, +1 } } // ne }; private bool m_Active; @@ -535,11 +535,13 @@ namespace Server.Engines.ConPVP public override string ToString() => "..."; - public Point3D GetBaseStartPoint(int index) => Points.Points[Math.Max(index, 0) % Points.Points.Length]; + private int StartPointIndex(int index) => Math.Max(index, 0) % Points.Points.Length; + + public Point3D GetBaseStartPoint(int index) => Points.Points[StartPointIndex(index)]; public void MoveInside(DuelPlayer[] players, int index) { - index = Math.Min(index, 0) % Points.Points.Length; + index = StartPointIndex(index); var start = Points.Points[index]; @@ -570,10 +572,10 @@ namespace Server.Engines.ConPVP p = offsets[^1]; } - p.X = p.X * matrix[0, 0] + p.Y * matrix[0, 1]; - p.Y = p.X * matrix[1, 0] + p.Y * matrix[1, 1]; + var x = p.X * matrix[0, 0] + p.Y * matrix[0, 1]; + var y = p.X * matrix[1, 0] + p.Y * matrix[1, 1]; - mob.MoveToWorld(new Point3D(start.X + p.X, start.Y + p.Y, start.Z), m_Facet); + mob.MoveToWorld(new Point3D(start.X + x, start.Y + y, start.Z), m_Facet); mob.Direction = mob.GetDirectionTo(Wall); Players.Add(mob); From ab89cd80f5dd3ca1220bffad5c599184d6267a23 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:53:41 +0200 Subject: [PATCH 20/31] fix: release a connection's gumps on disconnect (#2689) `GumpSystem` clears a connection's gumps on `EventSink.Disconnected`, but `Mobile.NetState` raises it after the state is already null, so nothing is ever removed: every connection that touched a gump stays in `_gumps` until restart, with any gumps still open when it closed. It now listens to `BeforeDisconnected`, while `m.NetState` still returns the connection. That event also fires on login, where there is no previous state and the handler does nothing. --- Projects/UOContent/Gumps/Base/GumpSystem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Gumps/Base/GumpSystem.cs b/Projects/UOContent/Gumps/Base/GumpSystem.cs index ddbb91382..f5252b1ad 100644 --- a/Projects/UOContent/Gumps/Base/GumpSystem.cs +++ b/Projects/UOContent/Gumps/Base/GumpSystem.cs @@ -31,7 +31,7 @@ public static partial class GumpSystem { IncomingPackets.Register(0xB1, 0, true, &DisplayGumpResponse); - EventSink.Disconnected += EventSink_Disconnected; + EventSink.BeforeDisconnected += EventSink_Disconnected; } private static void EventSink_Disconnected(Mobile m) From b7d93f512619ad05b7ab741bdf748a5a2a03aaf5 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:17:05 +0200 Subject: [PATCH 21/31] fix: fishing catches by expansion, SOS facets and the big fish mark (#2688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fishing mutate table offers every special catch in every era, so a pre-T2A shard fishes up boots, magic fish, treasure maps and bottles (added in the March 1999 fishing update), and a T2A shard gets the special fishing net and big fish, which no T2A publish or guide lists. Each entry now carries the expansion that added it; the chances are unchanged. The nets in SOS chests and on krakens need UO:R as well, and before SE ancient SOS roll 1 in 40 and their chests aren't hued white, since Publish 28 changed both. Two fixes from the same notes come with it. An SOS now only works in the facet it was found in, so kraken and leviathan bottles are made at death with the creature's facet. Big fish, their trophy deed and their trophy show the fisherman's mark and weight only from SE. ## Why each change ### The rule - `Core.T2A`, `Core.UOR`, `Core.SE`… mean "the shard is this expansion **or newer**". - An expansion includes every publish that came out while it was the newest one. Publish 27 (September 2004) came out before Samurai Empire (November 2004), so it belongs to AOS. - An item can't drop on a shard older than the expansion that added it. - When the sources only narrow it down to a window that spans two expansions, the item takes the older one, so no shard loses something it may really have had. ### When each fishing item arrived | Expansion | Released | What it added to fishing | Sources | |---|---|---|---| | T2A | Oct 1, 1998 | Boots, magic fish, treasure maps, bottles (update of March 17, 1999) | [1] | | UO:R | May 4, 2000 | Special fishing net (first listed in August 2000) | [2], [6], [7] | | UO:R or Third Dawn | | Big fish (first listed between November 2000 and March 2001) | [3], [8], [9] | | AOS | Feb 11, 2003 | Ancient SOS at 1 in 40, fabled fishing net (Publish 27, September 14, 2004) | [4] | | SE | Nov 2, 2004 | Ancient SOS at 1 in 25, white ancient chests, the fisherman's mark on big fish (Publish 28, January 12, 2005) | [5] | Release dates: [13]. ### `Fishing.cs` **`MutateEntry` gets an `Expansion` field and constructor argument.** Each row has to know which expansion added it. `MutateEntry` is private to `Fishing`, so no other code is affected. **`MutateType` skips rows newer than the shard:** ```csharp if (Core.Expansion < entry.m_Expansion) { continue; } ``` A skipped row behaves as if it weren't in the table. Every other row rolls exactly as before. **The rows:** | Row | Expansion | Why | |---|---|---| | Boots, shoes, sandals, thigh boots | T2A | The March 17, 1999 update is the first time fishing gives anything but fish: "you now have a chance of pulling up … One of any of several types of boots". [1] | | Magic fish (prized, wondrous, truly rare, peculiar) | T2A | Same update: "Magical fish, which can boost stamina, strength, intelligence, or dexterity". [1] | | Treasure map | T2A | Same update: "Treasure maps". [1] | | Message in a bottle | T2A | Same update: "Messages in bottles". [1] | | Special fishing net | UO:R | Missing from the 1999 list [1] and from the March 2000 fishing changes, which only mention treasure maps and SOS bottles [2]. Stratics' fishing guide doesn't have it from July 1999 to July 8, 2000 [6] and has it on August 10, 2000 [7], after UO:R came out. | | Big fish | UO:R | Stratics' fishing guide doesn't have it from July 1999 [6] to November 7, 2000 [8] and has it on May 24, 2001 [9]. Publish 11 (March 14, 2001) already lets taxidermy kits use it [3]. So it is certainly not T2A. It came at the end of UO:R or with Third Dawn (March 7, 2001); the sources can't tell which, so it takes UO:R, the older one. | | Nothing (the empty row) | None | Exists in every expansion; unchanged. | **SOS chest, white hue: `if (sos.IsAncient && Core.SE)`** Publish 28: "Hued level 4 fishing chests white." [5] It came out after SE, so before SE the ancient chest keeps its normal colour. **SOS chest, net: `if (Core.UOR) { chest.DropItem(...); }`** You can fish up an SOS chest from T2A on, because bottles are T2A [1]. The net inside it is UO:R (see the table), so before UO:R the chest comes without a net. The fabled net only goes in ancient chests, and those already need AOS [4]. **SOS: `from.Map == sos.TargetMap`, in `CheckResources` and `Construct`** Both accepted an SOS whenever the fisher was in Felucca or Trammel, without comparing the SOS's `TargetMap`, so a Trammel SOS paid out in Felucca at the same spot. Publish 5: "SOS bottles and treasure maps will only work in the land they are found in (for instance, a map found in Felucca will only work in Felucca)." [14] Treasure maps already check their facet. ### `MessageInABottle.cs` **`GetRandomLevel`: `Utility.Random(Core.SE ? 25 : 40) < 1`** Publish 27 added ancient SOS at "a 1 in 40 chance" [4]. Publish 28 "Changed Ancient SoS spawn chance from 1 in 40 to 1 in 25" [5]. So AOS rolls 1 in 40, SE and newer 1 in 25. The `Core.AOS` check was already there. ### `Kraken.cs` **Bottle, now made in `OnBeforeDeath`: `if (Core.T2A && Utility.RandomDouble() < .05)`** Bottles don't exist before T2A [1], so a kraken can't carry one there. The 5% is unchanged; no official source gives a number. The kraken is in Stratics' 1999 creature list [10]; its kraken page says "Loot: Unknown" in January 2000 [11] and "Loot: Messages in a Bottle, Treasure Maps, 300 - 600 gold" in October 2000 [12]. The constructor packed the bottle before the kraken had a map, so it always pointed to Trammel; with the SOS check above, a kraken killed in Felucca would drop an SOS that only works in Trammel. It is now made at death with the kraken's facet, as `BaseCreature.OnBeforeDeath` does for treasure maps; the net already does the same for the kraken it brings up. **Net: `if (Core.UOR) { PackItem(new SpecialFishingNet()); // Confirm? }`** Nets are UO:R [6], [7]. The `// Confirm?` stays: in 2000 Stratics says the net was "loot on Sea Serpents" [7], [12], not on krakens. Which creature drops the net is out of scope; this PR only fixes the expansion. ### `Leviathan.cs` **Bottle, now made in `OnBeforeDeath`** Same as the kraken: it was packed in the constructor and pointed to Trammel. It is now made at death with the leviathan's facet, and still drops every time. ### `BigFish.cs` and `TaxidermyKit.cs` **Mark and weight: `if (Core.SE && Weight >= 20)`, and the same in the trophy and its deed** Publish 28: "Added variable weight and fisherman's mark on big fish, big fish trophy deed, and big fish trophy. Any big fish caught that is below 20 stones will not display weight or fisherman's mark." [5] It came out after SE, so before SE none of the three shows them. ### What this PR doesn't change No chance, skill threshold or row order changes. The open questions (which creature drops the net, the exact expansion of big fish, its weight before Publish 28, the rates) are in #2687. ## References 1. Origin Update Center, update of March 17, 1999: the fishing overhaul that adds boots, magic fish, treasure maps and bottles. https://web.archive.org/web/19990417083713/http://update.owo.com/latest.html 2. Publish 4, March 8, 2000: "Every time you successfully fish up a treasure map or SOS bottle a sea serpent will surface and attack." https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2000-2/2000-publish-04-8th-march/ (copy from the time: https://web.archive.org/web/20000408162342/http://update.uo.com/design_180.html) 3. Publish 11, March 14, 2001: taxidermy kits accept "Big Fish". https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2001-2/2001-publish-11-14th-march/ (copy from the time: https://web.archive.org/web/20010331072129/http://update.uo.com/design_321.html) 4. Publish 27, September 14, 2004: "There is a 1 in 40 chance that any message in a bottle becomes 'an ancient SOS'", "Added fabled fishing nets and ancient sos treasure". https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2004-2/publish-27/ 5. Publish 28, January 12, 2005: "Hued level 4 fishing chests white", "Changed Ancient SoS spawn chance from 1 in 40 to 1 in 25". https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2005-2/publish-28-12th-january/ 6. Stratics fishing guide without the net or big fish, the same text from July 24, 1999 to July 8, 2000: last modified July 24, 1999, https://web.archive.org/web/19991009203653/http://uo.stratics.com:80/fishing.htm ; last modified July 8, 2000, https://web.archive.org/web/20000711071050/http://uo.stratics.com:80/fishing.shtml 7. Stratics fishing guide, last modified August 10, 2000: lists the special fishing net, "found as loot on sea serpents". https://web.archive.org/web/20000831173915/http://uo.stratics.com:80/fishing.shtml 8. Stratics fishing guide, last modified November 7, 2000: still no big fish. https://web.archive.org/web/20001209013100/http://uo.stratics.com:80/fishing.shtml 9. Stratics fishing guide, last modified May 24, 2001: lists big fish. https://web.archive.org/web/20010608153326/http://uo.stratics.com:80/content/skills/fishing.shtml 10. Stratics Hunter's Guide menu, last modified July 4, 1999: the kraken is in the creature list. https://web.archive.org/web/19991013090543/http://uo.stratics.com:80/hunters/huntmenu.htm 11. Stratics kraken page, last modified January 1, 2000: "Loot: Unknown". https://web.archive.org/web/20000119061647/http://uo.stratics.com:80/hunters/kraken.html 12. Stratics kraken page, last modified October 15, 2000: "Loot: Messages in a Bottle, Treasure Maps, 300 - 600 gold"; the net "can be found as loot on Sea Serpents". https://web.archive.org/web/20001017190252/http://uo.stratics.com:80/hunters/kraken.shtml 13. Expansion release dates: https://en.wikipedia.org/wiki/Ultima_Online#Expansions_and_follow-up_releases 14. Publish 5, April 27, 2000: "SOS bottles and treasure maps will only work in the land they are found in". https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2000-2/2000-publish-05-27th-april/ (copy from the time: https://web.archive.org/web/20000510032101/http://update.uo.com/design_196.html) --- Projects/UOContent/Engines/Harvest/Fishing.cs | 35 +++++++++++++------ .../Items/Resources/Fishing/BigFish.cs | 2 +- .../Carpenter Items/TaxidermyKit.cs | 4 +-- .../Fishing/Misc/MessageInABottle.cs | 2 +- .../Monsters/Reptile/Magic/Leviathan.cs | 8 ++++- .../Mobiles/Monsters/Reptile/Melee/Kraken.cs | 16 ++++++--- 6 files changed, 47 insertions(+), 20 deletions(-) diff --git a/Projects/UOContent/Engines/Harvest/Fishing.cs b/Projects/UOContent/Engines/Harvest/Fishing.cs index 6b3ef49ae..b45c44b72 100644 --- a/Projects/UOContent/Engines/Harvest/Fishing.cs +++ b/Projects/UOContent/Engines/Harvest/Fishing.cs @@ -12,22 +12,23 @@ namespace Server.Engines.Harvest private static readonly MutateEntry[] _mutateTable = { - new(80.0, 80.0, 4080.0, true, typeof(SpecialFishingNet)), - new(80.0, 80.0, 4080.0, true, typeof(BigFish)), - new(90.0, 80.0, 4080.0, true, typeof(TreasureMap)), - new(100.0, 80.0, 4080.0, true, typeof(MessageInABottle)), + 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, typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)), - new(0.0, 200.0, -200.0, false, new Type[] { null }) + new(0.0, 105.0, -420.0, false, Expansion.T2A, typeof(Boots), typeof(Shoes), typeof(Sandals), typeof(ThighBoots)), + new(0.0, 200.0, -200.0, false, Expansion.None, new Type[] { null }) }; private static readonly int[] _waterLandTiles = @@ -173,6 +174,11 @@ namespace Server.Engines.Harvest { var entry = _mutateTable[i]; + if (Core.Expansion < entry.m_Expansion) + { + continue; + } + if (!deepWater && entry.m_DeepWater) { continue; @@ -200,7 +206,7 @@ namespace Server.Engines.Harvest { foreach (var sos in pack.FindItemsByType()) { - if ((from.Map == Map.Felucca || from.Map == Map.Trammel) && from.InRange(sos.TargetLocation, 60)) + if (from.Map == sos.TargetMap && from.InRange(sos.TargetLocation, 60)) { return true; } @@ -240,7 +246,7 @@ namespace Server.Engines.Harvest // We don't have to queue since we are returning on the first SOS. foreach (var sos in pack.FindItemsByType()) { - if ((from.Map == Map.Felucca || from.Map == Map.Trammel) && from.InRange(sos.TargetLocation, 60)) + if (from.Map == sos.TargetMap && from.InRange(sos.TargetLocation, 60)) { Item preLoot = null; @@ -296,14 +302,17 @@ namespace Server.Engines.Harvest LockableContainer chest = Utility.RandomBool() ? new MetalGoldenChest() : new WoodenChest(); - if (sos.IsAncient) + if (sos.IsAncient && Core.SE) { chest.Hue = 0x481; } TreasureMapChest.Fill(chest, Math.Max(1, Math.Min(4, sos.Level))); - chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet()); + if (Core.UOR) + { + chest.DropItem(sos.IsAncient ? new FabledFishingNet() : new SpecialFishingNet()); + } chest.Movable = true; chest.Locked = false; @@ -531,17 +540,21 @@ namespace Server.Engines.Harvest private class MutateEntry { public readonly bool m_DeepWater; + public readonly Expansion m_Expansion; public readonly double m_MaxSkill; public readonly double m_MinSkill; public readonly double m_ReqSkill; public readonly Type[] m_Types; - public MutateEntry(double reqSkill, double minSkill, double maxSkill, bool deepWater, params Type[] types) + public MutateEntry( + double reqSkill, double minSkill, double maxSkill, bool deepWater, Expansion expansion, params Type[] types + ) { m_ReqSkill = reqSkill; m_MinSkill = minSkill; m_MaxSkill = maxSkill; m_DeepWater = deepWater; + m_Expansion = expansion; m_Types = types; } } diff --git a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs index e97c88239..b19e17290 100644 --- a/Projects/UOContent/Items/Resources/Fishing/BigFish.cs +++ b/Projects/UOContent/Items/Resources/Fishing/BigFish.cs @@ -30,7 +30,7 @@ public partial class BigFish : Item, ICarvable { base.GetProperties(list); - if (Weight >= 20) + if (Core.SE && Weight >= 20) { if (_fisher != null) { diff --git a/Projects/UOContent/Items/Skill Items/Carpenter Items/TaxidermyKit.cs b/Projects/UOContent/Items/Skill Items/Carpenter Items/TaxidermyKit.cs index 3713056c0..8f2054add 100644 --- a/Projects/UOContent/Items/Skill Items/Carpenter Items/TaxidermyKit.cs +++ b/Projects/UOContent/Items/Skill Items/Carpenter Items/TaxidermyKit.cs @@ -218,7 +218,7 @@ public partial class TrophyAddon : Item, IAddon { base.GetProperties(list); - if (_animalWeight >= 20) + if (Core.SE && _animalWeight >= 20) { if (!string.IsNullOrWhiteSpace(_hunter)) { @@ -346,7 +346,7 @@ public partial class TrophyDeed : Item { base.GetProperties(list); - if (_animalWeight >= 20) + if (Core.SE && _animalWeight >= 20) { if (!string.IsNullOrWhiteSpace(_hunter)) { diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs index bf626c2fd..7b8f3878c 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs @@ -37,7 +37,7 @@ public partial class MessageInABottle : Item public static int GetRandomLevel() { - if (Core.AOS && Utility.Random(25) < 1) + if (Core.AOS && Utility.Random(Core.SE ? 25 : 40) < 1) { return 4; // ancient } diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs index 8e7271348..e4ffe5ba3 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Magic/Leviathan.cs @@ -50,7 +50,6 @@ namespace Server.Mobiles CanSwim = true; CantWalk = true; - PackItem(new MessageInABottle()); PackItem(new Rope { ItemID = 0x14F8 }); PackItem(new Rope { ItemID = 0x14FA }); } @@ -63,6 +62,13 @@ namespace Server.Mobiles public override int TreasureMapLevel => 5; + public override bool OnBeforeDeath() + { + PackItem(new MessageInABottle(Map == Map.Felucca ? Map.Felucca : Map.Trammel)); + + return base.OnBeforeDeath(); + } + public static Type[] Artifacts { get; } = { // Decorations diff --git a/Projects/UOContent/Mobiles/Monsters/Reptile/Melee/Kraken.cs b/Projects/UOContent/Mobiles/Monsters/Reptile/Melee/Kraken.cs index 21f7d949f..919f77b34 100644 --- a/Projects/UOContent/Mobiles/Monsters/Reptile/Melee/Kraken.cs +++ b/Projects/UOContent/Mobiles/Monsters/Reptile/Melee/Kraken.cs @@ -46,12 +46,10 @@ namespace Server.Mobiles rope.ItemID = 0x14F8; PackItem(rope); - if (Utility.RandomDouble() < .05) + if (Core.UOR) { - PackItem(new MessageInABottle()); + PackItem(new SpecialFishingNet()); // Confirm? } - - PackItem(new SpecialFishingNet()); // Confirm? } public override string CorpseName => "a krakens corpse"; @@ -59,6 +57,16 @@ namespace Server.Mobiles public override int TreasureMapLevel => 4; + public override bool OnBeforeDeath() + { + if (Core.T2A && Utility.RandomDouble() < .05) + { + PackItem(new MessageInABottle(Map == Map.Felucca ? Map.Felucca : Map.Trammel)); + } + + return base.OnBeforeDeath(); + } + public override void GenerateLoot() { AddLoot(LootPack.Rich); From 6102cdabd774ec7254da04a9ee7e27ab03dd6450 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:21:50 +0200 Subject: [PATCH 22/31] fix: players jailed across a restart can be jailed again after release (#2690) Loading the jail records added every player still serving a sentence to CurrentlyBeingJailed, which only guards the jail sequence and is cleared when it ends. Nothing removed them on release, so JailPlayer silently returned for them until the next restart. --- Projects/UOContent/Systems/JailSystem/JailSystem.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Projects/UOContent/Systems/JailSystem/JailSystem.cs b/Projects/UOContent/Systems/JailSystem/JailSystem.cs index 8802d0487..3e0d7fa4f 100644 --- a/Projects/UOContent/Systems/JailSystem/JailSystem.cs +++ b/Projects/UOContent/Systems/JailSystem/JailSystem.cs @@ -417,7 +417,6 @@ public class JailSystem : GenericPersistence PlayerJailRecords[player] = record; if (record.IsCurrentlyJailed) { - CurrentlyBeingJailed.Add(player); var jailTime = record.JailEndTime - Core.Now; JailTimers[player] = Timer.DelayCall(jailTime, ReleasePlayer, record.JailedBy, player); } From 261ea01ab4b7c49a043dfabc7f44703b648883f8 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:41:27 +0200 Subject: [PATCH 23/31] fix: send crash reports and bound email retries (#2680) Crash reports never reached SMTP: `SendCrashEmail` returned immediately when email was enabled and never called the sender. It now respects the enabled setting, sends the report, and waits up to 30 seconds before the crash handler returns and the process can exit. The sender uses the `Message-ID` generated by `MimeMessage` and the sender address already added by each caller. This removes the duplicate `Message-ID` header and duplicate address within the `From` header. Gmail rejects duplicate `Message-ID` headers: https://support.google.com/a/answer/13567860. Email retries use a bounded doubling schedule instead of squaring an integer delay, which could overflow and throw through an `async void` method. The sender returns `Task`, uses `ConfigureAwait(false)` throughout, and logs failures. With the default five attempts and three-second initial delay, waits are 3, 6, 12, and 24 seconds. Each wait is capped at one hour, and there is no wait after the last attempt. Validation: - Full Release test suite on the branch updated with `main`: 2,103 passed, 2 intentionally skipped. - Eight email test cases cover disabled-email early returns, retry timing, low attempt counts, and overflow-prone delays. - SMTP delivery and the crash-time shutdown path are not covered by an integration test. --- .../UOContent.Tests/Tests/Misc/EmailTests.cs | 61 +++++++++++++++ Projects/UOContent/Misc/Email.cs | 77 +++++++++++++++---- 2 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Misc/EmailTests.cs diff --git a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs new file mode 100644 index 000000000..de6825c9f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using Server.Configurations; +using Server.Misc; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class EmailTests +{ + [Fact] + public void SendCrashEmailDoesNothingWhileEmailIsDisabled() + { + Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); + + // Missing input must be ignored while email is disabled. + var report = Path.Combine(Path.GetTempPath(), $"crash-email-test-{Guid.NewGuid():N}", "Crash.log"); + + Email.SendCrashEmail(report); + } + + [Fact] + public void SendQueueEmailDoesNothingWhileEmailIsDisabled() + { + Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); + + Email.SendQueueEmail(null, "Other"); + } + + [Fact] + public void RetryWaitsDoubleFromTheConfiguredDelayAndStopAtTheLastAttempt() + { + TimeSpan[] expected = + [ + TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(24) + ]; + + Assert.Equal(expected, Email.RetryDelays(5, 3)); + } + + [Theory] + [InlineData(int.MinValue)] + [InlineData(0)] + [InlineData(1)] + public void FewerThanTwoAttemptsNeverWait(int attempts) + { + Assert.Empty(Email.RetryDelays(attempts, 3)); + } + + [Theory] + [InlineData(8)] + [InlineData(int.MaxValue)] + public void RetryWaitsNeverOverflowAndStopDoublingAtAnHour(int firstDelaySeconds) + { + var delays = Email.RetryDelays(64, firstDelaySeconds); + + Assert.All(delays, delay => Assert.InRange(delay, TimeSpan.Zero, TimeSpan.FromHours(1))); + Assert.Equal(TimeSpan.FromHours(1), delays[^1]); + } +} diff --git a/Projects/UOContent/Misc/Email.cs b/Projects/UOContent/Misc/Email.cs index 5e30ed30a..4b8f89661 100644 --- a/Projects/UOContent/Misc/Email.cs +++ b/Projects/UOContent/Misc/Email.cs @@ -6,11 +6,22 @@ using MimeKit; using Server.Accounting; using Server.Configurations; using Server.Engines.Help; +using Server.Logging; namespace Server.Misc { public static class Email { + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(Email)); + + // How long SendCrashEmail holds up the exit after a crash: at the default settings, room for the first + // attempts (3 and 6 s apart; a fourth starts at 21 s when they fail fast), and no longer when the SMTP + // server is dead. + private static readonly TimeSpan CrashEmailTimeout = TimeSpan.FromSeconds(30); + + // A retry wait stops doubling at an hour: no multi-day waits, and nothing left to overflow. + private const int MaxRetryDelaySeconds = 3600; + /// /// Sends Queue-Page request using Email /// @@ -69,16 +80,17 @@ namespace Server.Misc }.ToMessageBody(); } - SendAsync(message); + _ = SendAsync(message); } /// - /// Sends crash email + /// Sends crash email, blocking for up to the crash timeout (30 s) while it is sent: the core exits + /// as soon as the crash handlers return, and a send left running dies with it. /// /// public static void SendCrashEmail(string filePath) { - if (EmailConfiguration.EmailEnabled) + if (!EmailConfiguration.EmailEnabled) { return; } @@ -94,27 +106,37 @@ namespace Server.Misc }; builder.Attachments.Add(filePath); message.Body = builder.ToMessageBody(); + + try + { + if (!SendAsync(message).Wait(CrashEmailTimeout)) + { + _logger.Warning("Crash email not sent after {Timeout}, giving up", CrashEmailTimeout); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Sending crash email failed"); + } } /// /// Sends emails async /// /// - private static async void SendAsync(MimeMessage message) + private static async Task SendAsync(MimeMessage message) { if (!EmailConfiguration.EmailEnabled) { return; } - var now = Core.Now; - var messageID = $"<{now:yyyyMMdd}.{now:HHmmssff}@{EmailConfiguration.EmailServer}>"; - message.Headers.Add("Message-ID", messageID); - message.From.Add(EmailConfiguration.FromAddress); + var attempts = EmailConfiguration.EmailSendRetryCount; + var retryDelays = RetryDelays(attempts, EmailConfiguration.EmailSendRetryDelay); + Exception lastException = null; - var delay = EmailConfiguration.EmailSendRetryDelay; - - for (var i = 0; i < EmailConfiguration.EmailSendRetryCount; i++) + // SendCrashEmail can block the game loop on this task: every await needs ConfigureAwait(false). + for (var i = 0; i < attempts; i++) { try { @@ -130,17 +152,40 @@ namespace Server.Misc } catch (Exception ex) { + lastException = ex; + if (i == 0) { - Console.WriteLine(ex.Message); - Console.WriteLine(ex.StackTrace); + _logger.Warning(ex, "Sending email {Subject} failed", message.Subject); } - delay *= delay; - - await Task.Delay(delay * 1000).ConfigureAwait(false); + if (i < retryDelays.Length) + { + await Task.Delay(retryDelays[i]).ConfigureAwait(false); + } } } + + _logger.Error(lastException, "Email {Subject} not sent after {Attempts} attempts", message.Subject, attempts); + } + + /// + /// The waits between send attempts: the configured delay, doubling each time, and none after the last. + /// + /// + /// + internal static TimeSpan[] RetryDelays(int attempts, int firstDelaySeconds) + { + var delays = new TimeSpan[Math.Max(attempts, 1) - 1]; + var seconds = Math.Clamp(firstDelaySeconds, 0, MaxRetryDelaySeconds); + + for (var i = 0; i < delays.Length; i++) + { + delays[i] = TimeSpan.FromSeconds(seconds); + seconds = Math.Min(seconds * 2, MaxRetryDelaySeconds); + } + + return delays; } } } From 73a1aaf5e08413e2654cb5fad2c556b8325b894e Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 7 Oct 2026 12:30:05 +1300 Subject: [PATCH 24/31] fix: MeteorSwarm resist halves that target's damage, not the shared pool (#2691) ## Summary Pre-AOS, a Meteor Swarm target that resists should take half of its share of the damage. Instead the resist branch halves the **shared** `damage` variable: ```csharp var toDeal = damage; if (!Core.AOS && CheckResisted(m)) { damage *= 0.5; // should be toDeal ``` `toDeal` has already been copied, so: - the target that resisted takes **full** damage, and - every target processed after it is halved, once per earlier resist, so a group where several resist ends up taking a fraction of the intended damage for reasons unrelated to their own resist. `ChainLightning` already handles this correctly (`toDeal *= 0.5;`), and this change makes Meteor Swarm match it. The AOS path is unaffected, since resist checks only run pre-AOS. The bug predates ModernUO (it was carried over from RunUO in the first alpha). ## Testing - `dotnet build Projects/UOContent/UOContent.csproj`: 0 warnings, 0 errors. - No unit test: the per-target damage sits inside `Target()`'s queue loop alongside a random resist roll, so testing it would mean refactoring the spell for a one-word fix. Happy to factor out a helper and add one if preferred. --- Projects/UOContent/Spells/Seventh/MeteorSwarm.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Spells/Seventh/MeteorSwarm.cs b/Projects/UOContent/Spells/Seventh/MeteorSwarm.cs index f73fb3111..fd70ea273 100644 --- a/Projects/UOContent/Spells/Seventh/MeteorSwarm.cs +++ b/Projects/UOContent/Spells/Seventh/MeteorSwarm.cs @@ -86,7 +86,7 @@ namespace Server.Spells.Seventh if (!Core.AOS && CheckResisted(m)) { - damage *= 0.5; + toDeal *= 0.5; m.SendLocalizedMessage(501783); // You feel yourself resisting magical energy. } From b98b0e296439c4df8da63db0987e06c4b922780c Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 7 Oct 2026 13:39:04 +1300 Subject: [PATCH 25/31] fix: digging up a treasure map chest no longer crashes when guardians spawn (#2692) ## Summary Digging up a decoded treasure map of level 0, or of level 2 and above, throws inside `DigTimer.OnTick` once the chest surfaces. The exception escapes the timer, so the server goes down: ``` System.NullReferenceException: Object reference not set to an instance of an object. at Server.Items.TreasureMap.DigTimer.OnTick() in ...\Items\Maps\TreasureMap.cs:line 809 ``` #2568 made `TreasureMapChest._guardians` lazily created: the constructor no longer initialises it, and migration nulls an empty list. #2569 updated the read in `CheckLocked` to match, but the dig timer still adds each spawned guardian directly: ```csharp if (bc != null) { m_Chest.Guardians.Add(bc); // null on a freshly dug chest } ``` Level 1 maps survive only because they spawn no guardians. I confirmed the regression by running the dig test below at #2568's parent, where it passes, and at #2568, where it fails. The dig timer now calls the generated `AddToGuardians`, which creates the list on first use. ## Testing - `dotnet build`: 0 warnings, 0 errors. - New `TreasureMapChestGuardianTests`: - `Dig_AddsSpawnedGuardiansToTheChest` (levels 0 and 2) drives the real `DigTimer` tick by tick until the chest surfaces. Before the fix both cases fail with the `NullReferenceException` above. It needs client map data, so it calls `TileDataRequirement.SkipIfMissing()` and skips on CI. - `AddToGuardians_OnNewChest_KeepsLevelZeroChestShut` needs no client data, so it runs on CI: the list starts null, the first guardian creates it, and a level 0 chest stays shut to players until that guardian is dead. - Full `UOContent.Tests`: 1196 passed and 2 skipped with client data; 1105 passed and 93 skipped without. --- .../TreasureMapChestGuardianTests.cs | 139 ++++++++++++++++++ Projects/UOContent/Items/Maps/TreasureMap.cs | 2 +- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs new file mode 100644 index 000000000..633025042 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestGuardianTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class TreasureMapChestGuardianTests +{ + // A treasure.cfg chest site with room to dig the chest up and spawn its guardians. + private const int SiteX = 2643; + private const int SiteY = 851; + + // The guardian list starts null and is created by the first guardian added. A level 0 chest + // stays shut to players until its guardians are dead. + [Fact] + public void AddToGuardians_OnNewChest_KeepsLevelZeroChestShut() + { + var map = Map.Felucca; + var location = new Point3D(5000, 610, 0); + var player = CreatePlayerMobile(map, location); + var guardian = new Mobile(World.NewMobile); + guardian.DefaultMobileInit(); + var chest = new TreasureMapChest(0); + + try + { + Assert.Null(chest.Guardians); + + chest.AddToGuardians(guardian); + + Assert.Same(guardian, Assert.Single(chest.Guardians)); + Assert.True(chest.CheckLocked(player)); + + guardian.Delete(); + + Assert.False(chest.CheckLocked(player)); + } + finally + { + chest.Delete(); + guardian.Delete(); + player.Delete(); + } + } + + // Drives the real dig, one DigTimer tick per call, until the chest surfaces and its guardians + // spawn. Levels 0 and 2+ spawn guardians; level 1 spawns none. + [SkippableTheory] + [InlineData(0)] + [InlineData(2)] + public void Dig_AddsSpawnedGuardiansToTheChest(int level) + { + TileDataRequirement.SkipIfMissing(); + + var map = Map.Felucca; + var site = new Point3D(SiteX, SiteY, map.GetAverageZ(SiteX, SiteY)); + var player = CreatePlayerMobile(map, new Point3D(site.X + 1, site.Y, site.Z)); + var treasureMap = new TreasureMap(level, map); + + try + { + Assert.True(map.CanFit(site.X, site.Y, site.Z, 16, true)); + Assert.True(player.BeginAction()); + + var timerType = typeof(TreasureMap).GetNestedType("DigTimer", BindingFlags.NonPublic)!; + var onTick = timerType.GetMethod("OnTick", BindingFlags.NonPublic | BindingFlags.Instance)!; + var digTimer = Activator.CreateInstance(timerType, player, treasureMap, site, map); + + for (var i = 0; i < 30 && !treasureMap.Completed; i++) + { + onTick.Invoke(digTimer, BindingFlags.DoNotWrapExceptions, null, null, null); + } + + var chest = FindChest(map, site); + + Assert.True(treasureMap.Completed); + Assert.NotNull(chest); + Assert.NotNull(chest.Guardians); + Assert.NotEmpty(chest.Guardians); + } + finally + { + Cleanup(map, site); + treasureMap.Delete(); + player.Delete(); + } + } + + private static TreasureMapChest FindChest(Map map, Point3D site) + { + foreach (var chest in map.GetItemsInRange(site, 0)) + { + return chest; + } + + return null; + } + + // Sweeps the site rather than trusting references, so a dig that throws partway still leaves + // nothing behind for the next case: the chest, both dirt piles and any guardian spawned so far. + private static void Cleanup(Map map, Point3D site) + { + var leftovers = new List(); + + foreach (var m in map.GetMobilesInRange(site, 3)) + { + leftovers.Add(m); + } + + foreach (var item in map.GetItemsInRange(site, 1)) + { + leftovers.Add(item); + } + + foreach (var item in map.GetItemsInRange(site, 1)) + { + leftovers.Add(item); + } + + foreach (var entity in leftovers) + { + entity.Delete(); + } + } + + private static PlayerMobile CreatePlayerMobile(Map map, Point3D location) + { + var mobile = new PlayerMobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.MoveToWorld(location, map); + return mobile; + } +} diff --git a/Projects/UOContent/Items/Maps/TreasureMap.cs b/Projects/UOContent/Items/Maps/TreasureMap.cs index 25711e120..fd2a9d75d 100644 --- a/Projects/UOContent/Items/Maps/TreasureMap.cs +++ b/Projects/UOContent/Items/Maps/TreasureMap.cs @@ -806,7 +806,7 @@ public partial class TreasureMap : MapItem if (bc != null) { - m_Chest.Guardians.Add(bc); + m_Chest.AddToGuardians(bc); } } } From 15a7ce55cee8477c0ba811184fef530a5bdb61a2 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:42:23 -0700 Subject: [PATCH 26/31] fix: T2A tailoring make-last no longer reuses a stale cloth hue for leather (#2694) ## Summary T2A tailoring Make Last failed after crafting a cloth item and then a leather item. `CraftContext.LastHue` was set only on the hued (cloth) path, so the next Make Last of a leather item took the hue-aware path and failed with "You don't have the resources". The leather path now resets `LastHue` to -1. ## Testing - `dotnet build`: 0 warnings, 0 errors - No automated test: driving `TailoringMenu` needs a NetState plus tiledata-backed resources, and CI has no tiledata. Not yet exercised in game. --- Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs index 80f950ab8..a7a5feccc 100644 --- a/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs +++ b/Projects/UOContent/Engines/Craft/T2A/TailoringMenu.cs @@ -278,6 +278,8 @@ public class TailoringMenu : ItemListMenu } else { + // Make Last picks the hue-aware overload from LastHue, so a leather craft must clear the cloth hue + context.LastHue = -1; DefTailoring.CraftSystem.CreateItem(from, itemDef.ItemType, resourceType, _tool, itemDef); } } From ee4c174e34519a9194fbffea49d746f3bdb934d1 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:26:00 +0200 Subject: [PATCH 27/31] test: delete the corpse and sword CorpsePacketTests leave in the world (#2696) `TestCorpseEquipPacket` and `TestCorpseContainerPacket` build a `Corpse` and a `VikingSword` and never delete them, so both stay in `World.Items` for every test that runs after them in the sequential collection. They're now deleted in a `finally`, the same way the other tests that create world items clean up. The packet assertions are unchanged. --- .../Items/Misc/Corpses/CorpsePacketTests.cs | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs index 40ad7c6d7..b5cec8aa6 100644 --- a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpsePacketTests.cs @@ -21,13 +21,21 @@ public class CorpsePacketTests var c = new Corpse(m, m.Items); - var expected = new CorpseEquip(m, c).Compile(); + try + { + var expected = new CorpseEquip(m, c).Compile(); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.SendCorpseEquip(m, c); + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendCorpseEquip(m, c); - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + c.Delete(); + weapon.Delete(); + } } [Theory] @@ -43,14 +51,22 @@ public class CorpsePacketTests var c = new Corpse(m, m.Items); - using var ns = PacketTestUtilities.CreateTestNetState(); - ns.ProtocolChanges = changes; + try + { + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.ProtocolChanges = changes; - var expected = (ns.ContainerGridLines ? (Packet)new CorpseContent6017(m, c) : new CorpseContent(m, c)).Compile(); + var expected = (ns.ContainerGridLines ? (Packet)new CorpseContent6017(m, c) : new CorpseContent(m, c)).Compile(); - ns.SendCorpseContent(m, c); + ns.SendCorpseContent(m, c); - var result = ns.SendBuffer.GetReadSpan(); - AssertThat.Equal(result, expected); + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + c.Delete(); + weapon.Delete(); + } } } From 0dbb0f5a2cffc9e23624cb46087a7de94a2df841 Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:21:43 +0200 Subject: [PATCH 28/31] fix: stealing from a container on the ground follows its lift rules (#2697) `Stealing` moves the item with `AddToBackpack` and never asks the container, so a theft skipped the rules a container sets for a hand lift: a treasure chest's crime, refusal and guardian roll, a boat hold from outside the boat, a house's moving crate, and the Haochi/Emino quest chests. An item in a container on the ground now goes through its `CheckLift` after the weight check, and a successful theft calls `OnItemLifted`, as a hand lift does. Deco containers are skipped: their `CheckLift` refuses everyone, and stealing is the only way into them. These are the containers with their own `CheckLift` that a thief can reach: | Container on the ground | What a hand lift already enforces | A theft until now | |---|---|---| | Treasure map chest | crime for a non-owner; refused if temporary, or for a non-owner on Trammel rules; guardian roll | none of it | | Boat hold | only from aboard, while the boat is stopped | also from outside the boat, or while it moves | | House moving crate | owner only | anyone | | Haochi / Emino quest chests | one item, only while on the quest | anyone, any number, and it didn't count for the quest | | Fish bowl not locked down | the fish stay in | the fish could be stolen | --- Projects/UOContent/Skills/Stealing.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Projects/UOContent/Skills/Stealing.cs b/Projects/UOContent/Skills/Stealing.cs index 01c530c79..bf8dbb1bc 100644 --- a/Projects/UOContent/Skills/Stealing.cs +++ b/Projects/UOContent/Skills/Stealing.cs @@ -7,6 +7,7 @@ using Server.Factions; using Server.Items; using Server.Misc; using Server.Mobiles; +using Server.Network; using Server.Spells; using Server.Spells.Fifth; using Server.Spells.Ninjitsu; @@ -259,12 +260,17 @@ public static class Stealing else { var w = toSteal.Weight + toSteal.TotalWeight; + var reject = LRReason.Inspecific; if (w > MaxWeightToSteal) { // This item is too heavy to steal from someone's backpack. _thief.SendLocalizedMessage(502722); } + else if (root is Container { IsDecoContainer: false } && !toSteal.CheckLift(_thief, toSteal, ref reject)) + { + _thief.SendLocalizedMessage(502710); // You can't steal that! + } else { if (toSteal.Stackable && toSteal.Amount > 1) @@ -375,6 +381,11 @@ public static class Stealing if (stolen != null) { + if (root is Container) + { + stolen.OnItemLifted(from, stolen); + } + from.AddToBackpack(stolen); if (!(stolen is Container || stolen.Stackable)) From 1992c9d1b51a4eeda6b8aaed2dcaf327554cbb17 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:02:36 -0700 Subject: [PATCH 29/31] fix: worn gear lifted off a corpse stays loot when put back (#2701) A corpse's worn-gear list was fixed at creation, and clients learn worn gear only from the 0x89 packet sent on first sight. A piece lifted and dropped back showed as loose loot to everyone watching, but came back "worn" after a relog. Lifting now takes the piece off the list, so every view agrees it is loot. Programmatic moves (the duel gather-and-re-equip in `DuelContext`) don't go through `OnItemLifted` and still see the original list. --- .../Items/Misc/Corpses/CorpseLootTests.cs | 42 +++++++++++++++++++ .../UOContent/Items/Misc/Corpses/Corpse.cs | 4 ++ 2 files changed, 46 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs new file mode 100644 index 000000000..26556155e --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseLootTests.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using Server; +using Server.Items; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class CorpseLootTests +{ + [Fact] + public void LiftedWornGear_PutBack_StaysLoot() + { + var owner = new Mobile((Serial)0x1); + owner.DefaultMobileInit(); + owner.Body = 0x190; + + // Weapons take their layer from tiledata, which CI doesn't load. + var sword = new VikingSword { Layer = Layer.OneHanded }; + Assert.True(owner.EquipItem(sword)); + + var corpse = new Corpse(owner, new List { sword }); + corpse.DropItem(sword); + + try + { + Assert.Contains(sword, corpse.EquipItems); + + corpse.OnItemLifted(owner, sword); + sword.Internalize(); + corpse.DropItem(sword); + + Assert.Same(corpse, sword.Parent); + Assert.DoesNotContain(sword, corpse.EquipItems); + } + finally + { + corpse.Delete(); + owner.Delete(); + } + } +} diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 04e9806c9..2cd0cea89 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -812,6 +812,10 @@ public partial class Corpse : Container, ICarvable AddToLooters(from); _instancedItems?.Remove(item); + + // Lifted gear is loot from then on. Clients only learn worn gear from 0x89 on first sight, so a + // piece put back would show worn after a relog but loose to everyone already watching. + RemoveFromEquipItems(item); } public override void GetContextMenuEntries(Mobile from, ref PooledRefList list) From 89a86d81b4b70b5d44b6f999efcacdda13de7869 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:21:50 -0700 Subject: [PATCH 30/31] feat: scope container child removal, skip-serialize and linearly delete container children (#2676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Removing an item from a container now reaches only the clients that were sent the item. This PR also adds container-scoped skip serialization, makes container deletion linear, and closes a lift-rejection resync gap. It is the groundwork for the NPC vendor buyback fix (#2677), and it is safe to merge by itself. ### Container child removal reaches only the clients that were sent the item Adds and updates for an item inside a private container have always gone only to the root mobile, the secure-trade partner, and the container's `Openers` (`ProcessDelta`, the same as RunUO). Removes, though, were broadcast to every client in range. That too is inherited from RunUO; #326 only converted the packets. Two costs: - **Bandwidth.** One packet per nearby client for every reagent, bandage or arrow stack that runs out, and every container-to-container move. - **Disclosure.** It sent the serial and timing of items moved in someone else's pack to every nearby client. Now: - **`Item.SendRemovePacket`:** a private container child's remove goes only to the root mobile, both secure-trade parties (found by walking up to the trade container, as `ProcessDelta` does), and the container's `Openers`. Each recipient is deduplicated and must be on the same map and in range. - **`Container.IsChildPublic(Item child)`** (default `IsPublicContainer`) marks children that were sent to every client in range. Their removal still broadcasts. Overrides can only widen: a public container's children always broadcast. - **`Corpse`** marks its worn gear public on human corpses. `SendInfoTo` shows that gear to everyone who sees the corpse, so bystanders still see looted gear disappear. Loot stays private, and corpses are not made public. - **`IsPublicContainer` is unchanged** and remains the escape hatch. - **Unchanged:** items equipped on mobiles, children of non-container items (spellbooks, bulletin boards), and ground items. - **`Item.Map` no longer sends removes for contained items.** A contained item only changes map along with its parent or root, and that ancestor's own remove already clears the subtree for everyone who knew it. The owner keeps their nested bags across a facet change, as before, and bystanders no longer get a remove per nested item. **For forks:** a custom container that sends its contents to clients other than the root, trade parties and openers must override `IsChildPublic` or `IsPublicContainer`. Otherwise those clients keep the item until they reopen the container, it leaves their range, or they log out. - **`Item.MoveToWorld` no longer sends a second remove for an item that had a parent.** It used to detach through the parent's `RemoveItem` (correctly scoped), then broadcast another remove from the old location. Every lift goes through `Internalize()`, so every lift from a pack or container broadcast the item's serial. Ground items are unchanged. - **Property-only updates** (`ItemDelta.Properties` without `Update`) of a private child now send the object property list revision only to the same recipients, not every client in range. Gump tooltips that reference such an item without the client holding it no longer get the revision nudge; their cached tooltip refreshes when re-requested. ### Lift rejection resyncs only items the requester was sent A rejected lift re-sent the item's full data to the requester without checking that their client had ever been sent it. `Item.IsSentTo(Mobile)` now applies the same rule as the adds, including `CanSee`: - **Private container children:** the root mobile, the trade parties, or the openers. - **Everything else:** visible and in update range. `Mobile.Lift` still always sends the lift-reject packet, but resyncs the item only when `IsSentTo` holds. ### Container content packets stay within the protocol limit `SendContainerContent` stack-allocated `items.Count × 20` bytes, which overflowed the stack for a container with hundreds of thousands of items. The `0x3C` length and count fields are 16-bit anyway. It now caps entries so the packet fits (3448 entries, or 3276 with grid lines), and rents a pooled buffer above a small size. Output is byte-identical for normal containers. ### Container-scoped skip serialization **`Container.SkipsChildSerialization`** (default `false`). When a container returns true: - its direct children are left out of world saves (the base `Item.SkipSerialization` checks the parent); - it writes no child list, so a skipped child's serial can never resolve to an unrelated item at load; - an item that loads naming such a container as its parent is deleted by the existing missing-parent path. **Known limitation:** `SkipSerialization` is read while the save files are written, after the world unfreezes. An item that enters an opted-in container in that window can be left out of that save. The next save is correct. Moving the decision into the freeze is a follow-up. ### Linear container deletion `Item.Delete` removes children back to front, but `RemoveItem` searched from the front with `List.Remove`, so deleting a container of *n* items was O(n²). `RemoveItem` now checks the last entry first. - 100,000 children took 8.0s before and 162ms after. - Other removals cost the same as before. **Save format unchanged for every existing type** (the schema generator produces no diff). ## Test plan - [x] `ContainerChildRemovalTests`: - a private child's removal isn't broadcast; - openers, the root owner's client and a trade party (who never opened the pouch) still receive it; - public containers and per-child `IsChildPublic` overrides still broadcast; - an override can't narrow a public container; - no duplicate packets when root and opener overlap; - deleting the owner sends no remove per child; - a facet change sends no remove for nested children to the owner or bystanders; - lifting from a backpack or moving a contained item to the ground sends no remove to bystanders; - equipped items and ground items keep their broadcast. - [x] `PrivateChildPropertiesTests`: a property-only update of a private child reaches the owner and openers, not bystanders; public-container children and ground items still broadcast. - [x] `CorpseChildPublicTests`: human corpse worn gear is public and loot isn't, bones and non-human bodies aren't; end to end, worn gear leaving the corpse reaches a bystander and deleted loot doesn't. - [x] `LiftRejectResyncTests`: - a bystander and an out-of-range requester get no resync; - the owner, an opener and a trade party do; - an invisible item isn't resynced to a non-staff owner, but is to staff; - equipment on a hidden mobile and an invisible ground item aren't resynced. - [x] `ContainerPacketTests`: large containers are capped at the protocol limit (with and without grid lines) and the pooled-buffer path is byte-identical. - [x] `ContainerChildSkipTests`, `ContainerBulkRemovalTests`. - [x] Server.Tests (944) and UOContent.Tests (1203) green on current `main`; schema generator clean. - [x] Manual in-game pass with three clients: - consumption; - snooping; - co-opened containers; - bank; - trade with and without opening the pouch; - facet change with nested bags; - pack animals; - player vendors; - human corpse looting by drag and by script; - every lift-rejection reason. Verified with a ClassicUO build instrumented to count: - removes for unknown serials; - adds dropped for an unknown parent; - items learned inside containers the client never opened; - lift resyncs of items the client never had. --- .../Tests/Items/ContainerBulkRemovalTests.cs | 58 +++ .../Tests/Items/ContainerChildRemovalTests.cs | 462 ++++++++++++++++++ .../Tests/Items/ContainerChildSkipTests.cs | 146 ++++++ .../Tests/Items/LiftRejectResyncTests.cs | 311 ++++++++++++ .../Items/PrivateChildPropertiesTests.cs | 187 +++++++ .../Packets/Outgoing/ContainerPacketTests.cs | 132 +++++ Projects/Server/Items/Container.cs | 15 + Projects/Server/Items/Item.cs | 196 +++++++- Projects/Server/Mobiles/Mobile.cs | 3 +- .../Packets/OutgoingContainerPackets.cs | 79 ++- .../Misc/Corpses/CorpseChildPublicTests.cs | 226 +++++++++ .../UOContent/Items/Misc/Corpses/Corpse.cs | 30 +- 12 files changed, 1808 insertions(+), 37 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs create mode 100644 Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs create mode 100644 Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs create mode 100644 Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs create mode 100644 Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs diff --git a/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs new file mode 100644 index 000000000..c529f8a12 --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/ContainerBulkRemovalTests.cs @@ -0,0 +1,58 @@ +using Server.Items; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class ContainerBulkRemovalTests +{ + [Fact] + public void DeletingLargeContainer_DeletesAllChildren_AndTotalsStayConsistent() + { + var outer = new Container(0xE75); + var pack = new Container(0xE75); + outer.DropItem(pack); + + var children = new Item[100_000]; + for (var i = 0; i < children.Length; i++) + { + children[i] = new Item(0x1234); + pack.DropItem(children[i]); + } + + Assert.Equal(children.Length + 1, outer.TotalItems); + + pack.Delete(); + + for (var i = 0; i < children.Length; i++) + { + Assert.True(children[i].Deleted); + } + + Assert.Empty(outer.Items); + Assert.Equal(0, outer.TotalItems); + outer.Delete(); + } + + // The tail fast path must not change which entry is removed when the item is elsewhere. + [Fact] + public void RemovingFromFrontMiddleAndEnd_RemovesExactlyThatItem() + { + var pack = new Container(0xE75); + var items = new Item[5]; + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.DropItem(items[i]); + } + + items[0].Delete(); + items[2].Delete(); + items[4].Delete(); + + Assert.Equal(2, pack.Items.Count); + Assert.Same(items[1], pack.Items[0]); + Assert.Same(items[3], pack.Items[1]); + pack.Delete(); + } +} diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs new file mode 100644 index 000000000..695838185 --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/ContainerChildRemovalTests.cs @@ -0,0 +1,462 @@ +using System; +using Server.Items; +using Server.Network; +using Server.Tests.Network; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class ContainerChildRemovalTests +{ + private class PublicContainer : Container + { + public PublicContainer() : base(0xE75) + { + } + + public override bool IsPublicContainer => true; + } + + private class OneChildPublicContainer : Container + { + public OneChildPublicContainer() : base(0xE75) + { + } + + public Item PublicChild { get; set; } + + public override bool IsChildPublic(Item child) => child == PublicChild; + } + + // An override that narrows below IsPublicContainer instead of only widening it, exercising the guard + // that keeps a public container's removals broadcast regardless of what IsChildPublic returns. + private class PublicContainerWithNarrowingOverride : Container + { + public PublicContainerWithNarrowingOverride() : base(0xE75) + { + } + + public override bool IsPublicContainer => true; + + public override bool IsChildPublic(Item child) => false; + } + + private static readonly Point3D _ownerLoc = new(1500, 1500, 0); + + private static (NetState, Mobile) CreateClient(Point3D location) + { + var ns = PacketTestUtilities.CreateTestNetState(); + ns.Account = new MockAccount(); + + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + ns.Mobile = mobile; + mobile.NetState = ns; + mobile.MoveToWorld(location, Map.Felucca); + return (ns, mobile); + } + + private static Mobile CreateOwnerWith(Container pack) + { + var owner = new Mobile(World.NewMobile); + owner.DefaultMobileInit(); + owner.MoveToWorld(_ownerLoc, Map.Felucca); + pack.Layer = Layer.ShopBuy; + pack.Visible = false; + owner.AddItem(pack); + return owner; + } + + private static bool ReceivedRemove(NetState ns, Serial serial) + { + Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; + OutgoingEntityPackets.CreateRemoveEntity(expected, serial); + return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; + } + + private static int CountRemoves(NetState ns, Serial serial) + { + Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; + OutgoingEntityPackets.CreateRemoveEntity(expected, serial); + + var span = ns.SendBuffer.GetReadSpan(); + var count = 0; + + while (true) + { + var index = span.IndexOf(expected); + + if (index < 0) + { + break; + } + + count++; + span = span[(index + expected.Length)..]; + } + + return count; + } + + private static void DisposeClient(NetState ns, Mobile mobile) + { + ns.Mobile = null; + ns.Dispose(); + mobile.Delete(); + } + + private static void Cleanup(NetState ns, Mobile client, Mobile owner) + { + DisposeClient(ns, client); + owner.Delete(); + } + + [Fact] + public void PrivateChildDelete_IsNotBroadcastToNearbyClients() + { + var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + var owner = CreateOwnerWith(new Container(0xE75)); + var child = new Item(0x1234); + ((Container)owner.FindItemOnLayer(Layer.ShopBuy)).DropItem(child); + + try + { + child.Delete(); + Assert.False(ReceivedRemove(ns, child.Serial)); + } + finally + { + Cleanup(ns, client, owner); + } + } + + [Fact] + public void PrivateChildDelete_ReachesOpeners() + { + var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + var pack = new Container(0xE75); + var owner = CreateOwnerWith(pack); + var child = new Item(0x1234); + pack.DropItem(child); + pack.Openers = [client]; + + try + { + child.Delete(); + Assert.True(ReceivedRemove(ns, child.Serial)); + } + finally + { + Cleanup(ns, client, owner); + } + } + + [Fact] + public void PrivateChildDelete_ReachesTheRootOwnersClient() + { + var (ownerNs, owner) = CreateClient(_ownerLoc); + var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + + var pack = new Container(0xE75) + { + Layer = Layer.Backpack, + Visible = false + }; + owner.AddItem(pack); + + var child = new Item(0x1234); + pack.DropItem(child); + + try + { + child.Delete(); + Assert.True(ReceivedRemove(ownerNs, child.Serial)); + Assert.False(ReceivedRemove(bystanderNs, child.Serial)); + } + finally + { + DisposeClient(ownerNs, owner); + DisposeClient(bystanderNs, bystander); + } + } + + [Fact] + public void PublicContainerChildDelete_IsBroadcast() + { + var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + var owner = CreateOwnerWith(new PublicContainer()); + var child = new Item(0x1234); + ((Container)owner.FindItemOnLayer(Layer.ShopBuy)).DropItem(child); + + try + { + child.Delete(); + Assert.True(ReceivedRemove(ns, child.Serial)); + } + finally + { + Cleanup(ns, client, owner); + } + } + + [Fact] + public void PublicContainer_NarrowingChildOverride_StillBroadcastsRemoval() + { + var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + var owner = CreateOwnerWith(new PublicContainerWithNarrowingOverride()); + var child = new Item(0x1234); + ((Container)owner.FindItemOnLayer(Layer.ShopBuy)).DropItem(child); + + try + { + child.Delete(); + Assert.True(ReceivedRemove(ns, child.Serial)); + } + finally + { + Cleanup(ns, client, owner); + } + } + + [Fact] + public void IsChildPublic_BroadcastsOnlyThatChild() + { + var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + var pack = new OneChildPublicContainer(); + var owner = CreateOwnerWith(pack); + var publicChild = new Item(0x1234); + var privateChild = new Item(0x1235); + pack.DropItem(publicChild); + pack.DropItem(privateChild); + pack.PublicChild = publicChild; + + try + { + privateChild.Delete(); + Assert.False(ReceivedRemove(ns, privateChild.Serial)); + + publicChild.Delete(); + Assert.True(ReceivedRemove(ns, publicChild.Serial)); + } + finally + { + Cleanup(ns, client, owner); + } + } + + [Fact] + public void DeletingOwner_SendsNoRemoveForPrivateChildren() + { + var (ns, client) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + var pack = new Container(0xE75); + var owner = CreateOwnerWith(pack); + var children = new Item[50]; + for (var i = 0; i < children.Length; i++) + { + children[i] = new Item(0x1234); + pack.DropItem(children[i]); + } + + try + { + owner.Delete(); + + for (var i = 0; i < children.Length; i++) + { + Assert.True(children[i].Deleted); + Assert.False(ReceivedRemove(ns, children[i].Serial)); + } + } + finally + { + Cleanup(ns, client, owner); + } + } + + [Fact] + public void TradePartner_ReceivesRemoveForNestedItem() + { + var (nsA, a) = CreateClient(_ownerLoc); + var (nsB, b) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + + var trade = new SecureTrade(a, b); + var pouch = new Container(0xE75); + trade.From.Container.DropItem(pouch); + var item = new Item(0x1234); + pouch.DropItem(item); + + try + { + item.Delete(); + Assert.True(ReceivedRemove(nsB, item.Serial)); + } + finally + { + trade.From.Container.Delete(); + trade.To.Container.Delete(); + DisposeClient(nsA, a); + DisposeClient(nsB, b); + } + } + + [Fact] + public void FacetChange_DoesNotRemoveNestedContainerChildren() + { + var (ownerNs, owner) = CreateClient(_ownerLoc); + var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + + var backpack = new Container(0xE75) { Layer = Layer.Backpack }; + owner.AddItem(backpack); + + var pouch = new Container(0xE75); + backpack.DropItem(pouch); + + var reagent = new Item(0xF7A); + pouch.DropItem(reagent); + + try + { + owner.MoveToWorld(_ownerLoc, Map.Trammel); + + Assert.False(ReceivedRemove(ownerNs, pouch.Serial)); + Assert.False(ReceivedRemove(ownerNs, reagent.Serial)); + Assert.False(ReceivedRemove(bystanderNs, pouch.Serial)); + Assert.False(ReceivedRemove(bystanderNs, reagent.Serial)); + } + finally + { + DisposeClient(ownerNs, owner); + DisposeClient(bystanderNs, bystander); + } + } + + [Fact] + public void NoDuplicateRemove_WhenRootIsAlsoAnOpener() + { + var (ownerNs, owner) = CreateClient(_ownerLoc); + + var pack = new Container(0xE75) + { + Layer = Layer.Backpack, + Visible = false + }; + owner.AddItem(pack); + pack.Openers = [owner]; + + var child = new Item(0x1234); + pack.DropItem(child); + + try + { + child.Delete(); + Assert.Equal(1, CountRemoves(ownerNs, child.Serial)); + } + finally + { + DisposeClient(ownerNs, owner); + } + } + + [Fact] + public void LiftingFromOwnBackpack_DoesNotBroadcastRemove() + { + var (ownerNs, owner) = CreateClient(_ownerLoc); + var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + + var pack = new Container(0xE75) { Layer = Layer.Backpack }; + owner.AddItem(pack); + + var item = new Item(0x1234); + pack.DropItem(item); + + try + { + item.Internalize(); + + Assert.True(ReceivedRemove(ownerNs, item.Serial)); + Assert.False(ReceivedRemove(bystanderNs, item.Serial)); + } + finally + { + item.Delete(); + DisposeClient(ownerNs, owner); + DisposeClient(bystanderNs, bystander); + } + } + + [Fact] + public void MovingPrivateChildToGroundSameMap_DoesNotSendOldLocationRemoveToBystanders() + { + var (ownerNs, owner) = CreateClient(_ownerLoc); + var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + + var pack = new Container(0xE75) { Layer = Layer.Backpack }; + owner.AddItem(pack); + + var item = new Item(0x1234); + pack.DropItem(item); + + var farLocation = new Point3D(_ownerLoc.X + 100, _ownerLoc.Y, 0); + + try + { + item.MoveToWorld(farLocation, Map.Felucca); + + Assert.False(ReceivedRemove(bystanderNs, item.Serial)); + } + finally + { + item.Delete(); + DisposeClient(ownerNs, owner); + DisposeClient(bystanderNs, bystander); + } + } + + [Fact] + public void LiftingEquippedItem_StillBroadcastsRemove() + { + var (ownerNs, owner) = CreateClient(_ownerLoc); + var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + + var weapon = new Item(0x1234) { Layer = Layer.OneHanded }; + owner.AddItem(weapon); + + try + { + weapon.Internalize(); + + Assert.True(ReceivedRemove(bystanderNs, weapon.Serial)); + } + finally + { + weapon.Delete(); + DisposeClient(ownerNs, owner); + DisposeClient(bystanderNs, bystander); + } + } + + [Fact] + public void GroundItemMove_StillSendsOldLocationRemove() + { + var (bystanderNs, bystander) = CreateClient(_ownerLoc); + + var groundLocation = new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0); + var item = new Item(0x1234); + item.MoveToWorld(groundLocation, Map.Felucca); + + var farLocation = new Point3D(_ownerLoc.X + 100, _ownerLoc.Y, 0); + + try + { + item.MoveToWorld(farLocation, Map.Felucca); + + Assert.True(ReceivedRemove(bystanderNs, item.Serial)); + } + finally + { + item.Delete(); + DisposeClient(bystanderNs, bystander); + } + } +} diff --git a/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs b/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs new file mode 100644 index 000000000..5b1cd471c --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/ContainerChildSkipTests.cs @@ -0,0 +1,146 @@ +using System; +using Server.Items; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class ContainerChildSkipTests +{ + private class SkippingContainer : Container + { + public SkippingContainer() : base(0xE75) + { + } + + public SkippingContainer(Serial serial) : base(serial) + { + } + + public override bool SkipsChildSerialization => true; + } + + [Fact] + public void ChildOfSkippingContainer_SkipsUntilMovedOut() + { + var pack = new SkippingContainer(); + var normal = new Container(0xE75); + var item = new Item(0x1234); + + try + { + pack.DropItem(item); + Assert.True(item.SkipSerialization); + Assert.False(pack.SkipSerialization); + + normal.DropItem(item); + Assert.False(item.SkipSerialization); + } + finally + { + item.Delete(); + pack.Delete(); + normal.Delete(); + } + } + + [Fact] + public void GrandchildOfSkippingContainer_IsNotSkipped() + { + var pack = new SkippingContainer(); + var inner = new Container(0xE75); + var item = new Item(0x1234); + + try + { + pack.DropItem(inner); + inner.DropItem(item); + + Assert.True(inner.SkipSerialization); + Assert.False(item.SkipSerialization); + } + finally + { + pack.Delete(); + } + } + + // 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() + { + 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 stays alive and in pack's list past this point, so its serial is still resolvable + // when copy deserializes below - deleting pack cascades to item, so both go together. + try + { + var copy = new SkippingContainer(World.NewItem); + copy.Deserialize(new BufferReader(buffer)); + + Assert.Empty(copy.Items); + } + finally + { + pack.Delete(); + } + } + + // An item can be saved naming a skipping container as parent while absent from its list; it must load + // as parentless so the missing-parent path deletes it rather than leaving an unreachable orphan. + [Fact] + public void ItemLoadingWithSkippingContainerAsParent_ClearsParent() + { + var pack = new SkippingContainer(); + var item = new Item(0x1234); + pack.DropItem(item); + + var writer = new BufferWriter(true); + item.Serialize(writer); + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + item.Delete(); + + try + { + var copy = new Item(World.NewItem); + copy.Deserialize(new BufferReader(buffer)); + + Assert.Null(copy.Parent); + } + finally + { + pack.Delete(); + } + } +} diff --git a/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs new file mode 100644 index 000000000..97cffbba9 --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/LiftRejectResyncTests.cs @@ -0,0 +1,311 @@ +using System; +using System.Buffers; +using Server.Items; +using Server.Network; +using Server.Tests.Network; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class LiftRejectResyncTests +{ + private static readonly Point3D _baseLoc = new(2200, 2200, 0); + + private static (NetState, Mobile) CreateClient(Point3D location) + { + var ns = PacketTestUtilities.CreateTestNetState(); + ns.Account = new MockAccount(); + + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + ns.Mobile = mobile; + mobile.NetState = ns; + mobile.MoveToWorld(location, Map.Felucca); + return (ns, mobile); + } + + private static void DisposeClient(NetState ns, Mobile mobile) + { + ns.Mobile = null; + ns.Dispose(); + mobile.Delete(); + } + + private static bool ReceivedContentUpdate(NetState ns, Serial serial) + { + Span expected = stackalloc byte[5]; + var writer = new SpanWriter(expected); + writer.Write((byte)0x25); // ContainerContentUpdate packet ID + writer.Write(serial); + return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; + } + + private static bool ReceivedWorldItem(NetState ns, Item item) + { + Span expected = stackalloc byte[OutgoingEntityPackets.MaxWorldEntityPacketLength]; + var length = OutgoingItemPackets.CreateWorldItem(expected, item); + return ns.SendBuffer.GetReadSpan().IndexOf(expected[..length]) >= 0; + } + + private static bool ReceivedEquipUpdate(NetState ns, Serial serial) + { + Span expected = stackalloc byte[5]; + var writer = new SpanWriter(expected); + writer.Write((byte)0x2E); // EquipUpdate packet ID + writer.Write(serial); + return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; + } + + [Fact] + public void PrivateNestedItem_RejectedLift_DoesNotResyncBystander() + { + var (ownerNs, owner) = CreateClient(_baseLoc); + var (bystanderNs, bystander) = CreateClient(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0)); + + var backpack = new Container(0xE75) { Layer = Layer.Backpack }; + owner.AddItem(backpack); + var pouch = new Container(0xE75); + backpack.DropItem(pouch); + var item = new Item(0x1234); + pouch.DropItem(item); + + try + { + bystander.Lift(item, item.Amount, out var rejected, out _); + + // Whichever check trips first (CheckNonlocalLift, accessibility, ...), the bystander was + // never sent this private child, so the rejection must not resynchronize it to them. + Assert.True(rejected); + Assert.False(ReceivedContentUpdate(bystanderNs, item.Serial)); + } + finally + { + DisposeClient(ownerNs, owner); + DisposeClient(bystanderNs, bystander); + } + } + + [Fact] + public void OwnerAlreadyHolding_RejectedLift_ResyncsPrivateChildToOwner() + { + var (ownerNs, owner) = CreateClient(_baseLoc); + + var backpack = new Container(0xE75) { Layer = Layer.Backpack }; + owner.AddItem(backpack); + var item = new Item(0x1234); + backpack.DropItem(item); + + owner.Holding = new Item(0x1); + + try + { + owner.Lift(item, item.Amount, out var rejected, out var reject); + + Assert.True(rejected); + Assert.Equal(LRReason.AreHolding, reject); + Assert.True(ReceivedContentUpdate(ownerNs, item.Serial)); + } + finally + { + owner.Holding?.Delete(); + DisposeClient(ownerNs, owner); + } + } + + [Fact] + public void GroundItemOutOfRange_RejectedLift_DoesNotResyncRequester() + { + var (requesterNs, requester) = CreateClient(_baseLoc); + + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(_baseLoc.X, _baseLoc.Y + 100, 0), Map.Felucca); + + try + { + requester.Lift(item, item.Amount, out var rejected, out var reject); + + Assert.True(rejected); + Assert.Equal(LRReason.OutOfRange, reject); + Assert.False(ReceivedWorldItem(requesterNs, item)); + } + finally + { + DisposeClient(requesterNs, requester); + item.Delete(); + } + } + + [Fact] + public void GroundContainerOpener_ForcedRejection_ResyncsPrivateChildToOpener() + { + var (requesterNs, requester) = CreateClient(_baseLoc); + + var container = new Container(0xE75); + container.MoveToWorld(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0), Map.Felucca); + var item = new Item(0x1234); + container.DropItem(item); + container.Openers = [requester]; + + requester.Holding = new Item(0x1); + + try + { + requester.Lift(item, item.Amount, out var rejected, out var reject); + + Assert.True(rejected); + Assert.Equal(LRReason.AreHolding, reject); + Assert.True(ReceivedContentUpdate(requesterNs, item.Serial)); + } + finally + { + requester.Holding?.Delete(); + DisposeClient(requesterNs, requester); + container.Delete(); + } + } + + [Fact] + public void TradePartner_RejectedLift_ResyncsNestedItemToTradePartner() + { + var (aNs, a) = CreateClient(_baseLoc); + var (bNs, b) = CreateClient(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0)); + + var trade = new SecureTrade(a, b); + var pouch = new Container(0xE75); + trade.From.Container.DropItem(pouch); + var item = new Item(0x1234); + pouch.DropItem(item); + + b.Holding = new Item(0x1); + + try + { + // b is a's trade partner: not the item's root and never an Opener of the pouch, but a + // sanctioned recipient of this private child through the trade relationship. + b.Lift(item, item.Amount, out var rejected, out var reject); + + Assert.True(rejected); + Assert.Equal(LRReason.AreHolding, reject); + Assert.True(ReceivedContentUpdate(bNs, item.Serial)); + } + finally + { + b.Holding?.Delete(); + trade.From.Container.Delete(); + trade.To.Container.Delete(); + DisposeClient(aNs, a); + DisposeClient(bNs, b); + } + } + + [Fact] + public void InvisibleItem_RejectedLift_DoesNotResyncToNonStaffOwner() + { + var (ownerNs, owner) = CreateClient(_baseLoc); + + var backpack = new Container(0xE75) { Layer = Layer.Backpack }; + owner.AddItem(backpack); + var item = new Item(0x1234) { Visible = false }; + backpack.DropItem(item); + + owner.Holding = new Item(0x1); + + try + { + owner.Lift(item, item.Amount, out var rejected, out var reject); + + // ProcessDelta never sends an invisible item to a non-staff owner (CanSee gates every + // private recipient); the rejection resync must honor the same gate. + Assert.True(rejected); + Assert.Equal(LRReason.AreHolding, reject); + Assert.False(ReceivedContentUpdate(ownerNs, item.Serial)); + } + finally + { + owner.Holding?.Delete(); + DisposeClient(ownerNs, owner); + } + } + + [Fact] + public void InvisibleItem_RejectedLift_ResyncsToStaffOwner() + { + var (ownerNs, owner) = CreateClient(_baseLoc); + owner.AccessLevel = AccessLevel.GameMaster; + + var backpack = new Container(0xE75) { Layer = Layer.Backpack }; + owner.AddItem(backpack); + var item = new Item(0x1234) { Visible = false }; + backpack.DropItem(item); + + owner.Holding = new Item(0x1); + + try + { + owner.Lift(item, item.Amount, out var rejected, out var reject); + + Assert.True(rejected); + Assert.Equal(LRReason.AreHolding, reject); + Assert.True(ReceivedContentUpdate(ownerNs, item.Serial)); + } + finally + { + owner.Holding?.Delete(); + DisposeClient(ownerNs, owner); + } + } + + [Fact] + public void EquippedItemOnHiddenMobile_RejectedLift_NoEquipResync() + { + var (ownerNs, owner) = CreateClient(_baseLoc); + var (requesterNs, requester) = CreateClient(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0)); + + owner.Hidden = true; + var weapon = new Item(0x1234) { Layer = Layer.OneHanded }; + owner.AddItem(weapon); + + try + { + requester.Lift(weapon, weapon.Amount, out var rejected, out var reject); + + // The requester can't see a hidden, non-staff owner: the lift is rejected OutOfSight and + // IsSentTo's CanSee gate must suppress the equip resync. + Assert.True(rejected); + Assert.Equal(LRReason.OutOfSight, reject); + Assert.False(ReceivedEquipUpdate(requesterNs, weapon.Serial)); + } + finally + { + weapon.Delete(); + DisposeClient(ownerNs, owner); + DisposeClient(requesterNs, requester); + } + } + + [Fact] + public void InvisibleGroundItemInRange_RejectedLift_NoWorldResync() + { + var (requesterNs, requester) = CreateClient(_baseLoc); + + var item = new Item(0x1234) { Visible = false }; + item.MoveToWorld(new Point3D(_baseLoc.X + 1, _baseLoc.Y, 0), Map.Felucca); + + try + { + requester.Lift(item, item.Amount, out var rejected, out var reject); + + // In range this time (unlike GroundItemOutOfRange above): only Visible gates it, so the + // rejection resync must not hand the requester a world packet for an item they can't see. + Assert.True(rejected); + Assert.Equal(LRReason.OutOfSight, reject); + Assert.False(ReceivedWorldItem(requesterNs, item)); + } + finally + { + DisposeClient(requesterNs, requester); + item.Delete(); + } + } +} diff --git a/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs new file mode 100644 index 000000000..4481b08b2 --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/PrivateChildPropertiesTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Buffers; +using Server.Items; +using Server.Network; +using Server.Tests.Network; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class PrivateChildPropertiesTests +{ + private class PublicContainer : Container + { + public PublicContainer() : base(0xE75) + { + } + + public override bool IsPublicContainer => true; + } + + private static readonly Point3D _ownerLoc = new(1600, 1600, 0); + + private static (NetState, Mobile) CreateClient(Point3D location) + { + var ns = PacketTestUtilities.CreateTestNetState(); + ns.Account = new MockAccount(); + + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + ns.Mobile = mobile; + mobile.NetState = ns; + mobile.MoveToWorld(location, Map.Felucca); + return (ns, mobile); + } + + private static void DisposeClient(NetState ns, Mobile mobile) + { + ns.Mobile = null; + ns.Dispose(); + mobile.Delete(); + } + + private static bool ReceivedOplInfo(NetState ns, Serial serial) + { + Span expected = stackalloc byte[5]; + var writer = new SpanWriter(expected); + writer.Write((byte)0xDC); // OPL info packet ID + writer.Write(serial); + return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; + } + + // AddItem/MoveToWorld already queue their own Update (+Properties) delta as a side effect of the + // Map setter. Draining it first isolates the properties-only delta the test actually exercises; + // otherwise the leftover Update flag routes through the Update branch and the test would pass or + // fail for the wrong reason. + private static void DrainPendingDelta(Item item) => item.ProcessDelta(); + + private static void InvalidateAndFlush(Item item) + { + // InvalidateProperties() only queues a delta when the OPL hash actually changes, which the + // drain above already stabilized. Queue ItemDelta.Properties directly so the test deterministically + // exercises the properties-only (no Update) branch under test. + item.Delta(ItemDelta.Properties); + item.ProcessDelta(); + } + + [Fact] + public void PrivateChildInOwnerBackpack_PropertiesOnlyUpdate_ReachesOwnerNotBystander() + { + var wasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + + var (ownerNs, owner) = CreateClient(_ownerLoc); + var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0)); + + var pack = new Container(0xE75) { Layer = Layer.Backpack }; + owner.AddItem(pack); + + var child = new Item(0x1234); + pack.DropItem(child); + + try + { + DrainPendingDelta(child); + InvalidateAndFlush(child); + + Assert.True(ReceivedOplInfo(ownerNs, child.Serial)); + Assert.False(ReceivedOplInfo(bystanderNs, child.Serial)); + } + finally + { + child.Delete(); + DisposeClient(ownerNs, owner); + DisposeClient(bystanderNs, bystander); + ObjectPropertyList.Enabled = wasEnabled; + } + } + + [Fact] + public void PrivateGroundContainer_PropertiesOnlyUpdate_ReachesOpenerNotNonOpener() + { + var wasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + + var (openerNs, opener) = CreateClient(_ownerLoc); + var (bystanderNs, bystander) = CreateClient(new Point3D(_ownerLoc.X + 2, _ownerLoc.Y, 0)); + + var container = new Container(0xE75); + container.MoveToWorld(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0), Map.Felucca); + container.Openers = [opener]; + + var child = new Item(0x1234); + container.DropItem(child); + + try + { + DrainPendingDelta(child); + InvalidateAndFlush(child); + + Assert.True(ReceivedOplInfo(openerNs, child.Serial)); + Assert.False(ReceivedOplInfo(bystanderNs, child.Serial)); + } + finally + { + container.Delete(); + DisposeClient(openerNs, opener); + DisposeClient(bystanderNs, bystander); + ObjectPropertyList.Enabled = wasEnabled; + } + } + + [Fact] + public void PublicContainerChild_PropertiesOnlyUpdate_StillReachesBystander() + { + var wasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + + var (bystanderNs, bystander) = CreateClient(_ownerLoc); + + var pack = new PublicContainer(); + pack.MoveToWorld(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0), Map.Felucca); + + var child = new Item(0x1234); + pack.DropItem(child); + + try + { + DrainPendingDelta(child); + InvalidateAndFlush(child); + + Assert.True(ReceivedOplInfo(bystanderNs, child.Serial)); + } + finally + { + pack.Delete(); + DisposeClient(bystanderNs, bystander); + ObjectPropertyList.Enabled = wasEnabled; + } + } + + [Fact] + public void GroundItem_PropertiesOnlyUpdate_StillReachesBystander() + { + var wasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + + var (bystanderNs, bystander) = CreateClient(_ownerLoc); + + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(_ownerLoc.X + 1, _ownerLoc.Y, 0), Map.Felucca); + + try + { + DrainPendingDelta(item); + InvalidateAndFlush(item); + + Assert.True(ReceivedOplInfo(bystanderNs, item.Serial)); + } + finally + { + item.Delete(); + DisposeClient(bystanderNs, bystander); + ObjectPropertyList.Enabled = wasEnabled; + } + } +} diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs index e18b999f0..00dfc4054 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using Server.Items; using Server.Network; using Xunit; @@ -196,4 +197,135 @@ public class ContainerPacketTests var result = ns.SendBuffer.GetReadSpan(); AssertThat.Equal(result, expected); } + + [Fact] + public void TestContainerContent_CapsAtProtocolLimit() + { + var cont = new Container(World.NewItem); + + // Past the 3448-entry cap (19-byte entries) so truncation engages. + const int itemCount = 5000; + for (var i = 0; i < itemCount; i++) + { + cont.AddItem(new Item(World.NewItem)); + } + + cont.Map = Map.Felucca; + + var m = new Mobile((Serial)0x1); + m.DefaultMobileInit(); + m.AccessLevel = AccessLevel.Administrator; + m.Map = Map.Felucca; + + try + { + using var ns = PacketTestUtilities.CreateTestNetState(); + // A ~64KB packet exceeds the 4KB pre-auth send buffer; an account lets Send() promote it + // on demand instead of exhausting the buffer and disconnecting. + ns.Account = new MockAccount(); + + var ex = Record.Exception(() => ns.SendContainerContent(m, cont)); + Assert.Null(ex); + + var span = ns.SendBuffer.GetReadSpan(); + Assert.Equal((byte)0x3C, span[0]); + + var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]); + var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]); + + const int entrySize = 19; // no grid lines negotiated on this NetState + var expectedMaxEntries = (ushort.MaxValue - 5) / entrySize; + + Assert.Equal(65517, length); + Assert.Equal(length, span.Length); + Assert.Equal(expectedMaxEntries, count); + } + finally + { + cont.Delete(); + m.Delete(); + } + } + + [Fact] + public void TestContainerContent_CapsAtProtocolLimit_WithGridLines() + { + var cont = new Container(World.NewItem); + + // Past the 3276-entry cap (20-byte entries with grid lines) so truncation engages. + const int itemCount = 5000; + for (var i = 0; i < itemCount; i++) + { + cont.AddItem(new Item(World.NewItem)); + } + + cont.Map = Map.Felucca; + + var m = new Mobile((Serial)0x1); + m.DefaultMobileInit(); + m.AccessLevel = AccessLevel.Administrator; + m.Map = Map.Felucca; + + try + { + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines; + ns.Account = new MockAccount(); + + var ex = Record.Exception(() => ns.SendContainerContent(m, cont)); + Assert.Null(ex); + + var span = ns.SendBuffer.GetReadSpan(); + Assert.Equal((byte)0x3C, span[0]); + + var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]); + var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]); + + Assert.Equal(65525, length); + Assert.Equal(length, span.Length); + Assert.Equal(3276, count); + } + finally + { + cont.Delete(); + m.Delete(); + } + } + + [Fact] + public void TestContainerContent_RentedBuffer_MatchesReferencePacket() + { + var cont = new Container(World.NewItem); + + // Below the entry cap but past the 1024-byte stackalloc threshold, so SendContainerContent + // rents its buffer instead of using the stack. + const int itemCount = 100; + for (var i = 0; i < itemCount; i++) + { + cont.AddItem(new Item(World.NewItem)); + } + + cont.Map = Map.Felucca; + + var m = new Mobile((Serial)0x1); + m.DefaultMobileInit(); + m.AccessLevel = AccessLevel.Administrator; + m.Map = Map.Felucca; + + try + { + var expected = new ContainerContent(m, cont).Compile(); + + using var ns = PacketTestUtilities.CreateTestNetState(); + ns.SendContainerContent(m, cont); + + var result = ns.SendBuffer.GetReadSpan(); + AssertThat.Equal(result, expected); + } + finally + { + cont.Delete(); + m.Delete(); + } + } } diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index 18aa27be5..d2fb7b590 100644 --- a/Projects/Server/Items/Container.cs +++ b/Projects/Server/Items/Container.cs @@ -390,6 +390,21 @@ public partial class Container : Item } } + /// + /// Direct children are left out of world saves; the container itself is still saved and + /// loads empty. + /// + public virtual bool SkipsChildSerialization => false; + + /// + /// Whether was sent to every client in range rather than only to the root + /// mobile, the secure-trade partner and openers. Removal of a public child is broadcast; removal of any + /// other child reaches only the clients that were sent it. Overrides may only widen this beyond + /// , never narrow it: a public container's children always broadcast + /// their removal regardless of what an override returns here. + /// + public virtual bool IsChildPublic(Item child) => IsPublicContainer; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public override void OnItemAdded(Item item) { diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 03ff9d000..4226f100f 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -922,7 +922,10 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } var info = LookupCompactInfo(); - var items = LookupItems(); + // Skipping containers never write their child serials: those children are not saved as + // top-level records (see SkipSerialization), so a written serial can be handed to an + // unrelated new item during the next load's synchronous deserialize loop. + var items = this is Container { SkipsChildSerialization: true } ? EmptyItems : LookupItems(); if (m_Direction != Direction.North) { @@ -1185,6 +1188,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert // are final yet. LastMoved = Core.Now; + // A parented item's removal already reached every client that was sent it (root, trade + // parties, openers) via RemoveItem below; sending another remove at oldLocation would + // broadcast it to bystanders who never knew about it. + var wasContained = Parent != null; + if (Parent is Mobile mobile) { mobile.RemoveItem(this); @@ -1202,7 +1210,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { m_Map.OnLeave(this); - if (oldLocation.m_X != 0) + if (!wasContained && oldLocation.m_X != 0) { SendRemovePacket(oldLocation); } @@ -1278,7 +1286,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } else if (m_Map != null) { - if (oldLocation.m_X != 0) + if (!wasContained && oldLocation.m_X != 0) { var removeEntity = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength].InitializePacket(); @@ -1344,7 +1352,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert m_Map.OnLeave(this); } - SendRemovePacket(); + if (m_Parent is not Container) + { + // A contained item only changes map along with its parent or root; their own + // removal already clears this subtree for every client that knew about it. + SendRemovePacket(); + } } var items = LookupItems(); @@ -1479,6 +1492,43 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert return; } + // A properties-only invalidation (no Update) on a private child must stay as scoped as every + // other private-child send, otherwise a bystander who was never sent the item learns its OPL hash. + if (!update && ObjectPropertyList.Enabled && (flags & ItemDelta.Properties) != 0 && + TryGetPrivateParent(out var cont)) + { + GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo); + + SendPrivateOplTo(root, worldLoc); + + if (tradeFrom != root) + { + SendPrivateOplTo(tradeFrom, worldLoc); + } + + if (tradeTo != root && tradeTo != tradeFrom) + { + SendPrivateOplTo(tradeTo, worldLoc); + } + + var privateOpeners = cont.Openers; + + if (privateOpeners != null) + { + for (var i = 0; i < privateOpeners.Count; ++i) + { + var mob = privateOpeners[i]; + + if (mob != root && mob != tradeFrom && mob != tradeTo) + { + SendPrivateOplTo(mob, worldLoc); + } + } + } + + return; + } + // A second item sharing an equipment layer is omitted by SendMobileIncoming (0x78); // sending it on its own via EquipUpdate/OPL would leave the client with two items on // one slot. Skip the per-client sends entirely for the dupe. @@ -1581,7 +1631,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert ClearProperties(); } - public virtual bool SkipSerialization => false; + public virtual bool SkipSerialization => m_Parent is Container { SkipsChildSerialization: true }; [IgnoreDupe] public ISpawner Spawner @@ -2926,6 +2976,13 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert m_Parent = null; } + // A skipping container never wrote its children's serials (see Serialize), so this + // item's frozen parent reference is stale/unreachable: treat it as a missing parent. + if (m_Parent is Container { SkipsChildSerialization: true }) + { + m_Parent = null; + } + if (m_Parent == null && (parent.IsMobile || parent.IsItem)) { Timer.DelayCall(Delete); @@ -3089,6 +3146,13 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert m_Parent = null; } + // A skipping container never wrote its children's serials (see Serialize), so this + // item's frozen parent reference is stale/unreachable: treat it as a missing parent. + if (m_Parent is Container { SkipsChildSerialization: true }) + { + m_Parent = null; + } + if (m_Parent == null && (parent.IsMobile || parent.IsItem)) { Timer.DelayCall(Delete); @@ -3552,8 +3616,19 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { var items = LookupItems(); - if (items.Remove(item)) + // Bulk removal (Delete, purges) walks children back to front; finding the tail in O(1) keeps + // deleting a large container linear instead of quadratic. + var index = items.Count - 1; + + if (index < 0 || !ReferenceEquals(items[index], item)) { + index = items.IndexOf(item); + } + + if (index >= 0) + { + items.RemoveAt(index); + if (this is not Container) { AcquireCompactInfo().Version++; @@ -4018,6 +4093,43 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert var removeEntity = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength].InitializePacket(); + if (TryGetPrivateParent(out var cont)) + { + // Private children are only ever sent to these recipients (see ProcessDelta); nobody else knows them. + OutgoingEntityPackets.CreateRemoveEntity(removeEntity, Serial); + + GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo); + + SendRemoveTo(root, worldLoc, removeEntity); + + if (tradeFrom != root) + { + SendRemoveTo(tradeFrom, worldLoc, removeEntity); + } + + if (tradeTo != root && tradeTo != tradeFrom) + { + SendRemoveTo(tradeTo, worldLoc, removeEntity); + } + + var openers = cont.Openers; + + if (openers != null) + { + for (var i = 0; i < openers.Count; ++i) + { + var mob = openers[i]; + + if (mob != root && mob != tradeFrom && mob != tradeTo) + { + SendRemoveTo(mob, worldLoc, removeEntity); + } + } + } + + return; + } + foreach (var state in m_Map.GetClientsInRange(worldLoc, GetMaxUpdateRange())) { var m = state.Mobile; @@ -4030,6 +4142,78 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } } + private void SendRemoveTo(Mobile m, Point3D worldLoc, ReadOnlySpan removeEntity) + { + if (m?.NetState != null && m.Map == m_Map && m.InRange(worldLoc, GetUpdateRange(m))) + { + m.NetState.Send(removeEntity); + } + } + + // Same recipient gate as SendRemoveTo, plus CanSee: this is an informational OPL push, not + // cleanup of a reference the client may already hold regardless of visibility. + private void SendPrivateOplTo(Mobile m, Point3D worldLoc) + { + var ns = m?.NetState; + + if (ns != null && m.CanSee(this) && m.Map == m_Map && m.InRange(worldLoc, GetUpdateRange(m))) + { + SendOPLPacketTo(ns); + } + } + + // Shared with IsSentTo so the private-child guard and recipient set (root/trade/openers) each live + // in one place. + private bool TryGetPrivateParent(out Container cont) + { + cont = m_Parent as Container; + return cont != null && !cont.IsPublicContainer && !cont.IsChildPublic(this); + } + + private void GetPrivateChildRecipients(Container cont, out Mobile root, out Mobile tradeFrom, out Mobile tradeTo) + { + root = cont.RootParent as Mobile; + + var trade = GetSecureTradeCont()?.Trade; + // Trade.From/To are unassigned while SecureTrade's own constructor is still adding the + // VirtualCheck to each side's container, so both must stay null-conditional. + tradeFrom = trade?.From?.Mobile; + tradeTo = trade?.To?.Mobile; + } + + /// + /// Whether 's client is expected to hold this item; gates resends keyed by a + /// client-supplied serial. + /// + public bool IsSentTo(Mobile m) + { + if (m == null || Deleted) + { + return false; + } + + if (TryGetPrivateParent(out var cont)) + { + GetPrivateChildRecipients(cont, out var root, out var tradeFrom, out var tradeTo); + + if (m == root) + { + // Mirrors ProcessDelta's own root gate: CanSee and in range. + return m.CanSee(this) && m.InRange(GetWorldLocation(), GetUpdateRange(m)); + } + + var isTradeOrOpener = m == tradeFrom || m == tradeTo || cont.Openers?.Contains(m) == true; + + // Trade parties and openers are re-validated against ProcessDelta's own gate (CanSee, + // current map, update range), so an invisible item or a stale Openers entry that hasn't + // been pruned yet never resyncs to them. + return isTradeOrOpener && m.CanSee(this) && m.Map == m_Map && + m.InRange(GetWorldLocation(), GetUpdateRange(m)); + } + + return m.CanSee(this) && m.InRange(GetWorldLocation(), GetUpdateRange(m)); + } + public virtual int GetDropSound() => -1; public Point3D GetWorldLocation() diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index d1db8113b..52628e74a 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -5203,7 +5203,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { state.SendLiftReject(reject); - if (item.Deleted) + // A rejection must not resynchronize state this client was never sent (client-supplied serial). + if (item.Deleted || !item.IsSentTo(this)) { return; } diff --git a/Projects/Server/Network/Packets/OutgoingContainerPackets.cs b/Projects/Server/Network/Packets/OutgoingContainerPackets.cs index fdb71bf90..b1d846e7b 100644 --- a/Projects/Server/Network/Packets/OutgoingContainerPackets.cs +++ b/Projects/Server/Network/Packets/OutgoingContainerPackets.cs @@ -16,6 +16,7 @@ using System; using System.Buffers; using System.IO; +using Server.Buffers; using Server.Logging; namespace Server.Network; @@ -166,6 +167,10 @@ public static class OutgoingContainerPackets ns.Send(writer.Span); } + // 0x3C's length and item-count fields are both 16-bit, so a container's contents can never + // legally fill more than this many bytes regardless of how many items it actually holds. + private const int MaxContainerContentBytes = ushort.MaxValue; + public static void SendContainerContent(this NetState ns, Mobile beholder, Item beheld) { if (ns.CannotSendPackets()) @@ -175,43 +180,63 @@ public static class OutgoingContainerPackets var items = beheld.Items; var count = items.Count; + var entrySize = ns.ContainerGridLines ? 20 : 19; - var writer = new SpanWriter(stackalloc byte[5 + items.Count * (ns.ContainerGridLines ? 20 : 19)]); - writer.Write((byte)0x3C); // Packet ID - writer.Seek(4, SeekOrigin.Current); // Length & written count + var maxEntries = (MaxContainerContentBytes - 5) / entrySize; + var entriesToSend = Math.Min(count, maxEntries); + var length = 5 + entriesToSend * entrySize; - var written = 0; + // A container holding thousands of items would blow the stack; rent from the pool past a + // small, safe threshold instead. + byte[] rented = null; + var buffer = length <= 1024 ? stackalloc byte[length] : rented = STArrayPool.Shared.Rent(length); - for (var i = 0; i < count; ++i) + try { - var child = items[i]; + var writer = new SpanWriter(buffer[..length]); + writer.Write((byte)0x3C); // Packet ID + writer.Seek(4, SeekOrigin.Current); // Length & written count - if (!child.Deleted && beholder.CanSee(child)) + var written = 0; + + for (var i = 0; i < count && written < entriesToSend; ++i) { - var loc = child.Location; + var child = items[i]; - writer.Write(child.Serial); - writer.Write((ushort)child.ItemID); - writer.Write((byte)0); // signed, itemID offset - writer.Write((ushort)Math.Min(child.Amount, ushort.MaxValue)); - writer.Write((short)loc.X); - writer.Write((short)loc.Y); - if (ns.ContainerGridLines) + if (!child.Deleted && beholder.CanSee(child)) { - writer.Write((byte)0); // Grid Location? - } - writer.Write(beheld.Serial); - writer.Write((ushort)(child.QuestItem ? Item.QuestItemHue : child.Hue)); + var loc = child.Location; - ++written; + writer.Write(child.Serial); + writer.Write((ushort)child.ItemID); + writer.Write((byte)0); // signed, itemID offset + writer.Write((ushort)Math.Min(child.Amount, ushort.MaxValue)); + writer.Write((short)loc.X); + writer.Write((short)loc.Y); + if (ns.ContainerGridLines) + { + writer.Write((byte)0); // Grid Location? + } + writer.Write(beheld.Serial); + writer.Write((ushort)(child.QuestItem ? Item.QuestItemHue : child.Hue)); + + ++written; + } + } + + writer.Seek(1, SeekOrigin.Begin); + writer.Write((ushort)writer.BytesWritten); + writer.Write((ushort)written); + writer.Seek(0, SeekOrigin.End); + + ns.Send(writer.Span); + } + finally + { + if (rented != null) + { + STArrayPool.Shared.Return(rented); } } - - writer.Seek(1, SeekOrigin.Begin); - writer.Write((ushort)writer.BytesWritten); - writer.Write((ushort)written); - writer.Seek(0, SeekOrigin.End); - - ns.Send(writer.Span); } } diff --git a/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs new file mode 100644 index 000000000..01a58a697 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Misc/Corpses/CorpseChildPublicTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Accounting; +using Server.Items; +using Server.Network; +using Server.Tests.Network; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class CorpseChildPublicTests +{ + // Weapons take their layer from tiledata, which CI doesn't load; without an explicit layer the + // equip is silently refused and the corpse's worn-gear list stays empty. + private static VikingSword EquipSword(Mobile owner) + { + var sword = new VikingSword { Layer = Layer.OneHanded }; + Assert.True(owner.EquipItem(sword)); + return sword; + } + + [Fact] + public void IsChildPublic_HumanCorpseWithWornGear_ReturnsTrue() + { + var owner = new Mobile((Serial)0x1); + owner.DefaultMobileInit(); + owner.Body = 0x190; + + var wornItem = EquipSword(owner); + + var corpse = new Corpse(owner, owner.Items); + + try + { + Assert.Equal(0x2006, corpse.ItemID); + Assert.True(((Body)corpse.Amount).IsHuman); + Assert.True(corpse.IsChildPublic(wornItem)); + } + finally + { + corpse.Delete(); + owner.Delete(); + } + } + + [Fact] + public void IsChildPublic_HumanCorpseWithLootItem_ReturnsFalse() + { + var owner = new Mobile((Serial)0x1); + owner.DefaultMobileInit(); + owner.Body = 0x190; + + var wornItem = EquipSword(owner); + + var corpse = new Corpse(owner, owner.Items); + + var lootItem = new Gold(100); + corpse.DropItem(lootItem); + + try + { + Assert.Equal(0x2006, corpse.ItemID); + Assert.True(((Body)corpse.Amount).IsHuman); + Assert.False(corpse.IsChildPublic(lootItem)); + } + finally + { + corpse.Delete(); + owner.Delete(); + } + } + + [Fact] + public void IsChildPublic_BoneCorpse_ReturnsFalse() + { + var owner = new Mobile((Serial)0x1); + owner.DefaultMobileInit(); + owner.Body = 0x190; + + var wornItem = EquipSword(owner); + + var corpse = new Corpse(owner, owner.Items); + + try + { + corpse.ItemID = 0xECA; + + Assert.True(((Body)corpse.Amount).IsHuman); + Assert.NotEqual(0x2006, corpse.ItemID); + Assert.False(corpse.IsChildPublic(wornItem)); + } + finally + { + corpse.Delete(); + owner.Delete(); + } + } + + [Fact] + public void IsChildPublic_NonHumanCorpse_ReturnsFalse() + { + var owner = new Mobile((Serial)0x1); + owner.DefaultMobileInit(); + owner.Body = 0xC9; + + var wornItem = EquipSword(owner); + + var corpse = new Corpse(owner, owner.Items); + + try + { + Assert.Equal(0x2006, corpse.ItemID); + Assert.False(((Body)corpse.Amount).IsHuman); + Assert.False(corpse.IsChildPublic(wornItem)); + } + finally + { + corpse.Delete(); + owner.Delete(); + } + } + + private static bool ReceivedRemove(NetState ns, Serial serial) + { + Span expected = stackalloc byte[OutgoingEntityPackets.RemoveEntityLength]; + OutgoingEntityPackets.CreateRemoveEntity(expected, serial); + return ns.SendBuffer.GetReadSpan().IndexOf(expected) >= 0; + } + + [Fact] + public void HumanCorpse_WornGearRemovalBroadcasts_LootRemovalDoesNot() + { + var corpseLoc = new Point3D(1500, 1500, 0); + + var bystanderNs = PacketTestUtilities.CreateTestNetState(); + bystanderNs.Account = new MockAccount(); + var bystander = new Mobile(World.NewMobile); + bystander.DefaultMobileInit(); + bystanderNs.Mobile = bystander; + bystander.NetState = bystanderNs; + bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); + + var owner = new Mobile((Serial)0x1); + owner.DefaultMobileInit(); + owner.Body = 0x190; + + var sword = new VikingSword(); + var corpse = new Corpse(owner, [sword]); + corpse.AddItem(sword); + + var lootItem = new Gold(100); + corpse.DropItem(lootItem); + + corpse.MoveToWorld(corpseLoc, Map.Felucca); + + Container pouch = null; + + try + { + // A container not in the world is a valid destination; only the removal from the corpse matters here. + pouch = new Container(0xE75); + pouch.AddItem(sword); + + Assert.True(ReceivedRemove(bystanderNs, sword.Serial)); + + lootItem.Delete(); + Assert.False(ReceivedRemove(bystanderNs, lootItem.Serial)); + } + finally + { + pouch?.Delete(); + corpse.Delete(); + owner.Delete(); + bystanderNs.Mobile = null; + bystanderNs.Dispose(); + bystander.Delete(); + } + } + + [Fact] + public void HumanCorpse_LiftingWornGear_BroadcastsRemoval() + { + var corpseLoc = new Point3D(1500, 1500, 0); + + var bystanderNs = PacketTestUtilities.CreateTestNetState(); + bystanderNs.Account = new MockAccount(); + var bystander = new Mobile(World.NewMobile); + bystander.DefaultMobileInit(); + bystanderNs.Mobile = bystander; + bystander.NetState = bystanderNs; + bystander.MoveToWorld(new Point3D(corpseLoc.X + 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); + + var owner = new Mobile(World.NewMobile); + owner.DefaultMobileInit(); + owner.Body = 0x190; + owner.MoveToWorld(new Point3D(corpseLoc.X - 1, corpseLoc.Y, corpseLoc.Z), Map.Felucca); + + var sword = new VikingSword { Layer = Layer.OneHanded }; + var corpse = new Corpse(owner, [sword]); + corpse.AddItem(sword); + corpse.MoveToWorld(corpseLoc, Map.Felucca); + + try + { + // Lifting takes the piece off the worn list before the remove is sent; the remove must + // still reach everyone who was shown it worn. + owner.Lift(sword, sword.Amount, out var rejected, out _); + + Assert.False(rejected); + Assert.DoesNotContain(sword, corpse.EquipItems); + Assert.True(ReceivedRemove(bystanderNs, sword.Serial)); + } + finally + { + owner.Holding = null; + sword.Delete(); + corpse.Delete(); + owner.Delete(); + bystanderNs.Mobile = null; + bystanderNs.Dispose(); + bystander.Delete(); + } + } +} diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 2cd0cea89..29a529ae5 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -100,6 +100,10 @@ public partial class Corpse : Container, ICarvable private Dictionary _instancedItems; + // Worn gear being lifted is off _equipItems before its remove goes out; until then it stays public so + // the remove reaches everyone who was shown it worn. + private Item _liftingWornItem; + [SerializableField(0)] private List _restoreEquip; @@ -197,6 +201,12 @@ public partial class Corpse : Container, ICarvable // Why was this public? // public override bool IsPublicContainer => true; + // Human corpses show their worn gear to everyone who sees them (SendInfoTo), not only to openers. + private bool ShowsWornGear => ((Body)Amount).IsHuman && ItemID == 0x2006; + + public override bool IsChildPublic(Item child) => + ShowsWornGear && (child == _liftingWornItem || _equipItems?.Contains(child) == true); + public Corpse(Mobile owner, List equipItems) : this(owner, 0, 0, 0, 0, equipItems) { } @@ -416,7 +426,7 @@ public partial class Corpse : Container, ICarvable { from.SendLocalizedMessage(500485); // You see nothing useful to carve from the corpse. } - else if (((Body)Amount).IsHuman && ItemID == 0x2006) + else if (ShowsWornGear) { new Blood(0x122D).MoveToWorld(Location, Map); @@ -587,6 +597,7 @@ public partial class Corpse : Container, ICarvable { _decayTimer?.Stop(); _decayTimer = null; + _liftingWornItem = null; } public static string GetCorpseName(Mobile m) => m is BaseCreature bc ? bc.CorpseNameOverride ?? bc.CorpseName : null; @@ -740,7 +751,7 @@ public partial class Corpse : Container, ICarvable { base.SendInfoTo(ns, world); - if (((Body)Amount).IsHuman && ItemID == 0x2006) + if (ShowsWornGear) { ns.SendCorpseContent(ns.Mobile, this); ns.SendCorpseEquip(ns.Mobile, this); @@ -815,7 +826,20 @@ public partial class Corpse : Container, ICarvable // Lifted gear is loot from then on. Clients only learn worn gear from 0x89 on first sight, so a // piece put back would show worn after a relog but loose to everyone already watching. - RemoveFromEquipItems(item); + if (_equipItems != null && this.Remove(_equipItems, item)) + { + _liftingWornItem = item; + } + } + + public override void OnItemRemoved(Item item) + { + base.OnItemRemoved(item); + + if (item == _liftingWornItem) + { + _liftingWornItem = null; + } } public override void GetContextMenuEntries(Mobile from, ref PooledRefList list) From d857f03b68521702f3ad598cc70c8ceb3117c226 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:56:39 -0700 Subject: [PATCH 31/31] fix: bound and purge NPC vendor buyback stock (#2677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Stacked on #2676. Review that first; this PR's diff is against it. Items players sell to NPC vendors go into the vendor's buyback (`ShopBuy`) pack. That pack was purged only when someone opened the buy menu, so a vendor that players sold to but never bought from grew without limit. - A server operator reported 250k+ items piling up from players crafting and selling on a macro. - Every one of those items was saved. - Purging or deleting that vendor then sent one remove packet per item to every nearby client and disconnected them. The behavior is inherited from RunUO. - **`VendorBuybackPack`** replaces the plain backpack on `Layer.ShopBuy`. - It opts in to `SkipsChildSerialization`, so buyback items are never saved. - Its contents are private like any container's, so removing them reaches only the vendor (no client) and openers. Purges and deletes send nothing to bystanders. - **Capacity.** The buyback holds at most `250 − buy-list entries` items, which is what the buy gump can show. The oldest items are evicted first. At capacity 0 the sold item is consumed and the seller is still paid. - **Purge on the restock grid.** - `Restock()` empties the buyback. - A purge-only timer, armed on the first sale, covers vendors nobody buys from. It is due at the next restock boundary after now, so a vendor whose restock hasn't run for hours doesn't purge an item the moment it's sold. - The timer never restocks, so stock doubling and halving is unchanged. - The old per-item one-hour `LastMoved` check is gone. - **Existing saves.** A legacy `ShopBuy` pack is replaced on the first tick after load. Thanks to #2676, deleting a pack of 250k+ items takes well under a second. **Behavior changes for shard operators:** - A buyback item lasts until the next restock boundary (at most `RestockDelay`), not one hour after its sale. - A vendor that changes maps drops its buyback. - A buy window that is open during a purge can show a row whose item is gone. Buying that row does nothing, and reopening shows the current list. ## Test plan - [x] `VendorBuybackPackTests`: evicts oldest first; capacity 0 consumes the item; purge; a stale front entry can't spin eviction - [x] `VendorBuybackTests`: - purge due time stays on the restock grid, including a stale restock and a restock time in the future; - partial stack sale: the split goes to buyback and the seller keeps the remainder; - zero capacity consumes the item and still pays; - restock purges and clears the timer, and the next sale re-arms it; - deleting the vendor cancels the timer; - a legacy 20k-item pack is replaced. - [x] UOContent.Tests and Server.Tests green; schema generator clean - [x] Manual in-game pass: - sell and buy back, including a partial stack; - over-capacity sale; - purge with no buyer; - `[restock`; - vendor facet change; - restart; - legacy save with a large `ShopBuy` pack; - mass selling with bystanders present; - deleting a vendor with a full buyback. --- .../Tests/Items/VendorBuybackPackTests.cs | 111 +++++++ .../Tests/Mobiles/VendorBuybackTests.cs | 278 ++++++++++++++++++ .../Server.Items.VendorBuybackPack.v0.json | 4 + .../UOContent/Mobiles/Vendors/BaseVendor.cs | 155 ++++++---- .../Mobiles/Vendors/VendorBuybackPack.cs | 56 ++++ 5 files changed, 545 insertions(+), 59 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs create mode 100644 Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json create mode 100644 Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs diff --git a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs new file mode 100644 index 000000000..cc0ccad42 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs @@ -0,0 +1,111 @@ +using Server.Items; +using Xunit; + +namespace Server.Tests.Items; + +[Collection("Sequential UOContent Tests")] +public class VendorBuybackPackTests +{ + [Fact] + public void AddBuyback_EvictsOldestFirst_WhenFull() + { + var pack = new VendorBuybackPack(); + var items = new Item[5]; + + try + { + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.AddBuyback(items[i], 3); + } + + Assert.Equal(3, pack.Items.Count); + Assert.True(items[0].Deleted); + Assert.True(items[1].Deleted); + Assert.Same(items[2], pack.Items[0]); + Assert.Same(items[3], pack.Items[1]); + Assert.Same(items[4], pack.Items[2]); + } + finally + { + pack.Delete(); + } + } + + [Fact] + public void AddBuyback_WithNoCapacity_ConsumesTheItem() + { + var pack = new VendorBuybackPack(); + var item = new Item(0x1234); + + try + { + pack.AddBuyback(item, 0); + + Assert.True(item.Deleted); + Assert.Empty(pack.Items); + } + finally + { + pack.Delete(); + } + } + + // DropItem rejects deleted items, so the stale entry is forced into Items directly. + [Fact] + public void AddBuyback_SkipsAlreadyDeletedEntry_WithoutSpinning() + { + var pack = new VendorBuybackPack(); + var real1 = new Item(0x1234); + var stale = new Item(0x1234); + var real2 = new Item(0x1234); + + try + { + pack.AddBuyback(real1, 100); + + stale.Delete(); + pack.Items.Insert(0, stale); + + pack.AddBuyback(real2, 1); + + Assert.True(real1.Deleted); + Assert.True(stale.Deleted); + Assert.Single(pack.Items); + Assert.Same(real2, pack.Items[0]); + } + finally + { + pack.Delete(); + } + } + + [Fact] + public void Purge_DeletesEveryChild() + { + var pack = new VendorBuybackPack(); + var items = new Item[10]; + + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.AddBuyback(items[i], 250); + } + + try + { + pack.Purge(); + + Assert.Empty(pack.Items); + for (var i = 0; i < items.Length; i++) + { + Assert.True(items[i].Deleted); + } + } + finally + { + pack.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs new file mode 100644 index 000000000..f61ca6229 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace Server.Tests.Mobiles; + +[Collection("Sequential UOContent Tests")] +public class VendorBuybackTests : IDisposable +{ + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class BandageSB : SBInfo + { + private readonly GenericSellInfo _sellInfo = new(); + + public BandageSB() => _sellInfo.Add(typeof(Bandage), 2); + + public override IShopSellInfo SellInfo => _sellInfo; + + public override List BuyInfo { get; } = []; + } + + private sealed class VendorStub : BaseVendor + { + private readonly List _sbInfos = []; + + public VendorStub() : base("the stub") + { + } + + public VendorStub(Serial serial) : base(serial) + { + } + + protected override List SBInfos => _sbInfos; + + public override void InitSBInfo() + { + _sbInfos.Add(new BandageSB()); + } + + public override void InitOutfit() + { + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + // Same sell price as BandageSB, but a buy list at the 250-entry limit so buyback capacity is 0. + private sealed class FullStockSB : SBInfo + { + private readonly GenericSellInfo _sellInfo = new(); + private readonly List _buyInfo = []; + + public FullStockSB() + { + _sellInfo.Add(typeof(Bandage), 2); + + for (var i = 0; i < 250; i++) + { + _buyInfo.Add(new GenericBuyInfo(typeof(Bandage), 5, 20, 0xE21, 0)); + } + } + + public override IShopSellInfo SellInfo => _sellInfo; + + public override List BuyInfo => _buyInfo; + } + + private sealed class FullStockVendorStub : BaseVendor + { + private readonly List _sbInfos = []; + + public FullStockVendorStub() : base("the stub") + { + } + + protected override List SBInfos => _sbInfos; + + public override void InitSBInfo() + { + _sbInfos.Add(new FullStockSB()); + } + + public override void InitOutfit() + { + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + private VendorStub NewVendor() + { + var vendor = new VendorStub(); + _created.Add(vendor); + return vendor; + } + + private FullStockVendorStub NewFullStockVendor() + { + var vendor = new FullStockVendorStub(); + _created.Add(vendor); + return vendor; + } + + private Mobile NewSeller() + { + var seller = new Mobile(World.NewMobile); + seller.DefaultMobileInit(); + seller.AddItem(new Backpack()); + _created.Add(seller); + return seller; + } + + [Theory] + [InlineData(0.25, 1.0)] // restocked 15m ago: next boundary is the regular one + [InlineData(5.0, 6.0)] // exactly on a boundary: strictly after now + [InlineData(5.5, 6.0)] // lazy restock hasn't run for hours: stay on the grid, don't fire now + public void NextBuybackPurge_IsTheNextRestockBoundaryAfterNow(double hoursSinceRestock, double expectedHours) + { + var lastRestock = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var now = lastRestock + TimeSpan.FromHours(hoursSinceRestock); + + var next = BaseVendor.GetNextBuybackPurge(lastRestock, TimeSpan.FromHours(1), now); + + Assert.Equal(lastRestock + TimeSpan.FromHours(expectedHours), next); + } + + // Backwards clock adjustment: LastRestock ahead of now clamps to now. + [Fact] + public void NextBuybackPurge_FutureAnchor_IsClampedToNow() + { + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var lastRestock = now + TimeSpan.FromHours(4); + + var next = BaseVendor.GetNextBuybackPurge(lastRestock, TimeSpan.FromHours(1), now); + + Assert.Equal(now + TimeSpan.FromHours(1), next); + } + + [Fact] + public void PartialStackSale_SplitGoesToBuyback_SellerKeepsRemainder() + { + var vendor = NewVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + + Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 4)])); + + Assert.Equal(6, bandages.Amount); + Assert.Same(seller.Backpack, bandages.Parent); + + var buyback = vendor.BuyPack.Items; + Assert.Single(buyback); + Assert.IsType(buyback[0]); + Assert.Equal(4, buyback[0].Amount); + Assert.True(buyback[0].SkipSerialization); + Assert.True(vendor.BuybackPurgeScheduled); + } + + [Fact] + public void Restock_PurgesBuyback() + { + var vendor = NewVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + Assert.Single(vendor.BuyPack.Items); + + vendor.Restock(); + + Assert.Empty(vendor.BuyPack.Items); + Assert.True(bandages.Deleted); + } + + [Fact] + public void Restock_ClearsPurgeSchedule_AndASubsequentSaleRearmsIt() + { + var vendor = NewVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + Assert.True(vendor.BuybackPurgeScheduled); + + vendor.Restock(); + + Assert.False(vendor.BuybackPurgeScheduled); + + var seller2 = NewSeller(); + var moreBandages = new Bandage(10); + seller2.Backpack.DropItem(moreBandages); + vendor.OnSellItems(seller2, [new SellItemResponse(moreBandages, 10)]); + + Assert.True(vendor.BuybackPurgeScheduled); + } + + [Fact] + public void Delete_CancelsBuybackPurgeTimer() + { + var vendor = NewVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + Assert.True(vendor.BuybackPurgeScheduled); + + vendor.Delete(); + + Assert.False(vendor.BuybackPurgeScheduled); + } + + [Fact] + public void LegacyBuyPack_IsReplaced_AndItsContentsDeleted() + { + var vendor = NewVendor(); + vendor.FindItemOnLayer(Layer.ShopBuy).Delete(); + + var legacy = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; + vendor.AddItem(legacy); + + var children = new Item[20_000]; + for (var i = 0; i < children.Length; i++) + { + children[i] = new Item(0x1234); + legacy.DropItem(children[i]); + } + + var pack = vendor.BuyPack; + + Assert.IsType(pack); + Assert.Same(pack, vendor.FindItemOnLayer(Layer.ShopBuy)); + Assert.True(legacy.Deleted); + for (var i = 0; i < children.Length; i++) + { + Assert.True(children[i].Deleted); + } + } + + [Fact] + public void SaleWithNoBuybackCapacity_ConsumesItem_AndStillPays() + { + var vendor = NewFullStockVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + + Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)])); + + Assert.True(bandages.Deleted); + Assert.Empty(vendor.BuyPack.Items); + Assert.False(vendor.BuybackPurgeScheduled); + + var gold = seller.Backpack.FindItemByType(); + Assert.NotNull(gold); + Assert.Equal(20, gold.Amount); + } +} diff --git a/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json b/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json new file mode 100644 index 000000000..38b4bbd59 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json @@ -0,0 +1,4 @@ +{ + "version": 0, + "type": "Server.Items.VendorBuybackPack" +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs index a6467746b..27f270648 100644 --- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs @@ -31,7 +31,10 @@ namespace Server.Mobiles private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseVendor)); private const int MaxSell = 500; - private static readonly TimeSpan InventoryDecayTime = TimeSpan.FromHours(1.0); + // One (not all) of the buy packets counts entries in a byte. + private const int BuyListLimit = 250; + + private TimerExecutionToken _buybackPurgeToken; private readonly List _buyInfo = new(); private readonly List _sellInfo = new(); @@ -71,11 +74,8 @@ namespace Server.Mobiles SetSpeed(0.5, 2.0); // these packs MUST exist, or the client will crash when the packets are sent - Container pack = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; - AddItem(pack); - - pack = new Backpack { Layer = Layer.ShopResale, Movable = false, Visible = false }; - AddItem(pack); + AddItem(new VendorBuybackPack()); + AddItem(new Backpack { Layer = Layer.ShopResale, Movable = false, Visible = false }); LastRestock = Core.Now; } @@ -105,20 +105,79 @@ namespace Server.Mobiles public override bool ShowFameTitle => false; - public Container BuyPack + public VendorBuybackPack BuyPack { get { - if (FindItemOnLayer(Layer.ShopBuy) is not Container pack) + var existing = FindItemOnLayer(Layer.ShopBuy); + + if (existing is VendorBuybackPack pack) { - pack = new Backpack { Layer = Layer.ShopBuy, Visible = false }; - AddItem(pack); + return pack; } + // Legacy pack; deleting it stays linear even with a huge backlog. + existing?.Delete(); + + pack = new VendorBuybackPack(); + AddItem(pack); return pack; } } + internal bool BuybackPurgeScheduled => _buybackPurgeToken.Running; + + internal static DateTime GetNextBuybackPurge(DateTime lastRestock, TimeSpan restockDelay, DateTime now) + { + if (restockDelay <= TimeSpan.Zero) + { + return now; + } + + // A backwards clock adjustment can future-date the anchor past RestockDelay. + if (lastRestock > now) + { + lastRestock = now; + } + + var next = lastRestock + restockDelay; + + if (next > now) + { + return next; + } + + // Restock is lazy and may be hours overdue; stay on its grid. + var periods = (now - lastRestock).Ticks / restockDelay.Ticks + 1; + return lastRestock + TimeSpan.FromTicks(restockDelay.Ticks * periods); + } + + private void AddToBuyback(Item item, int capacity) + { + BuyPack.AddBuyback(item, capacity); + + if (!item.Deleted && !_buybackPurgeToken.Running) + { + var delay = GetNextBuybackPurge(LastRestock, RestockDelay, Core.Now) - Core.Now; + Timer.StartTimer(delay, CancelAndPurgeBuyback, out _buybackPurgeToken); + } + } + + private void PurgeBuyback() => (FindItemOnLayer(Layer.ShopBuy) as VendorBuybackPack)?.Purge(); + + // A fired token-bearing timer isn't returned to its pool until its token is cancelled. + private void CancelAndPurgeBuyback() + { + _buybackPurgeToken.Cancel(); + PurgeBuyback(); + } + + public override void OnDelete() + { + _buybackPurgeToken.Cancel(); + base.OnDelete(); + } + public virtual bool IsTokunoVendor => Map == Map.Tokuno; public virtual bool IsTerMurVendor => Map == Map.TerMur; @@ -132,6 +191,7 @@ namespace Server.Mobiles public virtual void Restock() { LastRestock = Core.Now; + CancelAndPurgeBuyback(); var buyInfo = GetBuyInfo(); @@ -427,6 +487,7 @@ namespace Server.Mobiles var info = GetSellInfo(); var buyInfo = GetBuyInfo(); + var buybackCapacity = Math.Max(0, BuyListLimit - buyInfo.Length); var GiveGold = 0; var Sold = 0; @@ -498,28 +559,14 @@ namespace Server.Mobiles if (!found) { - var cont = BuyPack; + var sold = resp.Item; if (amount < resp.Item.Amount) { - var item = LiftItemDupe(resp.Item, resp.Item.Amount - amount); + sold = LiftItemDupe(resp.Item, resp.Item.Amount - amount) ?? resp.Item; + } - if (item != null) - { - item.SetLastMoved(); - cont.DropItem(item); - } - else - { - resp.Item.SetLastMoved(); - cont.DropItem(resp.Item); - } - } - else - { - resp.Item.SetLastMoved(); - cont.DropItem(resp.Item); - } + AddToBuyback(sold, buybackCapacity); } } else @@ -738,6 +785,9 @@ namespace Server.Mobiles CheckMorph(); LoadSBInfo(); + + // LoadSBInfo re-anchored the restock grid. Not done there since it also runs on deserialize. + CancelAndPurgeBuyback(); } public virtual int GetRandomNecromancerHue() @@ -882,7 +932,7 @@ namespace Server.Mobiles { var buyItem = buyInfo[idx]; - if (buyItem.Amount <= 0 || list.Count >= 250) + if (buyItem.Amount <= 0 || list.Count >= BuyListLimit) { continue; } @@ -914,21 +964,6 @@ namespace Server.Mobiles var playerItems = cont.Items; - for (var i = playerItems.Count - 1; i >= 0; --i) - { - if (i >= playerItems.Count) - { - continue; - } - - var item = playerItems[i]; - - if (item.LastMoved + InventoryDecayTime <= Core.Now) - { - item.Delete(); - } - } - for (var i = 0; i < playerItems.Count; ++i) { var item = playerItems[i]; @@ -946,17 +981,13 @@ namespace Server.Mobiles } } - if (name != null && list.Count < 250) + if (name != null && list.Count < BuyListLimit) { list.Add(new BuyItemState(name, cont.Serial, item.Serial, price, item.Amount, item.ItemID, item.Hue)); opls.Enqueue(item.PropertyList); } } - // one (not all) of the packets uses a byte to describe number of items in the list. Osi = dumb. - // if (list.Count > 255) - // Console.WriteLine( "Vendor Warning: Vendor {0} has more than 255 buy items, may cause client errors!", this ); - if (list.Count <= 0) { return; @@ -988,17 +1019,9 @@ namespace Server.Mobiles public virtual void SendPacksTo(Mobile from) { - var pack = FindItemOnLayer(Layer.ShopBuy); + from.NetState.SendEquipUpdate(BuyPack); - if (pack == null) - { - pack = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; - AddItem(pack); - } - - from.NetState.SendEquipUpdate(pack); - - pack = FindItemOnLayer(Layer.ShopSell); + var pack = FindItemOnLayer(Layer.ShopSell); if (pack != null) { @@ -1306,6 +1329,20 @@ namespace Server.Mobiles { LoadSBInfo(); + if (FindItemOnLayer(Layer.ShopBuy) is not VendorBuybackPack) + { + // Deleting during deserialization is unsafe; replace on the first tick. + Timer.StartTimer( + () => + { + if (!Deleted) + { + _ = BuyPack; + } + } + ); + } + if (IsParagon) { IsParagon = false; diff --git a/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs b/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs new file mode 100644 index 000000000..d72bec895 --- /dev/null +++ b/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs @@ -0,0 +1,56 @@ +using ModernUO.Serialization; + +namespace Server.Items; + +/// +/// Holds items players sold to an NPC vendor until the next restock. The contents are never saved +/// and their removal reaches only the vendor and openers, like any private container. +/// +[SerializationGenerator(0)] +public partial class VendorBuybackPack : Backpack +{ + [Constructible] + public VendorBuybackPack() + { + Layer = Layer.ShopBuy; + Movable = false; + Visible = false; + } + + public override bool SkipsChildSerialization => true; + + // DropItem appends, so Items[0] is always the oldest entry. + public void AddBuyback(Item item, int capacity) + { + if (capacity <= 0) + { + item.Delete(); + return; + } + + while (Items.Count >= capacity) + { + var oldest = Items[0]; + oldest.Delete(); + + // Delete() is a no-op on an already-deleted item, which would spin this loop. + if (Items.Count > 0 && Items[0] == oldest) + { + Items.RemoveAt(0); + } + } + + DropItem(item); + } + + public void Purge() + { + for (var i = Items.Count - 1; i >= 0; --i) + { + if (i < Items.Count) + { + Items[i].Delete(); + } + } + } +}