diff --git a/Projects/UOContent/Items/Weapons/Ranged/AmmoRecovery.cs b/Projects/UOContent/Items/Weapons/Ranged/AmmoRecovery.cs new file mode 100644 index 000000000..680ef9bdf --- /dev/null +++ b/Projects/UOContent/Items/Weapons/Ranged/AmmoRecovery.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using Server.Mobiles; + +namespace Server.Items +{ + /// + /// SE-era archery ammo recovery. When a player misses a shot there is a chance the spent ammo + /// can be gathered back up afterwards. The banked ammo is held off of + /// (in this side table) so the vast majority of players who never miss with a bow carry no extra + /// state. A single repeating timer per tracked player gathers the ammo, but only once the archer + /// has disengaged: out of warmode and not running (standing still or walking is fine). + /// + public static class AmmoRecovery + { + // How often we re-check whether the archer has settled enough to gather their ammo. + private static readonly TimeSpan RecoveryInterval = TimeSpan.FromSeconds(10); + + // A running step is only "current" if one landed this recently. Comfortably above the running + // step cadence (run-foot ~200ms, run-mount ~100ms) so an actively running archer always reads as + // running, while a stopped runner ages out within ~half a second. + private const long RunningWindowMillis = 500; + + // Only players who have banked recoverable ammo appear here, so this stays tiny. + private static readonly Dictionary _states = new(); + + private sealed class RecoveryState + { + public readonly Dictionary Ammo = new(); + public RecoveryTimer Timer; + } + + private sealed class RecoveryTimer : Timer + { + private readonly PlayerMobile _player; + + // delay == interval, count 0 => first fire after the interval, then repeats until Stop(). + public RecoveryTimer(PlayerMobile player) : base(RecoveryInterval, 0) => _player = player; + + protected override void OnTick() => OnRecoveryTick(_player); + } + + // Called from BaseRanged.OnMiss when a player misses and the shot is recoverable. + public static void Bank(PlayerMobile player, Type ammoType) + { + if (!Core.SE || ammoType == null) + { + return; + } + + if (!_states.TryGetValue(player, out var state)) + { + state = new RecoveryState(); + state.Timer = new RecoveryTimer(player); + state.Timer.Start(); + _states[player] = state; + } + + state.Ammo.TryGetValue(ammoType, out var count); + state.Ammo[ammoType] = count + 1; + } + + // Stops tracking a player and cancels their pending recovery (e.g. on delete). + public static void Forget(PlayerMobile player) + { + if (_states.Remove(player, out var state)) + { + state.Timer?.Stop(); + } + } + + private static void OnRecoveryTick(PlayerMobile player) + { + if (!_states.TryGetValue(player, out var state)) + { + return; + } + + if (player.Deleted || state.Ammo.Count == 0) + { + Forget(player); + return; + } + + // Gather the scattered ammo only once the archer has disengaged: out of warmode and not running. + if (!player.Alive || player.Warmode || IsRunning(player)) + { + return; // Not settled yet; try again on the next tick. + } + + Recover(player, state); + Forget(player); + } + + // The Direction.Running bit is left set after the player stops, so it alone can't tell us whether + // the archer is still running. We pair it with movement recency: a running step is only "current" + // if one landed within roughly two run-step intervals. A stopped runner ages out of that window, + // and a walker never sets the bit at all -- both are treated as "not running", which is what we want. + private static bool IsRunning(Mobile m) => + (m.Direction & Direction.Running) != 0 && + Core.TickCount - m.LastMoveTime < RunningWindowMillis; + + private static void Recover(PlayerMobile player, RecoveryState state) + { + foreach (var (type, amount) in state.Ammo) + { + if (amount <= 0) + { + continue; + } + + Item ammo = null; + + try + { + ammo = type.CreateInstance(); + } + catch + { + // ignored + } + + if (ammo == null) + { + continue; + } + + ammo.Amount = amount; + + var name = ammo.Name; + if (name == null) + { + var label = ammo.LabelNumber; + + // Arrow (1023903/1023904) and bolt (1027163/1027164) name clilocs keep their plural form + // two entries above the singular, so bump the label when recovering more than one. + if (ammo.Amount != 1 && label is 1023903 or 1023904 or 1027163 or 1027164) + { + label += 2; + } + + name = $"#{label}"; + } + + player.PlaceInBackpack(ammo); + player.SendLocalizedMessage(1073504, $"{ammo.Amount}\t{name}"); // You recover ~1_NUM~ ~2_AMMO~. + } + + state.Ammo.Clear(); + } + } +} diff --git a/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs b/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs index bd9264603..4fd786809 100644 --- a/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs +++ b/Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using ModernUO.Serialization; using Server.Mobiles; using Server.Network; @@ -21,8 +20,6 @@ namespace Server.Items [SerializedCommandProperty(AccessLevel.GameMaster)] private int _velocity; - private TimerExecutionToken _recoveryTimerToken; - public BaseRanged(int itemID) : base(itemID) { } @@ -144,29 +141,8 @@ namespace Server.Items { if (attacker is PlayerMobile pm) { - var ammo = AmmoType; - - if (ammo != null) - { - pm.RecoverableAmmo ??= new Dictionary(); - pm.RecoverableAmmo.TryGetValue(ammo, out var result); - pm.RecoverableAmmo[ammo] = result + 1; - } - - if (!pm.Warmode) - { - if (!_recoveryTimerToken.Running) - { - Timer.StartTimer(TimeSpan.FromSeconds(10), - () => - { - _recoveryTimerToken.Cancel(); - pm.RecoverAmmo(); - }, - out _recoveryTimerToken - ); - } - } + // Bank the spent ammo; it is gathered back up once the archer disengages. + AmmoRecovery.Bank(pm, AmmoType); } } else diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 86f8e4848..af85a195a 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1623,16 +1623,9 @@ namespace Server.Mobiles ReceivedHonorContext?.OnTargetDamaged(from, amount); - if (!willKill) + if (!willKill && CanBeDistracted && ControlOrder == OrderType.Follow) { - if (CanBeDistracted && ControlOrder == OrderType.Follow) - { - CheckDistracted(from); - } - } - else if (from is PlayerMobile mobile) - { - Timer.StartTimer(TimeSpan.FromSeconds(10), mobile.RecoverAmmo); + CheckDistracted(from); } base.OnDamage(amount, from, willKill); diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 1eb9f45c5..0a81bb846 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -536,8 +536,6 @@ namespace Server.Mobiles set => SetFlag(PlayerFlag.RefuseTrades, value); } - public Dictionary RecoverableAmmo { get; set; } - [CommandProperty(AccessLevel.GameMaster)] public DateTime AcceleratedStart { get; set; } @@ -2381,11 +2379,6 @@ namespace Server.Mobiles ReceivedHonorContext?.OnTargetDamaged(from, amount); SentHonorContext?.OnSourceDamaged(from, amount); - if (willKill && from is PlayerMobile mobile) - { - Timer.StartTimer(TimeSpan.FromSeconds(10), mobile.RecoverAmmo); - } - base.OnDamage(amount, from, willKill); } @@ -2406,14 +2399,6 @@ namespace Server.Mobiles } } - public override void OnWarmodeChanged() - { - if (!Warmode) - { - Timer.StartTimer(TimeSpan.FromSeconds(10), RecoverAmmo); - } - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool FindItems_Callback(Item item) => !item.Deleted && (item.LootType == LootType.Blessed || item.Insured) && Backpack != item.Parent; @@ -2465,8 +2450,6 @@ namespace Server.Mobiles ReceivedHonorContext?.OnTargetKilled(); SentHonorContext?.OnSourceKilled(); - RecoverAmmo(); - return base.OnBeforeDeath(); } @@ -3727,50 +3710,6 @@ namespace Server.Mobiles AutoStabled = null; } - public void RecoverAmmo() - { - if (!Core.SE || !Alive || RecoverableAmmo == null) - { - return; - } - - foreach (var kvp in RecoverableAmmo) - { - if (kvp.Value > 0) - { - Item ammo = null; - - try - { - ammo = kvp.Key.CreateInstance(); - } - catch - { - // ignored - } - - if (ammo == null) - { - continue; - } - - ammo.Amount = kvp.Value; - - var name = ammo.Name ?? ammo switch - { - Arrow _ => $"arrow{(ammo.Amount != 1 ? "s" : "")}", - Bolt _ => $"bolt{(ammo.Amount != 1 ? "s" : "")}", - _ => $"#{ammo.LabelNumber}" - }; - - PlaceInBackpack(ammo); - SendLocalizedMessage(1073504, $"{ammo.Amount}\t{name}"); // You recover ~1_NUM~ ~2_AMMO~. - } - } - - RecoverableAmmo.Clear(); - } - private static int GetInsuranceCost(Item item) => 600; private void ToggleItemInsurance() @@ -4172,6 +4111,8 @@ namespace Server.Mobiles ReceivedHonorContext?.Cancel(); SentHonorContext?.Cancel(); + AmmoRecovery.Forget(this); + if (Stabled != null) { foreach (var stabled in Stabled)