8.5 KiB
NEO-32 implementation plan — CooldownSnapshot sync + slot presentation
Story reference
- Key: NEO-32
- Title: E1M4-04: CooldownSnapshot sync + slot presentation
- Linear: https://linear.app/neon-sprawl/issue/NEO-32/e1m4-04-cooldownsnapshot-sync-slot-presentation
- Project: E1.M4 — AbilityInputScaffold · Label: E1.M4
Kickoff clarifications
Asked (transport): Initial multiple-choice offered “dedicated GET”, “embed in HotbarLoadoutResponse”, or “hybrid”. User asked for a recommendation; agent recommended dedicated GET (smaller poll payload, keeps hotbar loadout schema focused on bindings). User then confirmed dedicated GET for the plan.
Asked (duration): Global constant vs per-ability registry. User selected one global prototype cooldown duration for all known abilities (server-side constant until E5.M1 catalog).
Linear note: Issue still lists blocked by NEO-28; repo work for NEO-28 is already described as landed in decomposition/docs. Clear the Linear blocker when you consider that dependency satisfied.
Goal, scope, and out-of-scope
Goal: Add a CooldownSnapshot wire contract and a server-backed update path so the hotbar reflects cooldown truth; gate repeat casts while a slot is cooling with synchronized server deny behavior and client presentation.
In scope (from Linear):
- Define
CooldownSnapshotprototype fields and a GET route that returns authoritative state. - Update hotbar slot presentation (ready vs cooling; remaining time or clear non-animated indicator).
- Server: reject casts for a slot on active cooldown with a stable reasonCode; client: optional local guard so spam does not flood HTTP (must still align with server deny).
Out of scope (from Linear):
- Final polished animation treatments for cooldown visuals.
Acceptance criteria checklist
- After a successful cast, the affected slot shows a cooling state until the server snapshot says the cooldown has completed.
- The client cannot repeatedly fire a cooling ability without server deny (and/or aligned guard UX) —
reasonCodefor cooldown documented and tested. - After reconnect, cooldown UI matches server state by re-fetching the snapshot (same process lifetime as today’s prototype stores).
Technical approach
-
Wire contract (
CooldownSnapshotv1)- New JSON DTOs (C#) with
schemaVersion,playerId,serverTimeUtc(ISO-8601) for client remaining-time math, and a fixed 8-slot list aligned withHotbarLoadoutResponse.SlotCountV1. - Per slot:
slotIndex, optionalabilityIdecho (or omit if redundant — prefer mirror loadout binding from client cache only; snapshot focuses on cooldownEndsAtUtc nullable). WhencooldownEndsAtUtcis null or ≤serverTimeUtc, slot is ready for cast from a cooldown perspective.
- New JSON DTOs (C#) with
-
Server authority
IPlayerAbilityCooldownStore(or equivalent name): in-memory per-player mapslotIndex → cooldown end (UTC). Registered as singleton in DI (prototype: no Postgres; resets on process restart, consistent with ephemeral combat-adjacent state).- Global constant
TimeSpan(e.g. 3s — pick one value in implementation) applied on accepted cast after all existing NEO-28 gates. AbilityCastApi: before accept, if slot has active cooldown → JSON deny with newreasonCode(e.g.on_cooldown). On accept → record cooldown end for that slot.GET /game/players/{id}/cooldown-snapshot: 404 if player unknown toIPositionStateStore(same pattern as other player routes); otherwise return computed snapshot from the cooldown store +UtcNow.
-
Client
- New
CooldownSnapshotClient(parallel toHotbarLoadoutClient): GET on boot after loadout sync (or chained on loadout success), plus a Timer or low-rate poll (e.g. 200–500ms while any slot cooling, idle when all ready — implementation detail) to refresh remaining UI. - New
CooldownState(or extendHotbarStateminimally): hold per-slotcooldownEndsAt/ “is cooling” derived from last snapshot + local clock vsserverTimeUtcoffset. - Presentation: minimal HUD per slot (e.g. extend existing labels or a single debug row listing cooling slots + seconds remaining — avoid “final animation” scope).
main.gd: beforerequest_cast, if local cooldown mirror says slot still cooling, skip POST and optionally mirror deny UX (must not contradict server; server remains source of truth). Oncast_result_receivedaccepted, optionally trigger immediate snapshot GET to avoid poll latency.
- New
-
Bruno
- Request folder for GET cooldown snapshot + optional flow after cast POST.
-
Manual QA
- Add
docs/manual-qa/NEO-32.mdduring implementation (cast → cooling → expiry; spam during cooldown; reconnect + GET).
- Add
Files to add
| Path | Purpose |
|---|---|
server/NeonSprawl.Server/Game/AbilityInput/CooldownSnapshotDtos.cs |
CooldownSnapshotResponse + per-slot JSON records (schemaVersion, serverTimeUtc, slots). |
server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs |
Interface: try get / set cooldown end per player+slot; clear or implicit expiry by time comparison. |
server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs |
Singleton in-memory implementation. |
server/NeonSprawl.Server/Game/AbilityInput/AbilityCooldownServiceCollectionExtensions.cs |
AddAbilityCooldownStore() registration (in-memory only for prototype). |
server/NeonSprawl.Server/Game/AbilityInput/CooldownSnapshotApi.cs |
MapCooldownSnapshotApi — GET route. |
server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs |
HTTP tests for GET shape, 404, and snapshot after cast. |
client/scripts/cooldown_snapshot_client.gd |
HTTP GET + signal for parsed snapshot. |
client/scripts/cooldown_state.gd |
Local mirror + helpers for “slot cooling” / remaining seconds. |
client/test/cooldown_snapshot_client_test.gd |
GdUnit: parse / apply snapshot behavior (mock HTTP if pattern exists in repo). |
bruno/neon-sprawl-server/... (GET cooldown-snapshot .bru) |
Manual API verification. |
docs/manual-qa/NEO-32.md |
Checklist: cooling display, deny on spam, reconnect hydration. |
Files to modify
| Path | Rationale |
|---|---|
server/NeonSprawl.Server/Program.cs |
Register cooldown store DI; MapCooldownSnapshotApi(). |
server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs |
Inject cooldown store; on_cooldown deny; start cooldown on accept. |
server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs |
Assert cooldown deny + accept commits cooldown (via follow-up GET or store-visible behavior through API). |
client/scripts/main.gd |
Instantiate client/state; boot + poll wiring; gate _request_hotbar_cast_slot when cooling; connect cast result → refresh snapshot. |
docs/decomposition/modules/E1_M4_AbilityInputScaffold.md |
Document CooldownSnapshot route + wire fields (module “current state”). |
docs/decomposition/modules/documentation_and_implementation_alignment.md |
E1.M4 row: NEO-32 landed note when implemented. |
Tests
| Test file | Coverage |
|---|---|
CooldownSnapshotApiTests.cs |
GET returns v1 schema; unknown player 404; known player empty snapshot; after accepted cast, slot shows future cooldownEndsAtUtc; after wall-clock / fake time if we use injectable clock — if no injectable clock, assert ordering with short duration + spin wait or expose test hook only if justified. Prefer TimeProvider / injectable time abstraction on store if tests need deterministic expiry without flakiness. |
AbilityCastApiTests.cs |
Second POST to same slot while cooling → accepted: false, reasonCode on_cooldown (exact string documented in plan + API const). |
client/test/cooldown_snapshot_client_test.gd |
JSON → state: ready vs cooling; serverTimeUtc offset handling for remaining display helper. |
If time injection is too large for this slice: document in Open questions and use integration-style test with real short duration (e.g. 100ms) + generous timeout — only as fallback.
Open questions / risks
- Flaky timing in CI: Mitigate with injectable
TimeProvideron the cooldown store or very short real durations; decide during implementation. - Linear blocked-by NEO-28: Remove relation in Linear when you accept NEO-28 as done for scheduling.
- None for wire shape or duration model — locked in kickoff.