# 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 `CooldownSnapshot` prototype 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 - [x] After a successful cast, the affected slot shows a cooling state until the server snapshot says the cooldown has completed. - [x] The client cannot repeatedly fire a cooling ability without server deny (and/or aligned guard UX) — `reasonCode` for cooldown documented and tested. - [x] After reconnect, cooldown UI matches server state by **re-fetching** the snapshot (same process lifetime as today’s prototype stores). ## Technical approach 1. **Wire contract (`CooldownSnapshot` v1)** - New JSON DTOs (C#) with `schemaVersion`, `playerId`, **`serverTimeUtc`** (ISO-8601) for client remaining-time math, and a fixed **8-slot** list aligned with `HotbarLoadoutResponse.SlotCountV1`. - Per slot: `slotIndex`, optional `abilityId` echo (or omit if redundant — prefer mirror loadout binding from client cache only; snapshot focuses on **cooldownEndsAtUtc** nullable). When `cooldownEndsAtUtc` is null or ≤ `serverTimeUtc`, slot is **ready** for cast from a cooldown perspective. 2. **Server authority** - **`IPlayerAbilityCooldownStore`** (or equivalent name): in-memory per-player map `slotIndex → 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 new **`reasonCode`** (e.g. `on_cooldown`). On accept → record cooldown end for that slot. - **`GET /game/players/{id}/cooldown-snapshot`:** 404 if player unknown to `IPositionStateStore` (same pattern as other player routes); otherwise return computed snapshot from the cooldown store + `UtcNow`. 3. **Client** - New **`CooldownSnapshotClient`** (parallel to `HotbarLoadoutClient`): 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 extend `HotbarState` minimally): hold per-slot `cooldownEndsAt` / “is cooling” derived from last snapshot + local clock vs `serverTimeUtc` offset. - **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`:** before `request_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). On `cast_result_received` accepted, optionally trigger immediate snapshot GET to avoid poll latency. 4. **Bruno** - Request folder for GET cooldown snapshot + optional flow after cast POST. 5. **Manual QA** - Add `docs/manual-qa/NEO-32.md` during implementation (cast → cooling → expiry; spam during cooldown; reconnect + GET). ## 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:** Resolved with **`FakeTimeProvider`** in `InMemoryWebApplicationFactory` + `TimeProvider` injection on cast and snapshot routes. - **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. ## Implementation reconciliation (shipped) - **Wire:** `CooldownSnapshotResponse` v1 + `GET /game/players/{id}/cooldown-snapshot`; cast deny **`on_cooldown`** (`AbilityCastApi.ReasonOnCooldown`); global duration `AbilityPrototypeCooldown.GlobalDuration` (3s). - **Client:** `cooldown_snapshot_client.gd`, `cooldown_state.gd`, `CooldownSlotsLabel` + poll timer + loadout-changed sync + cast-accept refresh in `main.gd`. - **Tests:** `CooldownSnapshotApiTests.cs`, cooldown cases in `AbilityCastApiTests.cs`, `client/test/cooldown_state_test.gd`. - **Bruno:** `bruno/neon-sprawl-server/cooldown-snapshot/Get cooldown snapshot.bru`.