169 lines
12 KiB
Markdown
169 lines
12 KiB
Markdown
# 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** |
|