NEO-32: add implementation plan for cooldown snapshot sync

pull/59/head
VinPropane 2026-04-30 21:13:17 -04:00
parent 0790a3398d
commit b4b67bccfd
1 changed files with 103 additions and 0 deletions

View File

@ -0,0 +1,103 @@
# 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
- [ ] 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) — `reasonCode` for cooldown documented and tested.
- [ ] After reconnect, cooldown UI matches server state by **re-fetching** the snapshot (same process lifetime as todays 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. 200500ms 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:** Mitigate with injectable `TimeProvider` on 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.