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

142 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-106 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-106 |
| **Title** | E5M3-07: Wire combat defeat → encounter progress/completion |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-106/e5m3-07-wire-combat-defeat-encounter-progresscompletion |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-07** |
| **Branch** | `NEO-106-wire-combat-defeat-encounter-progress-completion` |
| **Precursor** | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) — `EncounterCompletionOperations.TryCompleteAndGrant` + inventory grants (**landed on `main`**) |
| **Pattern** | [NEO-44](NEO-44-implementation-plan.md) / `CombatDefeatGigXpGrant` — static wiring helper called from `AbilityCastApi`; [NEO-104](NEO-104-implementation-plan.md) / [NEO-105](NEO-105-implementation-plan.md) — encounter ops already exist |
| **Blocks** | [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) — `GET /game/players/{id}/encounter-progress` + Bruno; [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) — telemetry hook sites (activates `encounter_start` comment from NEO-104) |
| **Client counterpart** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels (via NEO-108 HTTP read) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Wiring structure** | Inline in `AbilityCastApi` vs dedicated helper? | **`EncounterCombatWiring` static helper** — mirror `CombatDefeatGigXpGrant`; keeps cast route readable; unit-testable without HTTP. | **Adopted** |
| **Activation trigger** | First damaging cast vs any successful accept? | **First cast with `DamageDealt > 0`** against an encounter NPC — E5M3 kickoff default (“first damaging cast”); colocate with existing aggro block. | **Adopted** (backlog + NEO-104 precedent; not separately asked) |
| **Integration tests** | `AbilityCastApiTests` in-proc vs Bruno defeat-all-three now? | **Extend `AbilityCastApiTests` only** — existing defeat spine helpers + NEO-44 gig XP test precedent; Bruno deferred to NEO-108 per backlog. | **Adopted** |
| **Cast response DTO** | Expose encounter completion on `AbilityCastResponse`? | **No** — NEO-108 adds authoritative GET; cast response unchanged (inventory + stores verified in tests). | **Adopted** (backlog out-of-scope; not separately asked) |
| **Completion on inventory deny** | Third defeat when bag full? | **Defeat progress recorded; `TryCompleteAndGrant` fail-closed per NEO-105** — no completion mark, no grants; player can retry when bag has space (future HTTP will reflect `active` + all-defeated). | **Adopted** (NEO-105 contract; not separately asked) |
## Goal, scope, and out-of-scope
**Goal:** Hook the **`POST …/ability-cast`** accept path so NPC defeats update encounter progress and invoke **`EncounterCompletionOperations.TryCompleteAndGrant`** when all required targets are defeated — preserving **NEO-44** per-defeat gig XP.
**In scope (from Linear + [E5M3-07](E5M3-prototype-backlog.md#e5m3-07--wire-combat-defeat--encounter-progresscompletion)):**
- **`EncounterCombatWiring.TryProcessCastOutcome`** — on damaging hit (`DamageDealt > 0`): **`EncounterProgressOperations.TryActivateOnFirstEngagement`**; on lethal hit (`TargetDefeated`): **`TryMarkTargetDefeated`**, then when **`IsAllRequiredTargetsDefeated`**: **`EncounterCompletionOperations.TryCompleteAndGrant`** for resolved encounter id.
- Wire helper from **`AbilityCastApi`** after combat resolve (alongside existing aggro + **`CombatDefeatGigXpGrant`** on defeat).
- Inject encounter + inventory dependencies into cast route handler (registries, progress/completion stores, item registry, inventory store, `TimeProvider`).
- **`EncounterCombatWiringTests`** — AAA unit tests for activate-on-damage, mark-on-defeat, completion-on-third-defeat (mocked deps / factory services).
- **`AbilityCastApiTests`** — in-proc integration: defeat all three prototype NPCs via cast → inventory grants once; partial progress (12 defeats) → no loot; gig XP **25** per defeat preserved (**75** total after three defeats).
- `server/README.md` — document combat → encounter wiring; update NEO-104/105 deferral notes.
**Out of scope (from Linear + backlog):**
- **`GET /game/players/{id}/encounter-progress`** + Bruno ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)).
- **`EncounterCompleteEvent` persistence** + E7.M2 hook ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)).
- Godot HUD — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**.
- Cast response schema changes, Postgres, `docs/manual-qa/NEO-106.md`.
## Acceptance criteria checklist
- [x] Defeating third required NPC triggers exactly one completion grant (`scrap_metal_bulk` ×10, `contract_handoff_token` ×1).
- [x] Partial progress (12 defeats) does not grant loot.
- [x] Gig XP still awarded per defeat via **`CombatDefeatGigXpGrant`** (25 per defeat, unchanged skill progression).
## Implementation reconciliation (shipped)
- **Wiring:** `EncounterCombatWiring.TryProcessCastOutcome` — activate on `damageDealt > 0`, mark on `targetDefeated`, `TryCompleteAndGrant` when all required NPCs defeated.
- **Integration:** `AbilityCastApi` injects encounter/inventory deps; call order preserves NEO-44 gig XP before encounter completion.
- **Tests:** `EncounterCombatWiringTests` (8); `AbilityCastApiTests` — defeat-all-three loot once, two-defeat no loot, 75 gig XP after three defeats.
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated.
## Technical approach
1. **`EncounterCombatWiring`** (`Game/Encounters/`)
- **`TryProcessCastOutcome(playerId, targetNpcInstanceId, damageDealt, targetDefeated, …deps)`** — best-effort (no throw); outcome ignored on cast accept path (mirror gig XP grant).
- **Activation:** when `damageDealt > 0`, call **`TryActivateOnFirstEngagement(playerId, targetNpcInstanceId, …)`**.
- **Defeat mark:** when `targetDefeated`, call **`TryMarkTargetDefeated(…)`**; resolve **`encounterId`** via **`TryResolveEncounterForNpc`**.
- **Completion:** after mark, if **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`**, call **`TryCompleteAndGrant(playerId, encounterId, …)`** — success/failure does not change cast accept JSON.
- Optional debug log on completion deny (inventory full, etc.) — same pattern as **`CombatDefeatGigXpGrant`**.
2. **`AbilityCastApi` changes**
- Add DI parameters: **`IEncounterDefinitionRegistry`**, **`IRewardTableDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, **`IItemDefinitionRegistry`**, **`IPlayerInventoryStore`**, **`TimeProvider`** (reuse route `clock` for completion timestamp).
- After successful combat resolve:
- Existing **`DamageDealt > 0`** aggro block: also call wiring with activation context (or call wiring once per accept with both flags).
- Existing **`TargetDefeated`** block (after **`CombatDefeatGigXpGrant`**): call wiring with defeat context — **gig XP grant order preserved before encounter completion** so NEO-44 AC holds even if completion fails.
- Update XML remarks: NEO-106 encounter wiring.
3. **Call order on lethal accept (frozen)**
| Step | Action |
|------|--------|
| 1 | `CombatOperations.TryResolve` success |
| 2 | Aggro (if `DamageDealt > 0`) |
| 3 | Cooldown start |
| 4 | `NpcRuntimeOperations.TryStopOnTargetDefeat` (if defeated) |
| 5 | **`CombatDefeatGigXpGrant.GrantOnCombatDefeat`** (if defeated) |
| 6 | **`EncounterCombatWiring.TryProcessCastOutcome`** — activate (if damage > 0) + mark + maybe complete |
| 7 | Return accept JSON |
4. **Integration test helpers** (`AbilityCastApiTests`)
- **`LockPrototypeTargetAsync(client, npcId)`** — generalize existing alpha lock helper.
- **`DefeatLockedTargetWithPulseAsync(client, factory, npcId)`** — bind pulse, lock target, 4 casts with cooldown advance (100 HP / 25 pulse damage).
- **`PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated`** — assert inventory + completion store after third defeat.
- **`PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoDefeated`** — assert zero scrap/token delta.
- Extend or duplicate gig XP test to assert **75** breach XP after three defeats (3 × 25).
5. **No new DI registration** — encounter stores/registries already in **`AddEncounterAndRewardCatalogs`**.
### Prototype cast → encounter flow (frozen)
| Cast event | Encounter effect |
|------------|------------------|
| First pulse hit (damage > 0) vs `prototype_npc_*` | `TryActivateOnFirstEngagement` → progress **started** |
| Lethal hit vs any required NPC | `TryMarkTargetDefeated` → +1 defeated id |
| Third distinct required NPC defeated | `TryCompleteAndGrant` → +10 scrap, +1 token, completion marked |
| Re-hit defeated NPC | Combat deny `target_defeated`; encounter ops no-op |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Static helper: activate on damage, mark on defeat, complete when all defeated. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs` | AAA unit tests for wiring orchestration (factory-backed deps). |
| `docs/plans/NEO-106-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject encounter/inventory deps; call `EncounterCombatWiring` on accept. |
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | In-proc defeat-all-three integration tests + partial progress + gig XP preservation. |
| `server/README.md` | Document combat → encounter wiring; remove NEO-106 deferral notes from progress/completion sections. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — E5M3-07 combat wiring (when landed). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-106 wiring when landed. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-106 combat wiring when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-07 AC when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `EncounterCombatWiringTests.cs` | **Arrange:** `InMemoryWebApplicationFactory` services. **Act/Assert:** damaging hit activates encounter; defeat marks progress; third defeat invokes completion (inventory + `IsCompleted`); non-encounter NPC no-op; already-completed encounter skips activate/mark; completion not called when only 2/3 defeated (non-lethal third hit); bag-full third defeat retains all-defeated progress without completion mark. |
| `AbilityCastApiTests.cs` | **Arrange:** HTTP client + pulse bind + per-NPC lock/defeat helpers. **Act/Assert:** defeat all three via cast → `scrap_metal_bulk` ×10 + `contract_handoff_token` ×1 once; two defeats → zero loot; gig XP 75 on breach after three defeats, skill XP unchanged; existing NEO-44 single-defeat test still passes. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Helper vs inline wiring** | **`EncounterCombatWiring`** — adopted at kickoff. | **adopted** |
| **Bruno defeat spine** | Defer to **NEO-108** — in-proc tests sufficient for NEO-106 AC. | **adopted** |
| **NPC HP variance** | All three prototype NPCs use catalog **100 HP**; pulse **25** → 4 casts each — reuse existing defeat loop pattern. | **adopted** |
| **Inventory-full on third defeat** | Progress stays all-defeated; completion retriable when bag has space — document in README; no cast deny. | **adopted** |
| **Event persistence** | Deferred to **NEO-107** — wiring returns/completes via existing `EncounterCompleteEvent` in result only. | **deferred** |