Merge pull request #145 from ViPro-Technologies/NEO-106-wire-combat-defeat-encounter-progress-completion

NEO-106: Wire combat defeat → encounter progress/completion
pull/146/head
VinPropane 2026-05-31 18:16:21 -04:00 committed by GitHub
commit 856dc8dcf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 890 additions and 10 deletions

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M3 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype |
| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring landed · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
| **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
## Purpose
@ -90,6 +90,8 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin
**Completion + inventory grants landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations.TryCompleteAndGrant`** + **`EncounterCompleteEvent`** result payload — reward-table fixed grants via **`PlayerInventoryOperations`**, fail-closed inventory deny, idempotent completion mark; event persistence deferred to NEO-107. Plan: [NEO-105 implementation plan](../../plans/NEO-105-implementation-plan.md); [server README — Encounter completion](../../../server/README.md#encounter-completion--inventory-grants-neo-105).
**Combat wiring landed ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring.TryProcessCastOutcome`** on **`AbilityCastApi`** — activate on first damaging hit, mark defeats, invoke completion grants when all required NPCs defeated; NEO-44 gig XP preserved. Per-player progress HTTP deferred to NEO-108. Plan: [NEO-106 implementation plan](../../plans/NEO-106-implementation-plan.md); [server README — Encounter combat wiring](../../../server/README.md#encounter-combat-wiring-neo-106).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.

File diff suppressed because one or more lines are too long

View File

@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **Combat/HTTP wire (NEO-106+) outstanding.**
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **NEO-106 landed:** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106 plan](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **HTTP progress read (NEO-108+) outstanding.**
### Epic 6 — PvP Security

View File

@ -239,9 +239,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria**
- [ ] Defeating third required NPC triggers exactly one completion grant.
- [ ] Partial progress (12 defeats) does not grant loot.
- [ ] Gig XP still awarded per defeat via **`CombatDefeatGigXpGrant`**.
- [x] Defeating third required NPC triggers exactly one completion grant.
- [x] Partial progress (12 defeats) does not grant loot.
- [x] Gig XP still awarded per defeat via **`CombatDefeatGigXpGrant`**.
**Landed ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`**; in-proc defeat-all-three integration tests; plan [NEO-106-implementation-plan.md](NEO-106-implementation-plan.md).
---

View File

@ -0,0 +1,141 @@
# 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** |

View File

@ -0,0 +1,58 @@
# Code review — NEO-106 (E5M3-07)
**Date:** 2026-05-31
**Scope:** Branch `NEO-106-wire-combat-defeat-encounter-progress-completion` vs `7656fde` (merge-base on `main`) — commits `fc09808``8306f92`
**Base:** `7656fde` (main at branch point, post NEO-105 merge)
## Verdict
**Approve with nits**
## Summary
NEO-106 adds **`EncounterCombatWiring.TryProcessCastOutcome`**, a static orchestration helper (mirroring **`CombatDefeatGigXpGrant`**) that activates encounter progress on first damaging hit, marks defeats on lethal hits, and invokes **`EncounterCompletionOperations.TryCompleteAndGrant`** when all required prototype NPCs are defeated. **`AbilityCastApi`** injects encounter/inventory dependencies and calls the helper on every successful combat resolve, preserving the frozen call order: aggro → cooldown → NPC runtime stop → gig XP grant → encounter wiring. Six AAA unit tests and three in-proc integration tests cover activation, partial progress, full completion loot, gig XP preservation (**75** breach XP after three defeats), and idempotent replay on already-completed encounters. Docs (plan, backlog, E5.M3 snapshot, alignment register, module register, `server/README.md`) are updated. HTTP progress read and client counterpart remain correctly deferred to NEO-108 / NEO-110. Risk is low: best-effort wiring with no cast JSON changes; inventory deny fail-closed behavior inherited from NEO-105.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-106-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; frozen call order and cast→encounter flow table reflected in code. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-07) | **Matches** — acceptance criteria checked; landed note cites plan. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Status marks **E5M3-07 / NEO-106** landed; implementation snapshot bullet added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-106** combat wiring; NEO-108+ HTTP still outstanding. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-106** wiring landed. |
| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110** via NEO-108; no prototype slice completion claim. |
| `server/README.md` | **Matches** — new **Encounter combat wiring (NEO-106)** section is accurate; encounter progress section defers HTTP read to NEO-108 only. |
## Blocking issues
None.
## Suggestions
1. ~~**Unit test for 2/3 defeated** — Plan test table lists “completion not called when only 2/3 defeated.” Integration test **`PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoNpcDefeated`** covers this at HTTP level; a wiring-level AAA test (two **`DefeatNpc`** calls, third **`TryProcessCastOutcome`** with `targetDefeated: false`) would mirror the plan table and isolate completion gating without HTTP.~~ **Done.** `TryProcessCastOutcome_ShouldNotComplete_WhenOnlyTwoTargetsDefeatedAndThirdHitNonLethal`.
2. ~~**Inventory-full on third defeat (wiring path)** — README and plan document fail-closed retry semantics; NEO-105 ops tests cover grant deny. Consider a wiring or integration test that fills the bag before the third lethal cast and asserts progress all-defeated but **`IsCompleted`** false and zero loot delta — exercises the documented retry path end-to-end.~~ **Done.** `TryProcessCastOutcome_ShouldRetainProgressWithoutCompletion_WhenBagFullOnThirdDefeat`.
3. ~~**`encounter_complete` success hook comment** — **`EncounterCompletionOperations`** has NEO-109 deny hook + E7.M2 success TODO; **`EncounterCombatWiring`** logs completion deny at Debug. A brief `// TODO(E9.M1): catalog emit — encounter_complete` on the success branch (or in **`TryCompleteAndGrant`**) would align with NEO-109 scope without runtime code.~~ **Done.** Hook comments on **`EncounterCombatWiring`** success path and **`EncounterCompletionOperations.TryCompleteAndGrant`**.
## Nits
- ~~Nit: **`server/README.md`** encounter progress section (line ~154) still reads “Combat cast wiring … land in NEO-106 / NEO-108” — combat wiring is landed; reword to “HTTP progress read lands in NEO-108” (the next line already links the NEO-106 section).~~ **Done.**
- ~~Nit: **`PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated`** reads **`inventoryAfter`** and resolves **`completionStore`** after the defeat loop (still under **Act**). Moving those reads into **Assert** would tighten AAA; behavior is unchanged.~~ **Done.** Inventory GET and completion store resolve moved to **Assert** in both encounter-loot integration tests.
## Verification
```bash
# NEO-106-focused (11 tests — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterCombatWiring|FullyQualifiedName~PostAbilityCast_ShouldGrantEncounterLoot|FullyQualifiedName~PostAbilityCast_ShouldNotGrantEncounterLoot|FullyQualifiedName~PostAbilityCast_ShouldGrantGigXpPerDefeat"
# Broader encounter slice (71 tests — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters"
# CI parity
python3 scripts/validate_content.py
```
**Local run:** 11/11 NEO-106-focused tests passed; 71/71 `Encounters` filter tests passed.

View File

@ -4,7 +4,9 @@ using System.Text;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Npc;
@ -34,14 +36,17 @@ public sealed class AbilityCastApiTests
post.EnsureSuccessStatusCode();
}
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client)
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) =>
await LockPrototypeTargetAsync(client, PrototypeNpcRegistry.PrototypeNpcMeleeId);
private static async Task LockPrototypeTargetAsync(HttpClient client, string targetId)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
TargetId = targetId,
});
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
@ -49,6 +54,80 @@ public sealed class AbilityCastApiTests
Assert.True(body!.SelectionApplied);
}
private static async Task MoveDevPlayerNearNpcAsync(HttpClient client, string npcInstanceId)
{
Assert.True(PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry));
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/move",
new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector
{
X = entry.X - 1,
Y = 0.9,
Z = entry.Z - 1,
},
});
response.EnsureSuccessStatusCode();
}
private static AbilityCastRequest PulseCastRequestForTarget(string targetId) =>
new()
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = targetId,
};
private static async Task DefeatLockedTargetWithPulseAsync(
HttpClient client,
InMemoryWebApplicationFactory factory,
string targetId)
{
Assert.NotNull(factory.FakeClock);
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
await BindSlot0PulseAsync(client);
await MoveDevPlayerNearNpcAsync(client, targetId);
await LockPrototypeTargetAsync(client, targetId);
var cast = PulseCastRequestForTarget(targetId);
AbilityCastResponse? defeatBody = null;
for (var i = 0; i < 12; i++)
{
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.NotNull(body);
Assert.True(body!.Accepted, body.ReasonCode);
Assert.NotNull(body.CombatResolution);
if (body.CombatResolution!.TargetDefeated)
{
defeatBody = body;
break;
}
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
}
Assert.NotNull(defeatBody);
Assert.True(defeatBody!.CombatResolution!.TargetDefeated);
}
private static int CountBagItem(PlayerInventorySnapshotResponse snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static async Task BindSlot0GuardAsync(HttpClient client)
{
var post = await client.PostAsJsonAsync(
@ -777,4 +856,113 @@ public sealed class AbilityCastApiTests
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
Assert.True(body3!.Accepted);
}
[Fact]
public async Task PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var inventoryBefore = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcEliteId);
// Assert
var inventoryAfter = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
Assert.NotNull(inventoryBefore);
Assert.NotNull(inventoryAfter);
Assert.Equal(
CountBagItem(inventoryBefore!, "scrap_metal_bulk") + 10,
CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
Assert.Equal(
CountBagItem(inventoryBefore!, "contract_handoff_token") + 1,
CountBagItem(inventoryAfter!, "contract_handoff_token"));
Assert.True(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
}
[Fact]
public async Task PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var inventoryBefore = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
// Assert
var inventoryAfter = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
Assert.NotNull(inventoryBefore);
Assert.NotNull(inventoryAfter);
Assert.Equal(
CountBagItem(inventoryBefore!, "scrap_metal_bulk"),
CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
Assert.Equal(
CountBagItem(inventoryBefore!, "contract_handoff_token"),
CountBagItem(inventoryAfter!, "contract_handoff_token"));
Assert.False(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
}
[Fact]
public async Task PostAbilityCast_ShouldGrantGigXpPerDefeat_WhenAllThreeNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var gigBefore = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
"/game/players/dev-local-1/gig-progression");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcEliteId);
var gigAfter = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
"/game/players/dev-local-1/gig-progression");
// Assert
Assert.NotNull(gigBefore);
Assert.NotNull(gigAfter);
var breachBefore = Assert.Single(gigBefore!.Gigs!);
var breachAfter = Assert.Single(gigAfter!.Gigs!);
Assert.Equal(0, breachBefore.Xp);
Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount * 3, breachAfter.Xp);
}
}

