From b4b67bccfdd3e94f07fc99ec99a0c90138b7bf5e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Thu, 30 Apr 2026 21:13:17 -0400 Subject: [PATCH] NEO-32: add implementation plan for cooldown snapshot sync --- docs/plans/NEO-32-implementation-plan.md | 103 +++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/plans/NEO-32-implementation-plan.md diff --git a/docs/plans/NEO-32-implementation-plan.md b/docs/plans/NEO-32-implementation-plan.md new file mode 100644 index 0000000..2aa1f93 --- /dev/null +++ b/docs/plans/NEO-32-implementation-plan.md @@ -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 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:** 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.