From 7d64ed2adc6c0809acae990e076958c9be96f72c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Thu, 30 Apr 2026 21:58:40 -0400 Subject: [PATCH] NEO-32: prune expired cooldown entries on TryGetCooldownEndUtc --- .../Get cooldown snapshot.bru | 4 ++ docs/reviews/2026-04-30-NEO-32.md | 2 +- ...InMemoryPlayerAbilityCooldownStoreTests.cs | 41 +++++++++++++++++++ .../Game/AbilityInput/CooldownSnapshotApi.cs | 2 +- .../IPlayerAbilityCooldownStore.cs | 4 +- .../InMemoryPlayerAbilityCooldownStore.cs | 40 +++++++++++------- 6 files changed, 75 insertions(+), 18 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/AbilityInput/InMemoryPlayerAbilityCooldownStoreTests.cs diff --git a/bruno/neon-sprawl-server/cooldown-snapshot/Get cooldown snapshot.bru b/bruno/neon-sprawl-server/cooldown-snapshot/Get cooldown snapshot.bru index 769b226..f2f3974 100644 --- a/bruno/neon-sprawl-server/cooldown-snapshot/Get cooldown snapshot.bru +++ b/bruno/neon-sprawl-server/cooldown-snapshot/Get cooldown snapshot.bru @@ -4,6 +4,10 @@ meta { seq: 1 } +docs { + NEO-32: expired slot entries are pruned when building the snapshot (server-side store hygiene). +} + get { url: {{baseUrl}}/game/players/dev-local-1/cooldown-snapshot body: none diff --git a/docs/reviews/2026-04-30-NEO-32.md b/docs/reviews/2026-04-30-NEO-32.md index 5e82ffc..dc8b82b 100644 --- a/docs/reviews/2026-04-30-NEO-32.md +++ b/docs/reviews/2026-04-30-NEO-32.md @@ -35,7 +35,7 @@ The branch delivers the planned NEO-32 slice: authoritative in-memory per-player 1. **C# AAA layout on new integration tests** — `CooldownSnapshotApiTests.GetCooldownSnapshot_ShouldExposeSlotEnd_WhenCastAccepted_ThenClear_WhenAdvancedPastDuration` and the two new cooldown tests in `AbilityCastApiTests` bundle HTTP calls and `FakeClock.Advance` inside a single `// Act` block. Per [csharp-style](.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), split so **Act** is only the invocations under assertion (or use clearly separated Act steps per phase) and keep clock advances in Arrange or interleaved with labeled sections. Low priority but keeps CI lint/review consistency for future edits. -2. **Stale cooldown entries in the in-memory store** — `TryGetCooldownEndUtc` does not prune expired ends; `BuildSnapshot` omits them from JSON, so wire behavior is correct. Expired rows linger until `IsOnCooldown` runs (e.g. next cast to that slot). For a long-lived prototype process this is negligible; if you ever extend GET-heavy paths, consider pruning in `TryGetCooldownEndUtc` or snapshot build for symmetry with `IsOnCooldown`. +2. ~~**Stale cooldown entries in the in-memory store** — `TryGetCooldownEndUtc` does not prune expired ends; `BuildSnapshot` omits them from JSON, so wire behavior is correct. Expired rows linger until `IsOnCooldown` runs (e.g. next cast to that slot). For a long-lived prototype process this is negligible; if you ever extend GET-heavy paths, consider pruning in `TryGetCooldownEndUtc` or snapshot build for symmetry with `IsOnCooldown`.~~ **Done.** `TryGetCooldownEndUtc` now takes `DateTimeOffset now` and shares pruning with `IsOnCooldown`; `InMemoryPlayerAbilityCooldownStoreTests` covers prune-on-read. ## Nits diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/InMemoryPlayerAbilityCooldownStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/InMemoryPlayerAbilityCooldownStoreTests.cs new file mode 100644 index 0000000..800eb93 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/InMemoryPlayerAbilityCooldownStoreTests.cs @@ -0,0 +1,41 @@ +using NeonSprawl.Server.Game.AbilityInput; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.AbilityInput; + +public sealed class InMemoryPlayerAbilityCooldownStoreTests +{ + [Fact] + public void TryGetCooldownEndUtc_ShouldReturnEnd_WhenNowBeforeCooldownEnd() + { + // Arrange + var store = new InMemoryPlayerAbilityCooldownStore(); + var t0 = new DateTimeOffset(2026, 4, 30, 12, 0, 0, TimeSpan.Zero); + store.StartCooldown("p1", 2, t0, TimeSpan.FromSeconds(3)); + + // Act + var has = store.TryGetCooldownEndUtc("p1", 2, t0 + TimeSpan.FromSeconds(1), out var endUtc); + + // Assert + Assert.True(has); + Assert.Equal(t0 + TimeSpan.FromSeconds(3), endUtc); + } + + [Fact] + public void TryGetCooldownEndUtc_ShouldPruneAndReturnFalse_WhenNowAtOrAfterEnd() + { + // Arrange + var store = new InMemoryPlayerAbilityCooldownStore(); + var t0 = new DateTimeOffset(2026, 4, 30, 12, 0, 0, TimeSpan.Zero); + store.StartCooldown("p1", 0, t0, TimeSpan.FromSeconds(3)); + var after = t0 + TimeSpan.FromSeconds(5); + + // Act + var pruned = store.TryGetCooldownEndUtc("p1", 0, after, out _); + var second = store.TryGetCooldownEndUtc("p1", 0, after, out _); + + // Assert + Assert.False(pruned); + Assert.False(second); + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/CooldownSnapshotApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/CooldownSnapshotApi.cs index 6318571..76f8948 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/CooldownSnapshotApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/CooldownSnapshotApi.cs @@ -32,7 +32,7 @@ public static class CooldownSnapshotApi for (var i = 0; i < HotbarLoadoutResponse.SlotCountV1; i++) { DateTimeOffset? end = null; - if (cooldowns.TryGetCooldownEndUtc(playerId, i, out var endUtc) && endUtc > now) + if (cooldowns.TryGetCooldownEndUtc(playerId, i, now, out var endUtc)) { end = endUtc; } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs b/server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs index f5c5aaa..408b0cc 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs @@ -9,6 +9,6 @@ public interface IPlayerAbilityCooldownStore /// Sets cooldown end for a slot to + . void StartCooldown(string playerId, int slotIndex, DateTimeOffset now, TimeSpan duration); - /// Gets stored end instant for the slot when an entry exists (may be in the past). - bool TryGetCooldownEndUtc(string playerId, int slotIndex, out DateTimeOffset endUtc); + /// Gets active cooldown end when is strictly before that end; removes expired entries (same hygiene as ). + bool TryGetCooldownEndUtc(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc); } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs b/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs index bfdc9ca..8d1ad06 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs @@ -9,6 +9,29 @@ public sealed class InMemoryPlayerAbilityCooldownStore : IPlayerAbilityCooldownS 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; @@ -25,23 +48,12 @@ public sealed class InMemoryPlayerAbilityCooldownStore : IPlayerAbilityCooldownS return false; } + endUtc = end; return true; } - public void StartCooldown(string playerId, int slotIndex, DateTimeOffset now, TimeSpan duration) + private bool TryGetActiveCooldownEnd(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc) { - var perSlot = ends.GetOrAdd(playerId, _ => new ConcurrentDictionary()); - perSlot[slotIndex] = now + duration; - } - - public bool TryGetCooldownEndUtc(string playerId, int slotIndex, out DateTimeOffset endUtc) - { - endUtc = default; - if (!ends.TryGetValue(playerId, out var perSlot)) - { - return false; - } - - return perSlot.TryGetValue(slotIndex, out endUtc); + return TryGetActiveCooldownEnd(ends, playerId, slotIndex, now, out endUtc); } }