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