using System.Collections.Concurrent; namespace NeonSprawl.Server.Game.AbilityInput; /// Thread-safe in-memory cooldown map (NEO-32); not persisted across process restarts. public sealed class InMemoryPlayerAbilityCooldownStore : IPlayerAbilityCooldownStore { private readonly ConcurrentDictionary> ends = new(StringComparer.Ordinal); public bool IsOnCooldown(string playerId, int slotIndex, DateTimeOffset now) { return TryGetActiveCooldownEnd(playerId, slotIndex, now, out _); } public void StartCooldown(string playerId, int slotIndex, DateTimeOffset now, TimeSpan duration) { var perSlot = ends.GetOrAdd(playerId, _ => new ConcurrentDictionary()); perSlot[slotIndex] = now + duration; } public bool TryGetCooldownEndUtc(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc) { return TryGetActiveCooldownEnd(playerId, slotIndex, now, out endUtc); } /// Shared read path: returns true with when cooldown is active; removes slot entry when >= stored end. private static bool TryGetActiveCooldownEnd( ConcurrentDictionary> ends, string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc) { endUtc = default; if (!ends.TryGetValue(playerId, out var perSlot)) { return false; } if (!perSlot.TryGetValue(slotIndex, out var end)) { return false; } if (now >= end) { perSlot.TryRemove(slotIndex, out _); return false; } endUtc = end; return true; } private bool TryGetActiveCooldownEnd(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc) { return TryGetActiveCooldownEnd(ends, playerId, slotIndex, now, out endUtc); } }