neon-sprawl/docs/plans/NEO-32-implementation-plan.md

104 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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