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

169 lines
12 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-108 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-108 |
| **Title** | E5M3-08: GET encounter-progress + wire DTOs + Bruno |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-108/e5m3-08-get-encounter-progress-wire-dtos-bruno |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-08** |
| **Branch** | `NEO-108-get-encounter-progress-wire-dtos-bruno` |
| **Precursor** | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores · [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants · [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring · [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) complete event store (**landed on `main`**) |
| **Pattern** | [NEO-103](NEO-103-implementation-plan.md) — world/player GET + Bruno; [NEO-44](NEO-44-implementation-plan.md) — player snapshot GET + position gate; [NEO-106](NEO-106-implementation-plan.md) — defeat-all-three cast helpers |
| **Blocks** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — client encounter HUD poll/refresh |
| **Client counterpart** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — Godot labels poll this GET; **not in this story** |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **`rewardGrantSummary` source** | Event store vs reward table vs fallback? | **`IEncounterCompleteEventStore.GrantedItems`** on `completed` — commit-time snapshot (NEO-107). | **Adopted** — event store |
| **Encounter rows in GET** | All catalog encounters vs only non-inactive? | **All** from **`GetDefinitionsInIdOrder()`** with derived **`inactive` / `active` / `completed`**. | **Adopted** (backlog) |
| **Player gate** | 404 for unknown player? | **`IPositionStateStore.TryGetPosition`** — gig/skill progression precedent. | **Adopted** |
| **Optional fields** | `completedAt` / `rewardGrantSummary` when not complete? | **Omit** (or JSON `null`) when not **`completed`**; **`defeatedTargetIds`** = `[]` when inactive. | **Adopted** |
| **Integration tests** | Dedicated API tests vs cast-only? | **`EncounterProgressApiTests`** + Bruno three-NPC spine; optional GET assertions after existing cast tests deferred unless useful. | **Adopted** |
| **Manual QA doc** | `docs/manual-qa/NEO-108.md`? | **Skip** — server-only (NEO-103 precedent); Bruno + automated API tests. | **Adopted** |
## Goal, scope, and out-of-scope
**Goal:** Expose authoritative per-player encounter progress over HTTP so clients and Bruno can read server-owned activation, defeat subset, completion timestamp, and grant summary without inferring from combat responses.
**In scope (from Linear + [E5M3-08](E5M3-prototype-backlog.md#e5m3-08--get-gameplayersidencounter-progress--wire-dtos)):**
- **`GET /game/players/{id}/encounter-progress`** with versioned envelope (`schemaVersion` **1**, **`encounters`** array).
- Per-encounter row: **`encounterId`**, **`state`** (`inactive` | `active` | `completed`), **`defeatedTargetIds`**, optional **`completedAt`**, optional **`rewardGrantSummary`** (`itemId` + `quantity` grants).
- Projection from **`IEncounterDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, **`IEncounterCompleteEventStore`** (no second source of truth).
- **`EncounterProgressApi`** + wire DTOs in `Game/Encounters/`.
- Integration tests (AAA): unknown player 404; inactive default; partial progress after 12 defeats; completed once with grant summary.
- Bruno `bruno/neon-sprawl-server/encounter-progress/` — self-contained three-NPC defeat pre-request → GET **`completed`** once.
- `server/README.md` route section; E5.M3 module snapshot + alignment register when landed.
**Out of scope (from Linear + backlog):**
- Godot poll client — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**.
- Telemetry hook sites ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)).
- Postgres persistence, cast response DTO changes.
- `docs/manual-qa/NEO-108.md` (server-only per kickoff).
## Acceptance criteria checklist
- [x] GET reflects progress after each defeat without client-side inference.
- [x] Completed encounter shows **`completed`** state and grant summary.
- [x] Bruno smoke: defeat chain → GET shows **`completed`** once.
## Implementation reconciliation (shipped)
- **Route:** `GET /game/players/{id}/encounter-progress``EncounterProgressApi.BuildSnapshot` projects progress, completion, and event stores; 404 via `IPositionStateStore`.
- **DTOs:** `EncounterProgressListResponse` (schema v1); row `state` inactive/active/completed; `rewardGrantSummary` from `IEncounterCompleteEventStore.GrantedItems`; optional fields omitted when not completed.
- **Tests:** five AAA tests in `EncounterProgressApiTests` (404, inactive, 1-defeat active, 2-defeat active, completed + grant summary).
- **Bruno:** `bruno/neon-sprawl-server/encounter-progress/` — inactive GET + three-NPC defeat spine.
- **Docs:** `server/README.md`; E5.M3 module snapshot; alignment register; module dependency register; backlog E5M3-08 AC.
## Technical approach
1. **Route:** **`GET /game/players/{id}/encounter-progress`** — inject **`IPositionStateStore`**, **`IEncounterDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, **`IEncounterCompleteEventStore`**.
2. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`GigProgressionSnapshotApi`**).
3. **State derivation** (per catalog encounter id, normalized lowercase keys in stores):
| Condition | **`state`** |
|-----------|-------------|
| **`IEncounterCompletionStore.IsCompleted`** | **`completed`** |
| Progress snapshot **`Started`** | **`active`** |
| Otherwise | **`inactive`** |
4. **Row fields:**
- **`encounterId`**: catalog **`id`** (lowercase).
- **`defeatedTargetIds`**: from **`EncounterProgressSnapshot.DefeatedNpcInstanceIds`** when progress exists; else **`[]`** (sorted ordinal for stable JSON).
- **`completedAt`**: ISO-8601 UTC from **`IEncounterCompletionStore.TryGetCompletedAt`** when **`completed`**; omitted otherwise.
- **`rewardGrantSummary`**: when **`completed`**, map **`EncounterCompleteEvent.GrantedItems`** from **`IEncounterCompleteEventStore.TryGet`** → `{ itemId, quantity }[]`. If completion flag set but event missing (invariant violation), throw **`InvalidOperationException`** (fail-fast; same spirit as NEO-103 missing reward table). Omitted when not **`completed`**.
5. **Ordering:** Build rows from **`encounterRegistry.GetDefinitionsInIdOrder()`** (prototype: single **`prototype_combat_pocket`**).
6. **Implementation:** **`EncounterProgressApi`** + **`EncounterProgressListDtos.cs`** with internal **`BuildSnapshot(playerId, …)`** testable like **`GigProgressionSnapshotApi.BuildSnapshot`**. Register **`app.MapEncounterProgressApi()`** in **`Program.cs`** after **`MapGigProgressionSnapshotApi()`**.
7. **Bruno:** `bruno/neon-sprawl-server/encounter-progress/`
- **`folder.bru`**
- **`Get encounter progress inactive.bru`** — GET before engagement → **`inactive`**, empty defeats.
- **`Get encounter progress after defeat spine.bru`** — pre-request: **`resetPrototypeCombatTargets`** (shared helper pattern from gig-progression), move, bind slot **3** pulse, defeat melee → ranged → elite via cast; GET asserts **`active`** with growing **`defeatedTargetIds`** after 12 defeats (optional intermediate GETs in pre-request) and final **`completed`** with **`rewardGrantSummary`** matching **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**; second GET still **`completed`** (idempotent read).
8. **Docs:** Update **`server/README.md`** (replace NEO-108 deferral under encounter progress section). Update [E5_M3](E5_M3_EncounterAndRewardTables.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md), [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) when landed.
### Expected prototype row progression (frozen)
| After | **`state`** | **`defeatedTargetIds`** | **`rewardGrantSummary`** |
|-------|-------------|-------------------------|--------------------------|
| No engagement | `inactive` | `[]` | omitted |
| First damaging cast vs required NPC | `active` | 03 ids | omitted |
| 12 lethal defeats | `active` | 12 ids | omitted |
| Third lethal defeat + grants | `completed` | 3 ids | scrap ×10, token ×1 |
| Subsequent GET / casts | `completed` | 3 ids | unchanged summary |
### Prototype completion read flow
```mermaid
sequenceDiagram
participant Client
participant API as EncounterProgressApi
participant Pos as IPositionStateStore
participant Reg as IEncounterDefinitionRegistry
participant Prog as IEncounterProgressStore
participant Comp as IEncounterCompletionStore
participant Ev as IEncounterCompleteEventStore
Client->>API: GET /game/players/{id}/encounter-progress
API->>Pos: TryGetPosition
alt unknown player
API-->>Client: 404
end
API->>Reg: GetDefinitionsInIdOrder
loop each encounter
API->>Comp: IsCompleted
API->>Prog: TryGetProgress
API->>Ev: TryGet when completed
end
API-->>Client: schemaVersion 1 + encounters[]
```
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs` | `Map*` extension for GET route; **`BuildSnapshot`** projection. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs` | Versioned response + per-encounter row + grant summary DTOs. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs` | AAA HTTP: 404, inactive, partial active, completed + grant summary. |
| `bruno/neon-sprawl-server/encounter-progress/folder.bru` | Bruno folder metadata. |
| `bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru` | GET default **`inactive`**. |
| `bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru` | Three-NPC defeat pre-request → **`completed`** once. |
| `docs/plans/NEO-108-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register **`MapEncounterProgressApi()`** after gig progression routes. |
| `server/README.md` | Document GET route, curl, store projection, Bruno folder; remove NEO-108 deferral notes. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — E5M3-08 HTTP read model. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-108 HTTP when landed. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-108 per-player GET when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-08 acceptance criteria when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `EncounterProgressApiTests.cs` | **404** for unknown player. **Inactive:** fresh factory → GET → one encounter **`prototype_combat_pocket`**, **`inactive`**, empty defeats, no **`completedAt`** / **`rewardGrantSummary`**. **Partial:** cast/defeat helpers (reuse **`AbilityCastApiTests`** pattern or inline HTTP) — after 1 defeat **`active`** + 1 id; after 2 defeats still **`active`** + 2 ids, not **`completed`**. **Completed:** defeat all three → **`completed`**, **`completedAt`** present, **`rewardGrantSummary`** quantities 10 + 1; completion store + event store aligned. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Event missing when completed** | Throw **`InvalidOperationException`** at projection time — should not happen after NEO-107; surfaces wiring bugs in tests. | **adopted** |
| **Bruno slot cooldown** | Use hotbar slot **3** (or next free) in defeat spine — avoid slot 0/1 conflicts with other Bruno folders (NEO-44 precedent). | **adopted** |
| **Case on `defeatedTargetIds`** | Return normalized lowercase ids from store (catalog ids are lowercase). | **adopted** |
| **NEO-110 poll cadence** | Defer — client story chooses poll-after-cast vs interval. | **deferred** |