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(); } } }