NEO-32: prune expired cooldown entries on TryGetCooldownEndUtc

pull/59/head
VinPropane 2026-04-30 21:58:40 -04:00
parent 7ddfb53219
commit 7d64ed2adc
6 changed files with 75 additions and 18 deletions

View File

@ -4,6 +4,10 @@ meta {
seq: 1 seq: 1
} }
docs {
NEO-32: expired slot entries are pruned when building the snapshot (server-side store hygiene).
}
get { get {
url: {{baseUrl}}/game/players/dev-local-1/cooldown-snapshot url: {{baseUrl}}/game/players/dev-local-1/cooldown-snapshot
body: none body: none

View File

@ -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. 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 ## Nits

View File

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

View File

@ -32,7 +32,7 @@ public static class CooldownSnapshotApi
for (var i = 0; i < HotbarLoadoutResponse.SlotCountV1; i++) for (var i = 0; i < HotbarLoadoutResponse.SlotCountV1; i++)
{ {
DateTimeOffset? end = null; DateTimeOffset? end = null;
if (cooldowns.TryGetCooldownEndUtc(playerId, i, out var endUtc) && endUtc > now) if (cooldowns.TryGetCooldownEndUtc(playerId, i, now, out var endUtc))
{ {
end = endUtc; end = endUtc;
} }

View File

@ -9,6 +9,6 @@ public interface IPlayerAbilityCooldownStore
/// <summary>Sets cooldown end for a slot to <paramref name="now"/> + <paramref name="duration"/>.</summary> /// <summary>Sets cooldown end for a slot to <paramref name="now"/> + <paramref name="duration"/>.</summary>
void StartCooldown(string playerId, int slotIndex, DateTimeOffset now, TimeSpan duration); void StartCooldown(string playerId, int slotIndex, DateTimeOffset now, TimeSpan duration);
/// <summary>Gets stored end instant for the slot when an entry exists (may be in the past).</summary> /// <summary>Gets active cooldown end when <paramref name="now"/> is strictly before that end; removes expired entries (same hygiene as <see cref="IsOnCooldown"/>).</summary>
bool TryGetCooldownEndUtc(string playerId, int slotIndex, out DateTimeOffset endUtc); bool TryGetCooldownEndUtc(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc);
} }

View File

@ -9,6 +9,29 @@ public sealed class InMemoryPlayerAbilityCooldownStore : IPlayerAbilityCooldownS
public bool IsOnCooldown(string playerId, int slotIndex, DateTimeOffset now) 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<int, DateTimeOffset>());
perSlot[slotIndex] = now + duration;
}
public bool TryGetCooldownEndUtc(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc)
{
return TryGetActiveCooldownEnd(playerId, slotIndex, now, out endUtc);
}
/// <summary>Shared read path: returns true with <paramref name="endUtc"/> when cooldown is active; removes slot entry when <paramref name="now"/> &gt;= stored end.</summary>
private static bool TryGetActiveCooldownEnd(
ConcurrentDictionary<string, ConcurrentDictionary<int, DateTimeOffset>> ends,
string playerId,
int slotIndex,
DateTimeOffset now,
out DateTimeOffset endUtc)
{
endUtc = default;
if (!ends.TryGetValue(playerId, out var perSlot)) if (!ends.TryGetValue(playerId, out var perSlot))
{ {
return false; return false;
@ -25,23 +48,12 @@ public sealed class InMemoryPlayerAbilityCooldownStore : IPlayerAbilityCooldownS
return false; return false;
} }
endUtc = end;
return true; 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<int, DateTimeOffset>()); return TryGetActiveCooldownEnd(ends, playerId, slotIndex, now, out endUtc);
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);
} }
} }