12 KiB
12 KiB
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 · Epic 5 Slice 3 · backlog E5M3-07 |
| Branch | NEO-106-wire-combat-defeat-encounter-progress-completion |
| Precursor | NEO-105 — EncounterCompletionOperations.TryCompleteAndGrant + inventory grants (landed on main) |
| Pattern | NEO-44 / CombatDefeatGigXpGrant — static wiring helper called from AbilityCastApi; NEO-104 / NEO-105 — encounter ops already exist |
| Blocks | NEO-108 — GET /game/players/{id}/encounter-progress + Bruno; NEO-109 — telemetry hook sites (activates encounter_start comment from NEO-104) |
| Client counterpart | 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):
EncounterCombatWiring.TryProcessCastOutcome— on damaging hit (DamageDealt > 0):EncounterProgressOperations.TryActivateOnFirstEngagement; on lethal hit (TargetDefeated):TryMarkTargetDefeated, then whenIsAllRequiredTargetsDefeated:EncounterCompletionOperations.TryCompleteAndGrantfor resolved encounter id.- Wire helper from
AbilityCastApiafter combat resolve (alongside existing aggro +CombatDefeatGigXpGranton 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 (1–2 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).EncounterCompleteEventpersistence + E7.M2 hook (NEO-107).- Godot HUD — client counterpart NEO-110.
- Cast response schema changes, Postgres,
docs/manual-qa/NEO-106.md.
Acceptance criteria checklist
- Defeating third required NPC triggers exactly one completion grant (
scrap_metal_bulk×10,contract_handoff_token×1). - Partial progress (1–2 defeats) does not grant loot.
- Gig XP still awarded per defeat via
CombatDefeatGigXpGrant(25 per defeat, unchanged skill progression).
Implementation reconciliation (shipped)
- Wiring:
EncounterCombatWiring.TryProcessCastOutcome— activate ondamageDealt > 0, mark ontargetDefeated,TryCompleteAndGrantwhen all required NPCs defeated. - Integration:
AbilityCastApiinjects encounter/inventory deps; call order preserves NEO-44 gig XP before encounter completion. - Tests:
EncounterCombatWiringTests(6);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
-
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, callTryActivateOnFirstEngagement(playerId, targetNpcInstanceId, …). - Defeat mark: when
targetDefeated, callTryMarkTargetDefeated(…); resolveencounterIdviaTryResolveEncounterForNpc. - Completion: after mark, if
IsAllRequiredTargetsDefeated(playerId, encounterId, …), callTryCompleteAndGrant(playerId, encounterId, …)— success/failure does not change cast accept JSON. - Optional debug log on completion deny (inventory full, etc.) — same pattern as
CombatDefeatGigXpGrant.
-
AbilityCastApichanges- Add DI parameters:
IEncounterDefinitionRegistry,IRewardTableDefinitionRegistry,IEncounterProgressStore,IEncounterCompletionStore,IItemDefinitionRegistry,IPlayerInventoryStore,TimeProvider(reuse routeclockfor completion timestamp). - After successful combat resolve:
- Existing
DamageDealt > 0aggro block: also call wiring with activation context (or call wiring once per accept with both flags). - Existing
TargetDefeatedblock (afterCombatDefeatGigXpGrant): call wiring with defeat context — gig XP grant order preserved before encounter completion so NEO-44 AC holds even if completion fails.
- Existing
- Update XML remarks: NEO-106 encounter wiring.
- Add DI parameters:
-
Call order on lethal accept (frozen)
Step Action 1 CombatOperations.TryResolvesuccess2 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 complete7 Return accept JSON -
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).
-
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. |
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 |