From fc09808482d7896644e9183a7d6f0df9b064f94c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:05:42 -0400 Subject: [PATCH 1/5] NEO-106: Add kickoff implementation plan for combat encounter wiring. --- docs/plans/NEO-106-implementation-plan.md | 134 ++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/plans/NEO-106-implementation-plan.md diff --git a/docs/plans/NEO-106-implementation-plan.md b/docs/plans/NEO-106-implementation-plan.md new file mode 100644 index 0000000..5bb893f --- /dev/null +++ b/docs/plans/NEO-106-implementation-plan.md @@ -0,0 +1,134 @@ +# 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 (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](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 + +- [ ] 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). + +## 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. | +| `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** | From f581cf412cb84408f186534d122f7fd95dd570eb Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:08:28 -0400 Subject: [PATCH 2/5] NEO-106: Wire encounter progress and completion from ability cast. --- .../Game/AbilityInput/AbilityCastApiTests.cs | 192 +++++++++++++- .../Encounters/EncounterCombatWiringTests.cs | 251 ++++++++++++++++++ .../Game/AbilityInput/AbilityCastApi.cs | 23 ++ .../Game/Encounters/EncounterCombatWiring.cs | 91 +++++++ 4 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index e2d6f2d..5db6470 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -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(); @@ -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(); + 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( + "/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); + var inventoryAfter = await client.GetFromJsonAsync( + "/game/players/dev-local-1/inventory"); + var completionStore = factory.Services.GetRequiredService(); + + // Assert + 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( + "/game/players/dev-local-1/inventory"); + + // Act + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcMeleeId); + await DefeatLockedTargetWithPulseAsync( + client, + factory, + PrototypeNpcRegistry.PrototypeNpcRangedId); + var inventoryAfter = await client.GetFromJsonAsync( + "/game/players/dev-local-1/inventory"); + var completionStore = factory.Services.GetRequiredService(); + + // Assert + 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( + "/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( + "/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); + } } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs new file mode 100644 index 0000000..825a5f7 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -0,0 +1,251 @@ +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_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 EncounterCombatWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new EncounterCombatWiringTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + 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); +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index cb94318..7d3f255 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -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 on_cooldown when the slot is inside the server cooldown window. /// NEO-82: after E1 gates pass, invokes ; per-ability catalog cooldown on successful resolve. /// NEO-44: on accept when is true, best-effort gig XP grant via (not E2.M2 skill XP). +/// NEO-106: on every successful resolve, best-effort encounter activate/defeat/completion via . /// 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( diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs new file mode 100644 index 0000000..5579776 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -0,0 +1,91 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// 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 AbilityCastResponse JSON. +/// +public static class EncounterCombatWiring +{ + /// + /// On damaging hits, activates encounter progress; on lethal hits, records defeat and may grant rewards. + /// + 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); + } + } +} From 8306f9255495137e246a58823df2bcf32a193c7a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:08:29 -0400 Subject: [PATCH 3/5] NEO-106: Document combat encounter wiring and update E5.M3 docs. --- .../modules/E5_M3_EncounterAndRewardTables.md | 4 +++- ...cumentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/plans/E5M3-prototype-backlog.md | 8 +++++--- docs/plans/NEO-106-implementation-plan.md | 13 ++++++++++--- server/README.md | 18 +++++++++++++++++- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index 64503cd..72f6ded 100644 --- a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md +++ b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md @@ -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. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index eae5ee8..7599060 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -58,7 +58,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../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; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../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 replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | -| E5.M3 | In Progress | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../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/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **Combat/HTTP wire (NEO-106+) outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | +| E5.M3 | In Progress | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../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/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **HTTP progress read (NEO-108+) outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index ee95acf..dc260fd 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -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-92–NEO-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 diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index 2449123..e3036b2 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -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 (1–2 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 (1–2 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). --- diff --git a/docs/plans/NEO-106-implementation-plan.md b/docs/plans/NEO-106-implementation-plan.md index 5bb893f..34d5389 100644 --- a/docs/plans/NEO-106-implementation-plan.md +++ b/docs/plans/NEO-106-implementation-plan.md @@ -46,9 +46,16 @@ ## 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). +- [x] Defeating third required NPC triggers exactly one completion grant (`scrap_metal_bulk` ×10, `contract_handoff_token` ×1). +- [x] Partial progress (1–2 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` (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 diff --git a/server/README.md b/server/README.md index 7bc48ce..db739e3 100644 --- a/server/README.md +++ b/server/README.md @@ -153,6 +153,8 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act 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). +**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) **`EncounterCompletionOperations.TryCompleteAndGrant`** applies a reward table’s **`fixedGrants`** via **`PlayerInventoryOperations`** when all required NPCs are defeated, then marks **`IEncounterCompletionStore`** idempotently. Returns internal **`EncounterCompleteEvent`** on success (persistence + E7.M2 hook: NEO-107). @@ -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 3–named** 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) From a7b835290cc7738de636a42620534d6bf1c79c94 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:09:20 -0400 Subject: [PATCH 4/5] NEO-106: Add code review for combat encounter wiring. --- docs/reviews/2026-05-31-NEO-106.md | 58 ++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/reviews/2026-05-31-NEO-106.md diff --git a/docs/reviews/2026-05-31-NEO-106.md b/docs/reviews/2026-05-31-NEO-106.md new file mode 100644 index 0000000..4ed4a7b --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-106.md @@ -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` | **Partially matches** — new **Encounter combat wiring (NEO-106)** section is accurate; encounter progress section still has one stale “land in NEO-106 / NEO-108” line (see Nits). | + +## 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. + +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. + +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. + +## 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). + +- 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. + +## Verification + +```bash +# NEO-106-focused (9 tests — passed locally) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterCombatWiring|FullyQualifiedName~PostAbilityCast_ShouldGrantEncounterLoot|FullyQualifiedName~PostAbilityCast_ShouldNotGrantEncounterLoot|FullyQualifiedName~PostAbilityCast_ShouldGrantGigXpPerDefeat" + +# Broader encounter slice (69 tests — passed locally) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters" + +# CI parity +python3 scripts/validate_content.py +``` + +**Local run:** 9/9 NEO-106-focused tests passed; 69/69 `Encounters` filter tests passed. From 9c74933e6e17c2d054d74d450c95b1909782fb49 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 18:10:41 -0400 Subject: [PATCH 5/5] NEO-106: Address code review findings for combat encounter wiring. --- docs/plans/NEO-106-implementation-plan.md | 4 +- docs/reviews/2026-05-31-NEO-106.md | 18 +-- .../Game/AbilityInput/AbilityCastApiTests.cs | 8 +- .../Encounters/EncounterCombatWiringTests.cs | 103 ++++++++++++++++++ .../Game/Encounters/EncounterCombatWiring.cs | 4 + .../EncounterCompletionOperations.cs | 1 + server/README.md | 2 +- 7 files changed, 124 insertions(+), 16 deletions(-) diff --git a/docs/plans/NEO-106-implementation-plan.md b/docs/plans/NEO-106-implementation-plan.md index 34d5389..eea27dc 100644 --- a/docs/plans/NEO-106-implementation-plan.md +++ b/docs/plans/NEO-106-implementation-plan.md @@ -54,7 +54,7 @@ - **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` (6); `AbilityCastApiTests` — defeat-all-three loot once, two-defeat no loot, 75 gig XP after three defeats. +- **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 @@ -127,7 +127,7 @@ | 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. | +| `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 diff --git a/docs/reviews/2026-05-31-NEO-106.md b/docs/reviews/2026-05-31-NEO-106.md index 4ed4a7b..ff72304 100644 --- a/docs/reviews/2026-05-31-NEO-106.md +++ b/docs/reviews/2026-05-31-NEO-106.md @@ -22,7 +22,7 @@ NEO-106 adds **`EncounterCombatWiring.TryProcessCastOutcome`**, a static orchest | `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` | **Partially matches** — new **Encounter combat wiring (NEO-106)** section is accurate; encounter progress section still has one stale “land in NEO-106 / NEO-108” line (see Nits). | +| `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 @@ -30,29 +30,29 @@ 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. +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. +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. +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). +- ~~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. +- ~~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 (9 tests — passed locally) +# 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 (69 tests — passed locally) +# Broader encounter slice (71 tests — passed locally) dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters" # CI parity python3 scripts/validate_content.py ``` -**Local run:** 9/9 NEO-106-focused tests passed; 69/69 `Encounters` filter tests passed. +**Local run:** 11/11 NEO-106-focused tests passed; 71/71 `Encounters` filter tests passed. diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 5db6470..5cfb71a 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -880,11 +880,11 @@ public sealed class AbilityCastApiTests client, factory, PrototypeNpcRegistry.PrototypeNpcEliteId); + + // Assert var inventoryAfter = await client.GetFromJsonAsync( "/game/players/dev-local-1/inventory"); var completionStore = factory.Services.GetRequiredService(); - - // Assert Assert.NotNull(inventoryBefore); Assert.NotNull(inventoryAfter); Assert.Equal( @@ -915,11 +915,11 @@ public sealed class AbilityCastApiTests client, factory, PrototypeNpcRegistry.PrototypeNpcRangedId); + + // Assert var inventoryAfter = await client.GetFromJsonAsync( "/game/players/dev-local-1/inventory"); var completionStore = factory.Services.GetRequiredService(); - - // Assert Assert.NotNull(inventoryBefore); Assert.NotNull(inventoryAfter); Assert.Equal( diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs index 825a5f7..16fb3bb 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -167,6 +167,81 @@ public sealed class EncounterCombatWiringTests 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() { @@ -215,6 +290,34 @@ public sealed class EncounterCombatWiringTests 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; diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs index 5579776..e9575ac 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -86,6 +86,10 @@ public static class EncounterCombatWiring 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). } } diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs index 692b085..01ed842 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -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( diff --git a/server/README.md b/server/README.md index db739e3..4a05c76 100644 --- a/server/README.md +++ b/server/README.md @@ -151,7 +151,7 @@ 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.