NEO-32: prune expired cooldown entries on TryGetCooldownEndUtc
parent
7ddfb53219
commit
7d64ed2adc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@ public interface IPlayerAbilityCooldownStore
|
|||
/// <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);
|
||||
|
||||
/// <summary>Gets stored end instant for the slot when an entry exists (may be in the past).</summary>
|
||||
bool TryGetCooldownEndUtc(string playerId, int slotIndex, out DateTimeOffset endUtc);
|
||||
/// <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, DateTimeOffset now, out DateTimeOffset endUtc);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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"/> >= 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))
|
||||
{
|
||||
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<int, DateTimeOffset>());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue