# 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 1–2 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 1–2 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` | 0–3 ids | omitted | | 1–2 lethal defeats | `active` | 1–2 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** |