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

156 lines
13 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-104 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-104 |
| **Title** | E5M3-05: Encounter progress + completion stores |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-104/e5m3-05-encounter-progress-completion-stores |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-05** |
| **Branch** | `NEO-104-encounter-progress-completion-stores` |
| **Precursor** | [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) — `GET /game/world/encounter-definitions` (**landed on `main`**) |
| **Pattern** | [NEO-61](NEO-61-implementation-plan.md) / `InMemoryResourceNodeInstanceStore` — keyed in-memory store + static operations; [NEO-44](NEO-44-implementation-plan.md) — gig progression store DI |
| **Blocks** | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) — `EncounterCompletionOperations` + inventory reward apply |
| **Client counterpart** | None this story — player-visible encounter HUD starts at [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) `GET /game/players/{id}/encounter-progress` |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Storage backend** | In-memory only vs Postgres when configured? | **In-memory only** — E5M3 kickoff default; session prototype. | **Adopted** — in-memory only |
| **Progress vs completion** | Single store vs split? | **Split stores** — progress: `started` + `defeatedNpcInstanceIds`; completion: idempotent `completedAt`; operations expose `IsAllRequiredTargetsDefeated`. | **Adopted** — split stores |
| **Encounter binding** | Explicit `encounterId` vs auto-resolve from NPC? | **Auto-resolve** — scan `IEncounterDefinitionRegistry` for encounter whose `requiredNpcInstanceIds` contains the NPC. | **Adopted** — auto-resolve from `npcInstanceId` |
| **Combat / HTTP wiring** | Wire `AbilityCastApi` or HTTP on this story? | **Defer** — E5M3-07 / E5M3-08; NEO-104 ships stores + operations + unit tests only. | **Adopted** (implicit — not asked; backlog out-of-scope) |
| **Activation before defeat** | Auto-activate on `TryMarkTargetDefeated` vs require prior `TryActivateOnFirstEngagement`? | **Require activation**`TryMarkTargetDefeated` no-ops when not started; activation is separate (NEO-106 wires first damaging cast). | **Adopted** (plan default; backlog activation hook) |
## Goal, scope, and out-of-scope
**Goal:** Server-owned per-player encounter **activation**, **defeat progress** (order-independent subset of `requiredNpcInstanceIds`), and an **idempotent completion flag** at the store layer — plus **`EncounterProgressOperations`** that resolve encounters from NPC ids via **`IEncounterDefinitionRegistry`**.
**In scope (from Linear + [E5M3-05](E5M3-prototype-backlog.md#e5m3-05--encounter-progress--completion-stores)):**
- **`IEncounterProgressStore`** — keyed by **`(playerId, encounterId)`**; fields: **`started`** (bool), **`defeatedNpcInstanceIds`** (set, normalized lowercase).
- **`IEncounterCompletionStore`** — **`TryMarkCompleted`**, **`IsCompleted`**, **`TryGetCompletedAt`**; second **`TryMarkCompleted`** is no-op (`false`).
- **`InMemoryEncounterProgressStore`**, **`InMemoryEncounterCompletionStore`** — thread-safe `ConcurrentDictionary` + per-key locks (mirror gathering/NPC stores).
- **`EncounterProgressOperations`**:
- **`TryActivateOnFirstEngagement(playerId, npcInstanceId, …)`** — resolve encounter; set `started` if not started and not completed.
- **`TryMarkTargetDefeated(playerId, npcInstanceId, …)`** — requires started + not completed; add NPC to defeated set if in encounters required list; idempotent re-mark of same NPC returns success without duplicate progress.
- **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — compares defeated set to registry `requiredNpcInstanceIds` (set equality).
- DI registration in **`EncounterCatalogServiceCollectionExtensions`** or new **`EncounterProgressServiceCollectionExtensions`**.
- **`InMemoryWebApplicationFactory`** — register in-memory encounter stores (remove any Postgres placeholder if added later).
- Unit tests (AAA) with injected fake stores / registry.
- `server/README.md` encounter progress section (brief).
**Out of scope (from Linear + backlog):**
- Inventory grants, **`EncounterCompleteEvent`**, **`EncounterCompletionOperations`** ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)).
- Combat defeat / ability-cast wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)).
- HTTP **`GET /game/players/{id}/encounter-progress`** ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)).
- Postgres persistence, Godot, Bruno, `docs/manual-qa/NEO-104.md`.
## Acceptance criteria checklist
- [x] Defeating required NPCs accumulates progress in any order (via `TryMarkTargetDefeated` after activation).
- [x] Completion predicate true only when all required ids defeated (`IsAllRequiredTargetsDefeated`).
- [x] Second completion attempt on same player+encounter is idempotent no-op at store layer (`TryMarkCompleted` returns `false`).
## Implementation reconciliation (shipped)
- **Stores:** `IEncounterProgressStore` / `InMemoryEncounterProgressStore`; `IEncounterCompletionStore` / `InMemoryEncounterCompletionStore`; `EncounterProgressIds` normalization.
- **Operations:** `EncounterProgressOperations``TryResolveEncounterForNpc`, `TryActivateOnFirstEngagement`, `TryMarkTargetDefeated`, `IsAllRequiredTargetsDefeated`.
- **DI:** registered in `AddEncounterAndRewardCatalogs`.
- **Tests:** `InMemoryEncounterProgressStoreTests`, `InMemoryEncounterCompletionStoreTests`, `EncounterProgressOperationsTests`; host DI resolve in `EncounterDefinitionRegistryTests`.
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated.
- **Plan file list:** `Program.cs` and `InMemoryWebApplicationFactory.cs` unchanged — stores register in existing `AddEncounterAndRewardCatalogs` (already invoked at startup); always in-memory (no Postgres test-factory swap).
## Technical approach
1. **Snapshots / keys**
- **`EncounterProgressSnapshot`**: `PlayerId`, `EncounterId`, `Started`, `DefeatedNpcInstanceIds` (read-only set).
- **`EncounterPlayerEncounterKey`**: normalize `playerId` + `encounterId` with trim + lowercase (match registry / position-state id normalization).
- NPC ids normalized the same way when stored and compared.
2. **`IEncounterProgressStore`**
- `TryGetProgress(playerId, encounterId, out snapshot)` — missing row ⇒ not started, empty defeated set.
- `TryActivate(playerId, encounterId)` — creates row with `started: true`, empty defeated set; returns `true` on first activation, `false` if already started (idempotent) or if completion store says completed.
- `TryAddDefeatedTarget(playerId, encounterId, npcInstanceId)` — requires existing started row; add to set; return `true` if newly added, `false` if duplicate NPC or not started.
3. **`IEncounterCompletionStore`**
- `bool IsCompleted(playerId, encounterId)`
- `bool TryMarkCompleted(playerId, encounterId, DateTimeOffset completedAt)``true` first time, `false` if already completed.
- `bool TryGetCompletedAt(…, out DateTimeOffset completedAt)`
4. **`EncounterProgressOperations`** (static, mirror `GatherOperations` / `AggroOperations`)
- **`TryResolveEncounterForNpc(npcInstanceId, registry, out encounterId, out definition)`** — scan `GetDefinitionsInIdOrder()` for first encounter where `requiredNpcInstanceIds` contains normalized NPC (prototype: single `prototype_combat_pocket`).
- **`TryActivateOnFirstEngagement`**: resolve encounter → check completion store → `TryActivate` on progress store.
- **`TryMarkTargetDefeated`**: resolve encounter → deny if completed → deny if not started → validate NPC in required list → `TryAddDefeatedTarget`.
- **`IsAllRequiredTargetsDefeated`**: load progress + definition; compare required set to defeated set (order-independent).
- Inject **`TimeProvider`** only where completion store needs timestamps (completion store accepts `completedAt` from caller; NEO-105 passes `TimeProvider.GetUtcNow()`).
5. **DI**
- `services.AddSingleton<IEncounterProgressStore, InMemoryEncounterProgressStore>();`
- `services.AddSingleton<IEncounterCompletionStore, InMemoryEncounterCompletionStore>();`
- Extend **`AddEncounterAndRewardCatalogs`** or add **`AddEncounterProgressStores`** called from **`Program.cs`** after encounter catalogs.
6. **Reserved hook**
- XML comment on **`TryActivateOnFirstEngagement`**: `// TODO(E9.M1): encounter_start telemetry` (activates E5M3-07 hook site).
7. **Docs**
- `server/README.md` — stores + operations, note combat/HTTP deferred.
- Update [E5_M3](E5_M3_EncounterAndRewardTables.md) implementation snapshot when landed.
### Prototype encounter behavior (frozen)
| Step | Expected |
|------|----------|
| Activate | `TryActivateOnFirstEngagement(devPlayer, prototype_npc_melee)` → started, 0/3 defeated |
| Defeat 2 | Mark melee + ranged → `IsAllRequiredTargetsDefeated` false |
| Defeat 3 | Mark elite → `IsAllRequiredTargetsDefeated` true |
| Complete store | `TryMarkCompleted` once → true; second → false |
| Order | elite → melee → ranged also reaches all-defeated |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressSnapshot.cs` | Immutable progress row for store read model. |
| `server/NeonSprawl.Server/Game/Encounters/IEncounterProgressStore.cs` | Progress store contract. |
| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs` | Thread-safe in-memory implementation. |
| `server/NeonSprawl.Server/Game/Encounters/IEncounterCompletionStore.cs` | Completion store contract. |
| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs` | Thread-safe in-memory implementation. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs` | Static orchestration: resolve encounter, activate, mark defeat, all-defeated predicate. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressOperationsTests.cs` | AAA unit tests: activation, any-order defeats, all-defeated predicate, completion idempotency. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterProgressStoreTests.cs` | AAA store tests: activate idempotency, duplicate defeat, not-started deny. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompletionStoreTests.cs` | AAA completion idempotency tests. |
| `docs/plans/NEO-104-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs` | Register `IEncounterProgressStore` + `IEncounterCompletionStore` singletons (or call new extension from here). |
| `server/NeonSprawl.Server/Program.cs` | Ensure encounter progress stores registered at startup (if split extension, invoke it). |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Swap in test in-memory encounter stores; strip production registrations in test host. |
| `server/README.md` | Document encounter progress/completion stores and operations; defer HTTP/combat to NEO-106/108. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — progress/completion stores (NEO-104). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-104 stores when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-05 AC when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `InMemoryEncounterProgressStoreTests.cs` | **Arrange:** store. **Act/Assert:** first `TryActivate` true; second false; `TryAddDefeatedTarget` without activate false; duplicate NPC add false; two distinct NPCs in set. |
| `InMemoryEncounterCompletionStoreTests.cs` | **Arrange:** store + fixed `completedAt`. **Act:** `TryMarkCompleted` twice. **Assert:** first true, second false; `IsCompleted` true; `TryGetCompletedAt` matches. |
| `EncounterProgressOperationsTests.cs` | **Arrange:** real `EncounterDefinitionRegistry` from test factory catalog + in-memory stores. **Act/Assert:** activate on `prototype_npc_melee`; defeat three NPCs in permuted order → `IsAllRequiredTargetsDefeated` true only after third; partial progress false; `TryMarkTargetDefeated` before activate false; `TryMarkCompleted` idempotent; progress blocked when already completed. |
| `EncounterDefinitionRegistryTests.cs` (optional host test) | **Arrange:** `InMemoryWebApplicationFactory`. **Assert:** resolves `IEncounterProgressStore` + `IEncounterCompletionStore` from DI. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Multiple encounters sharing one NPC** | First match in `GetDefinitionsInIdOrder()` wins — acceptable for prototype (single encounter). Document in operations XML. | **adopted** |
| **Mark defeat after encounter completed** | `TryMarkTargetDefeated` returns false — fail closed. | **adopted** |
| **Postgres follow-on** | Defer — new migration + store pair in a future issue when session persistence is required. | **deferred** |