NEO-108: Add kickoff implementation plan for encounter-progress GET.

Documents HTTP projection from progress, completion, and event stores,
Bruno defeat spine, and adopted grant-summary source from kickoff.
pull/147/head
VinPropane 2026-05-31 18:35:42 -04:00
parent 51e2007d60
commit 16018586b7
1 changed files with 160 additions and 0 deletions

View File

@ -0,0 +1,160 @@
# 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
- [ ] GET reflects progress after each defeat without client-side inference.
- [ ] Completed encounter shows **`completed`** state and grant summary.
- [ ] Bruno smoke: defeat chain → GET shows **`completed`** once.
## 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** |