View File

@ -0,0 +1,354 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class EncounterCombatWiringTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
[Fact]
public async Task TryProcessCastOutcome_ShouldActivateEncounter_WhenDamagingHitWithoutDefeat()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: false,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress));
Assert.True(progress.Started);
Assert.Empty(progress.DefeatedNpcInstanceIds);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldMarkDefeatWithoutCompletion_WhenOnlyOneTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress));
Assert.Contains(MeleeNpc, progress.DefeatedNpcInstanceIds);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldGrantLootOnce_WhenThirdTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
EliteNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldNoOp_WhenNpcNotInAnyEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
"unknown_npc_instance",
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out _));
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldSkipActivateAndMark_WhenEncounterAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
_ = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var after);
Assert.Equal(CountBagItem(before!, "scrap_metal_bulk"), CountBagItem(after!, "scrap_metal_bulk"));
Assert.Equal(CountBagItem(before!, "contract_handoff_token"), CountBagItem(after!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldNotComplete_WhenOnlyTwoTargetsDefeatedAndThirdHitNonLethal()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
EliteNpc,
damageDealt: 25,
targetDefeated: false,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(
EncounterProgressOperations.IsAllRequiredTargetsDefeated(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.ProgressStore));
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldRetainProgressWithoutCompletion_WhenBagFullOnThirdDefeat()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
FillBag(deps);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
EliteNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.True(
EncounterProgressOperations.IsAllRequiredTargetsDefeated(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.ProgressStore));
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldNotActivate_WhenZeroDamageWithoutDefeat()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 0,
targetDefeated: false,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress) && progress.Started);
}
private static void DefeatNpc(EncounterCombatWiringTestDependencies deps, string npcId)
{
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
npcId,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
}
private static void DefeatAllRequiredTargets(EncounterCombatWiringTestDependencies deps)
{
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
DefeatNpc(deps, EliteNpc);
}
private static void FillBag(EncounterCombatWiringTestDependencies deps)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
PlayerId,
"survey_drone_kit",
quantity: 1,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
}
private static int TotalBagQuantity(PlayerInventorySnapshot snapshot)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty)
{
total += slot.Quantity;
}
}
return total;
}
private static EncounterCombatWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new EncounterCombatWiringTestDependencies(
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private sealed record EncounterCombatWiringTestDependencies(
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
}

View File

@ -1,6 +1,8 @@
using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting;
@ -18,6 +20,7 @@ namespace NeonSprawl.Server.Game.AbilityInput;
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the server cooldown window.
/// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve.
/// NEO-44: on accept when <see cref="CombatResult.TargetDefeated"/> is true, best-effort gig XP grant via <see cref="CombatDefeatGigXpGrant"/> (not E2.M2 skill XP).
/// NEO-106: on every successful resolve, best-effort encounter activate/defeat/completion via <see cref="EncounterCombatWiring"/>.
/// </remarks>
public static class AbilityCastApi
{
@ -68,6 +71,12 @@ public static class AbilityCastApi
INpcBehaviorDefinitionRegistry behaviorRegistry,
INpcRuntimeStateStore npcRuntimeStore,
IPlayerGigProgressionStore gigStore,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore encounterProgressStore,
IEncounterCompletionStore encounterCompletionStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
{
@ -255,6 +264,20 @@ public static class AbilityCastApi
loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant)));
}
EncounterCombatWiring.TryProcessCastOutcome(
id.Trim(),
lookupKey,
combatResult.DamageDealt,
combatResult.TargetDefeated,
encounterRegistry,
rewardTableRegistry,
encounterProgressStore,
encounterCompletionStore,
itemRegistry,
inventoryStore,
clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));
// NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit).
// Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned).
return JsonAbilityCast(

View File

@ -0,0 +1,95 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// NEO-106: wires ability-cast combat outcomes to encounter activation, defeat progress, and completion grants.
/// Best-effort on the cast accept path — outcome does not change <c>AbilityCastResponse</c> JSON.
/// </summary>
public static class EncounterCombatWiring
{
/// <summary>
/// On damaging hits, activates encounter progress; on lethal hits, records defeat and may grant rewards.
/// </summary>
public static void TryProcessCastOutcome(
string playerId,
string targetNpcInstanceId,
int damageDealt,
bool targetDefeated,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
if (damageDealt > 0)
{
EncounterProgressOperations.TryActivateOnFirstEngagement(
playerId,
targetNpcInstanceId,
encounterRegistry,
progressStore,
completionStore);
}
if (!targetDefeated)
{
return;
}
if (!EncounterProgressOperations.TryMarkTargetDefeated(
playerId,
targetNpcInstanceId,
encounterRegistry,
progressStore,
completionStore))
{
return;
}
if (!EncounterProgressOperations.TryResolveEncounterForNpc(
targetNpcInstanceId,
encounterRegistry,
out var encounterId,
out _))
{
return;
}
if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated(
playerId,
encounterId,
encounterRegistry,
progressStore))
{
return;
}
var completion = EncounterCompletionOperations.TryCompleteAndGrant(
playerId,
encounterId,
encounterRegistry,
rewardTableRegistry,
progressStore,
completionStore,
itemRegistry,
inventoryStore,
timeProvider);
if (!completion.Success)
{
logger?.LogDebug(
"Encounter completion grant skipped for player {PlayerId} encounter {EncounterId} ({ReasonCode})",
playerId,
encounterId,
completion.ReasonCode);
return;
}
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_complete` ---
// TODO(E9.M1): catalog emit — once per player+encounter when grants commit (NEO-106 cast path).
}
}

View File

@ -108,6 +108,7 @@ public static class EncounterCompletionOperations
}
// TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent (NEO-107 expands persistence + hook).
// TODO(E9.M1): catalog emit — encounter_complete on successful grant commit (NEO-109).
var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId);
var completeEvent = new EncounterCompleteEvent(

View File

@ -151,7 +151,9 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act
- **`TryMarkTargetDefeated(…)`** — requires prior activation; accumulates defeats in any order; idempotent per NPC.
- **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — set equality vs catalog **`requiredNpcInstanceIds`**.
Combat cast wiring (**`AbilityCastApi`**) and **`GET /game/players/{id}/encounter-progress`** land in NEO-106 / NEO-108. Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md).
**`GET /game/players/{id}/encounter-progress`** lands in NEO-108. Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md).
**Combat wiring (NEO-106):** see [Encounter combat wiring (NEO-106)](#encounter-combat-wiring-neo-106) — **`EncounterCombatWiring`** on **`POST …/ability-cast`** accept path.
## Encounter completion + inventory grants (NEO-105)
@ -166,7 +168,19 @@ Combat cast wiring (**`AbilityCastApi`**) and **`GET /game/players/{id}/encounte
| **`inventory_full`** | Pre-flight or apply could not place full grant quantities. |
| **`inventory_store_missing`** | Player inventory store could not read/write. |
Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** from **`prototype_combat_pocket_clear`**. Inventory deny fails closed without marking complete (compensating rollback on partial apply or failed completion mark). Combat wiring lands in NEO-106. Plan: [NEO-105 implementation plan](../../docs/plans/NEO-105-implementation-plan.md).
Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** from **`prototype_combat_pocket_clear`**. Inventory deny fails closed without marking complete (compensating rollback on partial apply or failed completion mark). Wired from **`AbilityCastApi`** via **`EncounterCombatWiring`** (NEO-106). Plan: [NEO-105 implementation plan](../../docs/plans/NEO-105-implementation-plan.md).
## Encounter combat wiring (NEO-106)
On every successful **`POST …/ability-cast`** accept, **`EncounterCombatWiring.TryProcessCastOutcome`** (best-effort; does not change cast JSON):
| Cast outcome | Encounter effect |
|--------------|------------------|
| **`damageDealt > 0`** vs a required NPC | **`EncounterProgressOperations.TryActivateOnFirstEngagement`** — starts **`prototype_combat_pocket`** progress (`encounter_start` hook site in NEO-104). |
| **`targetDefeated: true`** | **`TryMarkTargetDefeated`**; when all three E5.M2 NPC ids defeated, **`EncounterCompletionOperations.TryCompleteAndGrant`**. |
| Inventory full on completion | Defeat progress retained; completion/grants fail closed (retry when bag has space). |
**Call order on lethal accept:** aggro → cooldown → NPC runtime stop → **`CombatDefeatGigXpGrant`** (NEO-44) → **`EncounterCombatWiring`**. Gig XP per defeat is unchanged (**25** to **`breach`** each). Verify loot with **`GET …/inventory`**; per-player progress HTTP lands in NEO-108. Plan: [NEO-106 implementation plan](../../docs/plans/NEO-106-implementation-plan.md).
## NPC behavior definitions (NEO-90)
@ -788,6 +802,8 @@ Prototype **cast intent** with **combat resolution** on accept (NEO-82). **NEO-2
**NEO-44:** on accept when **`combatResolution.targetDefeated`** is **`true`**, the server grants **25** gig XP to prototype main gig **`breach`** via **`CombatDefeatGigXpGrant`** (outside E2.M2). Verify with **`GET …/gig-progression`**; cast response has no gig block in this slice.
**NEO-106:** on every successful accept, **`EncounterCombatWiring.TryProcessCastOutcome`** activates encounter progress on first damaging hit and applies completion grants when all required NPCs are defeated ([Encounter combat wiring (NEO-106)](#encounter-combat-wiring-neo-106)). Verify loot with **`GET …/inventory`**.
Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Optional **Slice 3named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above).
## Cooldown snapshot (NEO-32)