# 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`.
