From 7abe7dcafd67b697a65e59a0ec8fb2ff90166eaa Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:18:58 -0400 Subject: [PATCH 1/4] NEO-128: add implementation plan for quest-complete reward wiring. --- docs/plans/NEO-128-implementation-plan.md | 158 ++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/plans/NEO-128-implementation-plan.md diff --git a/docs/plans/NEO-128-implementation-plan.md b/docs/plans/NEO-128-implementation-plan.md new file mode 100644 index 0000000..f111aca --- /dev/null +++ b/docs/plans/NEO-128-implementation-plan.md @@ -0,0 +1,158 @@ +# NEO-128 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-128 | +| **Title** | E7M2-05: Wire router into `QuestStateOperations.TryMarkComplete` | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-128/e7m2-05-wire-router-into-queststateoperationstrymarkcomplete | +| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-05** | +| **Branch** | `NEO-128-e7m2-quest-complete-reward-wiring` | +| **Precursor** | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) **`Done`** — `RewardRouterOperations.TryDeliverQuestCompletion` | +| **Pattern** | [NEO-105](NEO-105-implementation-plan.md) — deliver side effects before completion mark; [NEO-118](NEO-118-implementation-plan.md) — `QuestObjectiveWiring` → `TryMarkComplete` on terminal step | +| **Blocks** | [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) (`completionRewardSummary` HTTP), [NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) (telemetry hook comments) | +| **Client counterpart** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — HUD surfacing (blocked by E7M2-06 / NEO-129) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Delivery vs mark ordering** | When should bundle apply run relative to `TryMarkComplete`? | **Deliver bundle first, then mark complete** — E7M2 “transactional equivalent” to fail-closed; mirrors NEO-105 (grants before completion mark); avoids new `TryRevertCompletion` store API. | **Adopted** — deliver-then-mark | +| **Bruno smoke** | Add Bruno requests for quest-complete reward delivery? | **Skip** — server-internal wiring only; no HTTP DTO changes (NEO-129); automated integration tests cover AC; Bruno optional per backlog because verification is store/inventory/skill assertions, not a new route. | **Adopted** — skip Bruno | + +**Additional defaults (no kickoff question — settled by backlog / landed code):** + +- **Idempotent quest replay** — when progress is already **`completed`**, return success immediately and **do not** call the router (NEO-121 `quest_complete` hook path unchanged; no double-grant). +- **Deny reason passthrough** — on delivery deny, return **`QuestStateOperationResult`** with **`Success: false`** and **`ReasonCode`** from **`RewardDeliveryResult.ReasonCode`** (`inventory_full`, etc.); quest row stays **`active`**. +- **`QuestStateOperationResult` shape** — unchanged; HTTP projection of grants is NEO-129. +- **Bundle source** — `definition.CompletionRewardBundle` from **`IQuestDefinitionRegistry`**; all four prototype quests have bundles (NEO-124/125 CI gates). +- **Wiring best-effort** — `QuestObjectiveWiring` continues to ignore `TryMarkComplete` deny envelopes (primary gather/craft/encounter ops unaffected); on inventory-full deny the quest remains active for retry. + +## Goal, scope, and out-of-scope + +**Goal:** First-time quest completion triggers **`completionRewardBundle`** delivery exactly once via **`RewardRouterOperations`**; idempotent complete replay does not double-grant items or XP. + +**In scope (from Linear + [E7M2-05](E7M2-prototype-backlog.md#e7m2-05--wire-router-into-queststateoperationstrymarkcomplete)):** + +- Call **`RewardRouterOperations.TryDeliverQuestCompletion`** from **`QuestStateOperations.TryMarkComplete`** on the first-time completion path (before store mark per adopted ordering). +- Skip router on idempotent **`completed`** early return. +- Thread router dependencies through **`QuestObjectiveWiring`** and upstream ops (`GatherOperations`, `CraftOperations`, `EncounterCompletionOperations`, `QuestProgressApi`) so objective-wiring completion exercises the same path. +- Integration tests (AAA): gather-intro wiring → salvage +25 + store record; idempotent `TryMarkComplete` replay → no duplicate XP; inventory-full deny → quest stays active, no store record; operator-chain → item + skill grants. +- `server/README.md` quest-state section update; E7.M2 module alignment anchor. + +**Out of scope (from Linear):** + +- HTTP **`completionRewardSummary`** ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06). +- E9.M1 telemetry ingest ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) E7M2-07) — existing comment-only hook sites stay. +- Encounter completion path; per-step bundles. +- Bruno smoke (deferred per kickoff). +- Godot client. + +## Acceptance criteria checklist + +- [ ] Completing a quest via objective wiring delivers bundle once. +- [ ] Idempotent complete replay does not double-grant items or XP. + +## Technical approach + +### 1. Extend `TryMarkComplete` (deliver-then-mark) + +Add router dependencies to the static signature (mirror **`RewardRouterOperations.TryDeliverQuestCompletion`** injectables): + +```csharp +public static QuestStateOperationResult TryMarkComplete( + string playerId, + string questId, + IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, + TimeProvider timeProvider) +``` + +**First-time completion flow** (after existing registry / active guards): + +1. Load **`definition.CompletionRewardBundle`** from registry (already resolved in guard). +2. **`RewardRouterOperations.TryDeliverQuestCompletion(...)`** — includes store idempotent replay (`already_delivered` success when store hit). +3. If **`!delivery.Success`**, return **`Deny(delivery.ReasonCode!)`** — quest stays **`active`**. +4. **`progressStore.TryMarkComplete(...)`** — on success, emit existing NEO-121 **`quest_complete`** comment hook; return **`Success(snapshot)`**. +5. Preserve existing idempotent / race handling when **`TryMarkComplete`** returns false but progress is already **`completed`** (re-read paths unchanged). + +**Idempotent replay** (progress already **`completed`**): existing early return — **no router call**. + +### 2. Dependency threading + +| Layer | Change | +|-------|--------| +| **`QuestObjectiveWiring`** | Add reward-router parameters to **`TryCompleteStepIfSatisfied`**, **`TryCompleteAllActiveQuests`**, and all public entry points that call them (`TryProcessGatherSuccess`, `TryProcessCraftSuccess`, `TryProcessEncounterCompleteSuccess`, `TryRefreshInventoryProgressForPlayer`). | +| **`QuestStateOperations`** | Pass new deps into **`TryMarkComplete`** from **`TryAccept`** / **`TryAdvanceStep`** wiring calls. | +| **`GatherOperations`**, **`CraftOperations`**, **`EncounterCompletionOperations`** | Extend method signatures + call sites to pass skill/progress/perk/delivery deps into wiring (resolve from existing HTTP/ops injectables same as gather/craft already thread quest stores). | +| **`QuestProgressApi`** | Pass reward deps into **`TryRefreshInventoryProgressForPlayer`**. | + +Use **`InMemoryWebApplicationFactory`** DI resolution in tests — no new service registration (stores already in **`Program.cs`**). + +### 3. Tests + +| Test file | New / extended cases | +|-----------|---------------------| +| **`QuestStateOperationsTests`** | **`TryMarkComplete_ShouldDeliverGatherIntroBundle_WhenFirstCompletion`** — accept + complete → **`IRewardDeliveryStore.TryGet`** true, salvage XP +25. **`TryMarkComplete_ShouldNotDoubleGrant_WhenIdempotentReplay`** — second complete → same store event, XP unchanged. **`TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull`** — fill bag, complete operator-chain quest setup → deny **`inventory_full`**, status **`active`**, no store record. | +| **`QuestObjectiveWiringTests`** | Extend **`GatherIntro_ShouldComplete_WhenScrapGatheredThreeTimes`** (or sibling) — assert salvage XP +25 and delivery store after wiring-driven complete. **`GatherIntro_ShouldNotDoubleGrantXp_WhenCompleteReplayed`** — manual second **`TryMarkComplete`** after wiring complete. | + +Reuse **`RewardRouterTestDependencies`**-style resolution or extend existing **`QuestTestDependencies`** / **`QuestWiringTestDependencies`** records with skill + delivery services. + +### 4. Docs + +- **`server/README.md`** — update **`QuestStateOperations.TryMarkComplete`** section: deliver-then-mark order, idempotent skip, deny passthrough, pointer to **`RewardRouterOperations`**. +- **`documentation_and_implementation_alignment.md`** — E7.M2 row: quest-state wiring (NEO-128). +- **`E7_M2_RewardAndUnlockRouter.md`** — implementation anchor for **`TryMarkComplete`** wiring. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-128-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Wire **`RewardRouterOperations`** into **`TryMarkComplete`**; extend signature; thread deps from accept/advance. | +| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Pass reward-router deps through completion wiring chain. | +| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Thread reward deps into **`TryProcessGatherSuccess`**. | +| `server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs` | Thread reward deps into **`TryProcessCraftSuccess`**. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Thread reward deps into **`TryProcessEncounterCompleteSuccess`**. | +| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Thread reward deps into inventory refresh wiring. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Bundle delivery, idempotent replay, inventory-full deny tests. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Wiring path asserts store + skill XP after gather complete. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | Update **`TryMarkComplete`** call sites with new parameters. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Update **`TryMarkComplete`** / wiring dependency resolution. | +| `server/README.md` | Document quest-complete reward wiring on **`TryMarkComplete`**. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — quest-state wiring (NEO-128). | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | NEO-128 implementation anchor. | + +## Tests + +| Target | Coverage | +|--------|----------| +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Direct **`TryMarkComplete`**: first completion delivers bundle + records store; idempotent replay no double XP; inventory-full deny leaves quest active without store record. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Gather-intro wiring path delivers salvage +25 once; replay does not double-grant. | +| **Compile-fix only** — `QuestAcceptApiTests`, `QuestProgressApiTests` | Updated **`TryMarkComplete`** signatures; existing accept/progress behavior unchanged. | +| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter "QuestStateOperationsTests|QuestObjectiveWiringTests"`; dev server boot unchanged. | + +## Open questions / risks + +| Question or risk | Agent recommendation | Status | +|------------------|----------------------|--------| +| Delivery succeeds but **`TryMarkComplete`** store write fails while quest still active | Extremely unlikely solo prototype; handle via existing re-read idempotent paths when another caller completed; defer explicit delivery rollback on mark-fail unless concurrency story requires it. | **deferred** | +| Orphan store record if mark never commits | **`TryDeliver`** idempotent replay on retry allows **`already_delivered`** success then mark on next complete attempt — self-healing. | **adopted** | +| Large signature expansion across wiring call graph | Accept explicit parameter threading (NEO-118 precedent); no new DI facade type in this story. | **adopted** | +| **`QuestStateOperationResult` grant projection** | Defer to NEO-129 HTTP read model. | **deferred** | + +None beyond the above. From da99769ddf3078e2e4ac3ffdaa26095fda1fd707 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:24:01 -0400 Subject: [PATCH 2/4] NEO-128: wire quest complete to reward router deliver-then-mark. TryMarkComplete delivers completionRewardBundle before marking complete; objective wiring and upstream ops thread reward dependencies. Tests cover bundle delivery, idempotent replay, and inventory-full deny. --- ...lth after quest complete reward wiring.bru | 28 +++ .../modules/E7_M2_RewardAndUnlockRouter.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E7M2-prototype-backlog.md | 4 +- docs/plans/NEO-128-implementation-plan.md | 11 +- .../Game/Crafting/CraftOperationsTests.cs | 29 ++- .../Encounters/EncounterCombatWiringTests.cs | 79 +++++++- .../EncounterCompletionOperationsTests.cs | 67 ++++++- .../Game/Gathering/GatherOperationsTests.cs | 23 ++- .../Game/Quests/QuestAcceptApiTests.cs | 34 +++- .../Game/Quests/QuestObjectiveWiringTests.cs | 128 ++++++++---- .../Game/Quests/QuestProgressApiTests.cs | 44 ++++- .../Game/Quests/QuestStateOperationsTests.cs | 182 +++++++++++++----- .../Game/AbilityInput/AbilityCastApi.cs | 15 ++ .../Game/Crafting/CraftOperations.cs | 9 + .../Game/Crafting/PlayerCraftApi.cs | 6 +- .../Game/Encounters/EncounterCombatWiring.cs | 15 ++ .../EncounterCompletionOperations.cs | 15 ++ .../Game/Gathering/GatherOperations.cs | 9 + .../Game/Interaction/InteractionApi.cs | 6 +- .../Game/Quests/QuestAcceptApi.cs | 13 +- .../Game/Quests/QuestObjectiveWiring.cs | 83 ++++++++ .../Game/Quests/QuestProgressApi.cs | 14 +- .../Game/Quests/QuestStateOperations.cs | 59 +++++- server/README.md | 4 +- 25 files changed, 764 insertions(+), 117 deletions(-) create mode 100644 bruno/neon-sprawl-server/quest-progress/Health after quest complete reward wiring.bru diff --git a/bruno/neon-sprawl-server/quest-progress/Health after quest complete reward wiring.bru b/bruno/neon-sprawl-server/quest-progress/Health after quest complete reward wiring.bru new file mode 100644 index 0000000..4a44951 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Health after quest complete reward wiring.bru @@ -0,0 +1,28 @@ +meta { + name: GET health (quest complete reward wiring NEO-128) + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-128 wires RewardRouterOperations into QuestStateOperations.TryMarkComplete (deliver-then-mark). + No new HTTP routes — quest-progress GET/accept POST signatures gained reward-router DI only. + Use this request to confirm the host started after wiring; verify bundle delivery via dotnet test + (QuestStateOperationsTests, QuestObjectiveWiringTests). +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md index 76306ed..6702f63 100644 --- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md +++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md @@ -71,3 +71,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r **NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Quest-state wiring lands in NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). + +**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. HTTP **`completionRewardSummary`** in NEO-129. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 9962204..1de2cdb 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | 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 | Ready | **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). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** 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) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [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 | -| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Wire router into **`QuestStateOperations.TryMarkComplete`** (NEO-128); extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | +| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/plans/E7M2-prototype-backlog.md b/docs/plans/E7M2-prototype-backlog.md index 339f756..9669b85 100644 --- a/docs/plans/E7M2-prototype-backlog.md +++ b/docs/plans/E7M2-prototype-backlog.md @@ -194,8 +194,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c **Acceptance criteria** -- [ ] Completing a quest via objective wiring delivers bundle once. -- [ ] Idempotent complete does not double-grant items or XP. +- [x] Completing a quest via objective wiring delivers bundle once. +- [x] Idempotent complete does not double-grant items or XP. **Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — HUD surfacing (blocked by E7M2-06). diff --git a/docs/plans/NEO-128-implementation-plan.md b/docs/plans/NEO-128-implementation-plan.md index f111aca..b540c4c 100644 --- a/docs/plans/NEO-128-implementation-plan.md +++ b/docs/plans/NEO-128-implementation-plan.md @@ -51,8 +51,15 @@ ## Acceptance criteria checklist -- [ ] Completing a quest via objective wiring delivers bundle once. -- [ ] Idempotent complete replay does not double-grant items or XP. +- [x] Completing a quest via objective wiring delivers bundle once. +- [x] Idempotent complete replay does not double-grant items or XP. + +## Implementation reconciliation (shipped) + +- **Operations:** `QuestStateOperations.TryMarkComplete` — deliver-then-mark via `RewardRouterOperations`; idempotent `completed` skip; deny passthrough from `RewardDeliveryReasonCodes`. +- **Wiring:** `QuestObjectiveWiring` + `GatherOperations` / `CraftOperations` / `EncounterCompletionOperations` / quest HTTP APIs thread reward-router dependencies. +- **Tests:** `QuestStateOperationsTests` — bundle delivery, idempotent replay, inventory-full deny; `QuestObjectiveWiringTests` — gather-intro wiring + replay no double-grant. +- **Docs:** `server/README.md`; E7.M2 module anchor + alignment register updated. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs index 8dabf13..5ecc9ec 100644 --- a/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs @@ -3,6 +3,7 @@ using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; @@ -35,6 +36,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -79,6 +82,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -114,6 +119,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -151,6 +158,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -185,6 +194,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -220,6 +231,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -252,6 +265,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -280,6 +295,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -311,6 +328,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -344,6 +363,8 @@ public sealed class CraftOperationsTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -392,7 +413,9 @@ public sealed class CraftOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -429,7 +452,9 @@ public sealed class CraftOperationsTests ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IQuestDefinitionRegistry QuestRegistry, - IPlayerQuestStateStore ProgressStore); + IPlayerQuestStateStore ProgressStore, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore); private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs index 7bfdb4d..95741a4 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -1,7 +1,10 @@ using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; @@ -37,6 +40,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -68,6 +77,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -103,6 +118,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -136,6 +157,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -162,6 +189,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var before); @@ -180,6 +213,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -212,6 +251,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -253,6 +298,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -291,6 +342,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -313,6 +370,12 @@ public sealed class EncounterCombatWiringTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); } @@ -363,7 +426,13 @@ public sealed class EncounterCombatWiringTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -389,5 +458,11 @@ public sealed class EncounterCombatWiringTests IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore, IQuestDefinitionRegistry QuestRegistry, - IPlayerQuestStateStore QuestProgressStore); + IPlayerQuestStateStore QuestProgressStore, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore SkillProgressionStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore); } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs index 82529c5..4441b24 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -1,7 +1,10 @@ using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; @@ -37,6 +40,12 @@ public sealed class EncounterCompletionOperationsTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, timeProvider); // Assert @@ -80,6 +89,12 @@ public sealed class EncounterCompletionOperationsTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, timeProvider); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst); @@ -96,6 +111,12 @@ public sealed class EncounterCompletionOperationsTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, timeProvider); // Assert @@ -154,6 +175,12 @@ public sealed class EncounterCompletionOperationsTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -188,6 +215,12 @@ public sealed class EncounterCompletionOperationsTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -224,6 +257,12 @@ public sealed class EncounterCompletionOperationsTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -258,6 +297,12 @@ public sealed class EncounterCompletionOperationsTests inventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -290,6 +335,12 @@ public sealed class EncounterCompletionOperationsTests deps.InventoryStore, deps.QuestRegistry, deps.QuestProgressStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); // Assert @@ -352,7 +403,13 @@ public sealed class EncounterCompletionOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -392,7 +449,13 @@ public sealed class EncounterCompletionOperationsTests IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore, IQuestDefinitionRegistry QuestRegistry, - IPlayerQuestStateStore QuestProgressStore); + IPlayerQuestStateStore QuestProgressStore, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore SkillProgressionStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore); private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs index c400b44..a62e740 100644 --- a/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs @@ -3,6 +3,7 @@ using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; @@ -36,6 +37,8 @@ public sealed class GatherOperationsTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); @@ -90,6 +93,8 @@ public sealed class GatherOperationsTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); @@ -126,6 +131,8 @@ public sealed class GatherOperationsTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); Assert.True(gather.Success); } @@ -147,6 +154,8 @@ public sealed class GatherOperationsTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); @@ -182,6 +191,8 @@ public sealed class GatherOperationsTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _); @@ -215,6 +226,8 @@ public sealed class GatherOperationsTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); @@ -249,6 +262,8 @@ public sealed class GatherOperationsTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); @@ -273,7 +288,9 @@ public sealed class GatherOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountItem(PlayerInventorySnapshot snapshot, string itemId) @@ -311,7 +328,9 @@ public sealed class GatherOperationsTests PerkUnlockEngine PerkUnlockEngine, IResourceNodeInstanceStore InstanceStore, IQuestDefinitionRegistry QuestRegistry, - IPlayerQuestStateStore ProgressStore); + IPlayerQuestStateStore ProgressStore, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore); private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs index c595ccc..d31af38 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs @@ -4,7 +4,10 @@ using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; @@ -202,22 +205,45 @@ public sealed class QuestAcceptApiTests // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); - var questRegistry = factory.Services.GetRequiredService(); - var progressStore = factory.Services.GetRequiredService(); + var services = factory.Services; + var questRegistry = services.GetRequiredService(); + var progressStore = services.GetRequiredService(); + var inventoryStore = services.GetRequiredService(); + var itemRegistry = services.GetRequiredService(); + var skillRegistry = services.GetRequiredService(); + var skillProgressionStore = services.GetRequiredService(); + var levelCurve = services.GetRequiredService(); + var perkUnlockEngine = services.GetRequiredService(); + var perkStore = services.GetRequiredService(); + var deliveryStore = services.GetRequiredService(); var timeProvider = TimeProvider.System; Assert.True(QuestStateOperations.TryAccept( PlayerId, GatherQuestId, questRegistry, progressStore, - factory.Services.GetRequiredService(), - factory.Services.GetRequiredService(), + inventoryStore, + itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider).Success); Assert.True(QuestStateOperations.TryMarkComplete( PlayerId, GatherQuestId, questRegistry, progressStore, + itemRegistry, + inventoryStore, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider).Success); // Act diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs index 9be4aaa..a412e11 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs @@ -5,6 +5,7 @@ using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; @@ -41,6 +42,37 @@ public sealed class QuestObjectiveWiringTests // Assert Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress)); Assert.Equal(QuestProgressStatus.Completed, progress.Status); + Assert.Equal( + 3 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather + 25, + deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent)); + Assert.Single(deliveryEvent.GrantedSkillXp); + Assert.Equal(25, deliveryEvent.GrantedSkillXp[0].Amount); + } + + [Fact] + public async Task GatherIntro_ShouldNotDoubleGrantXp_WhenCompleteReplayed() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + Assert.True(TryAccept(deps, GatherQuestId).Success); + for (var i = 0; i < 3; i++) + { + Assert.True(TryGather(deps, AlphaNodeId).Success); + } + + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var firstEvent)); + var salvageXpBeforeReplay = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"); + + // Act + var replay = TryMarkComplete(deps, GatherQuestId); + + // Assert + Assert.True(replay.Success); + Assert.Equal(salvageXpBeforeReplay, deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var replayEvent)); + Assert.Equal(firstEvent.IdempotencyKey, replayEvent.IdempotencyKey); } [Fact] @@ -78,6 +110,12 @@ public sealed class QuestObjectiveWiringTests deps.ProgressStore, deps.InventoryStore, deps.ItemRegistry, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); // Assert @@ -147,37 +185,13 @@ public sealed class QuestObjectiveWiringTests SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1); Assert.True(TryAccept(deps, ChainQuestId).Success); Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _)); - Assert.True(QuestStateOperations.TryAdvanceStep( - PlayerId, - ChainQuestId, - 1, - deps.QuestRegistry, - deps.ProgressStore, - deps.InventoryStore, - deps.ItemRegistry, - deps.TimeProvider).Success); + Assert.True(TryAdvanceStep(deps, ChainQuestId, 1).Success); Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _)); - Assert.True(QuestStateOperations.TryAdvanceStep( - PlayerId, - ChainQuestId, - 2, - deps.QuestRegistry, - deps.ProgressStore, - deps.InventoryStore, - deps.ItemRegistry, - deps.TimeProvider).Success); + Assert.True(TryAdvanceStep(deps, ChainQuestId, 2).Success); Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _)); // Act - var advance = QuestStateOperations.TryAdvanceStep( - PlayerId, - ChainQuestId, - 3, - deps.QuestRegistry, - deps.ProgressStore, - deps.InventoryStore, - deps.ItemRegistry, - deps.TimeProvider); + var advance = TryAdvanceStep(deps, ChainQuestId, 3); // Assert Assert.True(advance.Success); @@ -201,6 +215,12 @@ public sealed class QuestObjectiveWiringTests deps.InventoryStore, deps.QuestRegistry, deps.ProgressStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); Assert.True(result.Success); } @@ -232,17 +252,51 @@ public sealed class QuestObjectiveWiringTests deps.ProgressStore, deps.InventoryStore, deps.ItemRegistry, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.TimeProvider); + + private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) => + QuestStateOperations.TryAdvanceStep( + PlayerId, + questId, + step, + deps.QuestRegistry, + deps.ProgressStore, + deps.InventoryStore, + deps.ItemRegistry, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.TimeProvider); + + private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => + QuestStateOperations.TryMarkComplete( + PlayerId, + questId, + deps.QuestRegistry, + deps.ProgressStore, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) { Assert.True(TryAccept(deps, questId).Success); - Assert.True(QuestStateOperations.TryMarkComplete( - PlayerId, - questId, - deps.QuestRegistry, - deps.ProgressStore, - deps.TimeProvider).Success); + Assert.True(TryMarkComplete(deps, questId).Success); } private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) => @@ -259,6 +313,8 @@ public sealed class QuestObjectiveWiringTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => @@ -275,6 +331,8 @@ public sealed class QuestObjectiveWiringTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) @@ -308,6 +366,8 @@ public sealed class QuestObjectiveWiringTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -328,5 +388,7 @@ public sealed class QuestObjectiveWiringTests IEncounterProgressStore EncounterProgressStore, IEncounterCompletionStore CompletionStore, IEncounterCompleteEventStore CompleteEventStore, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs index ef1d379..af0396d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -7,6 +7,7 @@ using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; @@ -230,6 +231,12 @@ public sealed class QuestProgressApiTests deps.InventoryStore, deps.QuestRegistry, deps.ProgressStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); Assert.True(result.Success); } @@ -261,17 +268,34 @@ public sealed class QuestProgressApiTests deps.ProgressStore, deps.InventoryStore, deps.ItemRegistry, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.TimeProvider); + + private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => + QuestStateOperations.TryMarkComplete( + PlayerId, + questId, + deps.QuestRegistry, + deps.ProgressStore, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) { Assert.True(TryAccept(deps, questId).Success); - Assert.True(QuestStateOperations.TryMarkComplete( - PlayerId, - questId, - deps.QuestRegistry, - deps.ProgressStore, - deps.TimeProvider).Success); + Assert.True(TryMarkComplete(deps, questId).Success); } private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) => @@ -288,6 +312,8 @@ public sealed class QuestProgressApiTests deps.InstanceStore, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => @@ -304,6 +330,8 @@ public sealed class QuestProgressApiTests deps.PerkUnlockEngine, deps.QuestRegistry, deps.ProgressStore, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) @@ -337,6 +365,8 @@ public sealed class QuestProgressApiTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -357,5 +387,7 @@ public sealed class QuestProgressApiTests IEncounterProgressStore EncounterProgressStore, IEncounterCompletionStore CompletionStore, IEncounterCompleteEventStore CompleteEventStore, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs index b017539..cb3bd4a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs @@ -1,7 +1,10 @@ using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; @@ -59,12 +62,7 @@ public sealed class QuestStateOperationsTests var deps = ResolveDependencies(factory); var timeProvider = TimeProvider.System; Assert.True(TryAccept(deps, GatherQuestId).Success); - Assert.True(QuestStateOperations.TryMarkComplete( - PlayerId, - GatherQuestId, - deps.QuestRegistry, - deps.ProgressStore, - timeProvider).Success); + Assert.True(TryMarkComplete(deps, GatherQuestId).Success); // Act var result = TryAccept(deps, RefineQuestId); @@ -116,12 +114,7 @@ public sealed class QuestStateOperationsTests var deps = ResolveDependencies(factory); var timeProvider = TimeProvider.System; Assert.True(TryAccept(deps, GatherQuestId).Success); - Assert.True(QuestStateOperations.TryMarkComplete( - PlayerId, - GatherQuestId, - deps.QuestRegistry, - deps.ProgressStore, - timeProvider).Success); + Assert.True(TryMarkComplete(deps, GatherQuestId).Success); // Act var result = TryAccept(deps, GatherQuestId); @@ -230,12 +223,7 @@ public sealed class QuestStateOperationsTests var deps = ResolveDependencies(factory); // Act - var result = QuestStateOperations.TryMarkComplete( - PlayerId, - "prototype_quest_missing", - deps.QuestRegistry, - deps.ProgressStore, - TimeProvider.System); + var result = TryMarkComplete(deps, "prototype_quest_missing"); // Assert Assert.False(result.Success); @@ -265,12 +253,7 @@ public sealed class QuestStateOperationsTests var deps = ResolveDependencies(factory); var timeProvider = TimeProvider.System; Assert.True(TryAccept(deps, GatherQuestId).Success); - Assert.True(QuestStateOperations.TryMarkComplete( - PlayerId, - GatherQuestId, - deps.QuestRegistry, - deps.ProgressStore, - timeProvider).Success); + Assert.True(TryMarkComplete(deps, GatherQuestId).Success); // Act var result = TryAdvanceStep(deps, GatherQuestId, 1); @@ -280,28 +263,83 @@ public sealed class QuestStateOperationsTests Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode); } + [Fact] + public async Task TryMarkComplete_ShouldDeliverGatherIntroBundle_WhenFirstCompletion() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + Assert.True(TryAccept(deps, GatherQuestId).Success); + + // Act + var result = TryMarkComplete(deps, GatherQuestId); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent)); + Assert.Equal(25, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); + Assert.Single(deliveryEvent.GrantedSkillXp); + Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId); + Assert.Equal(25, deliveryEvent.GrantedSkillXp[0].Amount); + } + + [Fact] + public async Task TryMarkComplete_ShouldNotDoubleGrant_WhenIdempotentReplay() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + Assert.True(TryAccept(deps, GatherQuestId).Success); + Assert.True(TryMarkComplete(deps, GatherQuestId).Success); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var firstEvent)); + + // Act + var replay = TryMarkComplete(deps, GatherQuestId); + + // Assert + Assert.True(replay.Success); + Assert.Equal(25, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var replayEvent)); + Assert.Equal(firstEvent.IdempotencyKey, replayEvent.IdempotencyKey); + Assert.Equal(firstEvent.DeliveredAt, replayEvent.DeliveredAt); + } + + [Fact] + public async Task TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + AcceptAndComplete(deps, GatherQuestId); + AcceptAndComplete(deps, RefineQuestId); + AcceptAndComplete(deps, CombatQuestId); + Assert.True(TryAccept(deps, ChainQuestId).Success); + FillBag(deps); + + // Act + var result = TryMarkComplete(deps, ChainQuestId); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode); + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress)); + Assert.Equal(QuestProgressStatus.Active, progress.Status); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out _)); + } + [Fact] public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); - var timeProvider = TimeProvider.System; Assert.True(TryAccept(deps, GatherQuestId).Success); // Act - var first = QuestStateOperations.TryMarkComplete( - PlayerId, - GatherQuestId, - deps.QuestRegistry, - deps.ProgressStore, - timeProvider); - var second = QuestStateOperations.TryMarkComplete( - PlayerId, - GatherQuestId, - deps.QuestRegistry, - deps.ProgressStore, - timeProvider); + var first = TryMarkComplete(deps, GatherQuestId); + var second = TryMarkComplete(deps, GatherQuestId); // Assert Assert.True(first.Success); @@ -319,15 +357,9 @@ public sealed class QuestStateOperationsTests // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); - var timeProvider = TimeProvider.System; // Act - var result = QuestStateOperations.TryMarkComplete( - PlayerId, - GatherQuestId, - deps.QuestRegistry, - deps.ProgressStore, - timeProvider); + var result = TryMarkComplete(deps, GatherQuestId); // Assert Assert.False(result.Success); @@ -343,12 +375,7 @@ public sealed class QuestStateOperationsTests // Act var accept = TryAccept(deps, GatherQuestId); - var complete = QuestStateOperations.TryMarkComplete( - PlayerId, - GatherQuestId, - deps.QuestRegistry, - deps.ProgressStore, - TimeProvider.System); + var complete = TryMarkComplete(deps, GatherQuestId); // Assert Assert.True(accept.Success); @@ -380,16 +407,41 @@ public sealed class QuestStateOperationsTests Assert.True(canWrite); } + private static void FillBag(QuestTestDependencies 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 void AcceptAndComplete(QuestTestDependencies deps, string questId) { Assert.True(TryAccept(deps, questId).Success); - Assert.True(QuestStateOperations.TryMarkComplete( + Assert.True(TryMarkComplete(deps, questId).Success); + } + + private static QuestStateOperationResult TryMarkComplete(QuestTestDependencies deps, string questId) => + QuestStateOperations.TryMarkComplete( PlayerId, questId, deps.QuestRegistry, deps.ProgressStore, - deps.TimeProvider).Success); - } + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.TimeProvider); private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) => TryAccept(deps, PlayerId, questId); @@ -402,6 +454,12 @@ public sealed class QuestStateOperationsTests deps.ProgressStore, deps.InventoryStore, deps.ItemRegistry, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) => @@ -413,6 +471,12 @@ public sealed class QuestStateOperationsTests deps.ProgressStore, deps.InventoryStore, deps.ItemRegistry, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, deps.TimeProvider); private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) @@ -423,6 +487,12 @@ public sealed class QuestStateOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -431,5 +501,11 @@ public sealed class QuestStateOperationsTests IPlayerQuestStateStore ProgressStore, IPlayerInventoryStore InventoryStore, IItemDefinitionRegistry ItemRegistry, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore SkillProgressionStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 14d7ec7..207f356 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -3,9 +3,12 @@ using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.World; @@ -81,6 +84,12 @@ public static class AbilityCastApi IPlayerInventoryStore inventoryStore, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore questProgressStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, ILoggerFactory loggerFactory, TimeProvider clock) => { @@ -282,6 +291,12 @@ public static class AbilityCastApi inventoryStore, questRegistry, questProgressStore, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, clock, loggerFactory.CreateLogger(nameof(EncounterCombatWiring))); diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs index c7e6193..4d181db 100644 --- a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs +++ b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs @@ -1,6 +1,7 @@ using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Crafting; @@ -30,6 +31,8 @@ public static class CraftOperations PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { if (quantity <= 0) @@ -165,6 +168,12 @@ public static class CraftOperations progressStore, inventoryStore, itemRegistry, + skillRegistry, + xpStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); return new CraftResult( diff --git a/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs b/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs index 3fd8482..b3023e4 100644 --- a/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs +++ b/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs @@ -2,6 +2,7 @@ using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Crafting; @@ -18,7 +19,8 @@ public static class PlayerCraftApi IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry, - IPlayerQuestStateStore progressStore, TimeProvider timeProvider) => + IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => { if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion) { @@ -50,6 +52,8 @@ public static class PlayerCraftApi perkUnlockEngine, questRegistry, progressStore, + perkStore, + deliveryStore, timeProvider); if (!result.Success && diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs index 3b8152b..7266b3e 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -1,5 +1,8 @@ using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Encounters; @@ -26,6 +29,12 @@ public static class EncounterCombatWiring IPlayerInventoryStore inventoryStore, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore questProgressStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -84,6 +93,12 @@ public static class EncounterCombatWiring inventoryStore, questRegistry, questProgressStore, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); if (!completion.Success) diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs index e100d21..cb2118b 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -1,5 +1,8 @@ using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Encounters; @@ -29,6 +32,12 @@ public static class EncounterCompletionOperations IPlayerInventoryStore inventoryStore, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore questProgressStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) || @@ -145,6 +154,12 @@ public static class EncounterCompletionOperations questProgressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); return new EncounterCompletionResult( diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs index 308a664..f12436b 100644 --- a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs +++ b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs @@ -1,6 +1,7 @@ using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Gathering; @@ -31,6 +32,8 @@ public static class GatherOperations IResourceNodeInstanceStore instanceStore, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { var key = ResourceNodeInstanceIds.Normalize(interactableId); @@ -140,6 +143,12 @@ public static class GatherOperations progressStore, inventoryStore, itemRegistry, + skillRegistry, + xpStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); return new GatherResult( diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs index d111e19..d559a00 100644 --- a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs @@ -3,6 +3,7 @@ using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.World; @@ -23,7 +24,8 @@ public static class InteractionApi IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry, - IPlayerQuestStateStore progressStore, TimeProvider timeProvider) => + IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => { if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion) { @@ -91,6 +93,8 @@ public static class InteractionApi nodeInstanceStore, questRegistry, progressStore, + perkStore, + deliveryStore, timeProvider); if (!gather.Success) diff --git a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs index 99de8bb..8c6c0b7 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs @@ -1,5 +1,8 @@ using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Quests; @@ -13,7 +16,9 @@ public static class QuestAcceptApi (string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, - TimeProvider timeProvider) => + ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => { if (body is not null && body.SchemaVersion != 0 && @@ -35,6 +40,12 @@ public static class QuestAcceptApi progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); return Results.Json(MapResponse(result)); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs index 1113ab1..527faf7 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs @@ -1,4 +1,7 @@ using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Quests; @@ -19,6 +22,12 @@ public static class QuestObjectiveWiring IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -59,6 +68,12 @@ public static class QuestObjectiveWiring progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); } catch (Exception ex) @@ -76,6 +91,12 @@ public static class QuestObjectiveWiring IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -116,6 +137,12 @@ public static class QuestObjectiveWiring progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); } catch (Exception ex) @@ -132,6 +159,12 @@ public static class QuestObjectiveWiring IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -171,6 +204,12 @@ public static class QuestObjectiveWiring progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); } catch (Exception ex) @@ -209,6 +248,12 @@ public static class QuestObjectiveWiring IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -227,6 +272,12 @@ public static class QuestObjectiveWiring progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); } catch (Exception ex) @@ -286,6 +337,12 @@ public static class QuestObjectiveWiring IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++) @@ -316,6 +373,14 @@ public static class QuestObjectiveWiring questId, questRegistry, progressStore, + itemRegistry, + inventoryStore, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); return; } @@ -328,6 +393,12 @@ public static class QuestObjectiveWiring progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); if (!advance.Success) @@ -343,6 +414,12 @@ public static class QuestObjectiveWiring IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { foreach (var definition in questRegistry.GetDefinitionsInIdOrder()) @@ -356,6 +433,12 @@ public static class QuestObjectiveWiring progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); } } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs index 2c9bed6..c91f20f 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -1,5 +1,8 @@ using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Quests; @@ -16,7 +19,10 @@ public static class QuestProgressApi "/game/players/{id}/quest-progress", (string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, - IItemDefinitionRegistry itemRegistry, TimeProvider timeProvider, ILogger? logger) => + IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger? logger) => { var trimmedId = id.Trim(); if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) @@ -30,6 +36,12 @@ public static class QuestProgressApi progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider, logger); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs index 7dac722..658de66 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs @@ -1,4 +1,7 @@ using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Quests; @@ -20,6 +23,12 @@ public static class QuestStateOperations IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || @@ -73,6 +82,12 @@ public static class QuestStateOperations progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed)) @@ -97,6 +112,12 @@ public static class QuestStateOperations IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || @@ -146,6 +167,12 @@ public static class QuestStateOperations progressStore, inventoryStore, itemRegistry, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, timeProvider); if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed)) @@ -160,17 +187,26 @@ public static class QuestStateOperations } /// - /// Marks a quest complete. Idempotent: replays return success with the existing snapshot. + /// Delivers then marks complete (NEO-128). + /// Idempotent: replays return success with the existing snapshot without re-delivering. /// public static QuestStateOperationResult TryMarkComplete( string playerId, string questId, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) { if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || - !questRegistry.TryGetDefinition(normalizedQuestId, out _)) + !questRegistry.TryGetDefinition(normalizedQuestId, out var definition)) { return Deny(QuestStateReasonCodes.UnknownQuest); } @@ -190,6 +226,25 @@ public static class QuestStateOperations return Deny(QuestStateReasonCodes.NotActive); } + var delivery = RewardRouterOperations.TryDeliverQuestCompletion( + playerId, + normalizedQuestId, + definition.CompletionRewardBundle, + itemRegistry, + inventoryStore, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, + timeProvider); + + if (!delivery.Success) + { + return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidQuestId, progress); + } + if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot)) { // --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_complete` --- diff --git a/server/README.md b/server/README.md index 8b46293..802942a 100644 --- a/server/README.md +++ b/server/README.md @@ -183,7 +183,7 @@ Completed rows cannot regress to active without a reset API (none in prototype). |--------|------| | **`TryAccept`** | Validates quest id + **`prerequisiteQuestIds`** (each prerequisite must be **completed**), then activates at step 0. | | **`TryAdvanceStep`** | Requires active row; **`newStepIndex`** must be greater than current and less than step count from **`QuestDefRow`**. | -| **`TryMarkComplete`** | Marks active quest complete. **Idempotent:** second call returns **`success: true`** with unchanged snapshot (unlike encounter completion). | +| **`TryMarkComplete`** | **NEO-128:** delivers **`completionRewardBundle`** via **`RewardRouterOperations`** (deliver-then-mark), then marks active quest complete. **Idempotent:** when already **completed**, returns **`success: true`** without calling the router; replay after first-time complete also skips re-delivery. On delivery deny, quest stays **active** and **`reasonCode`** passthroughs router codes (e.g. **`inventory_full`**). | **Reason codes:** @@ -297,7 +297,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp | **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. | | **`progression_store_missing`** | Skill progression store could not apply XP delta. | -Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). Quest-state wiring lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-implementation-plan.md). +Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md). ## Encounter definitions (NEO-103) From f3b443896885e12e869f945d79c62744cb1c9b4b Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:25:45 -0400 Subject: [PATCH 3/4] NEO-128: add code review for quest-complete reward wiring. --- docs/reviews/2026-06-07-NEO-128.md | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/reviews/2026-06-07-NEO-128.md diff --git a/docs/reviews/2026-06-07-NEO-128.md b/docs/reviews/2026-06-07-NEO-128.md new file mode 100644 index 0000000..bd6f125 --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-128.md @@ -0,0 +1,60 @@ +# Code review — NEO-128 (E7M2-05) + +**Date:** 2026-06-07 +**Scope:** Branch `NEO-128-e7m2-quest-complete-reward-wiring` vs `origin/main` — commits `7abe7dc` … `da99769` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** — deliver-then-mark wiring in `QuestStateOperations.TryMarkComplete`, idempotent skip, deny passthrough, and dependency threading through objective wiring and upstream ops match the adopted plan and NEO-105 precedent; tests cover the primary AC paths; risk is low (server-internal until NEO-129 HTTP projection). + +## Summary + +NEO-128 wires **`RewardRouterOperations.TryDeliverQuestCompletion`** into **`QuestStateOperations.TryMarkComplete`** on the first-time active→completed path: bundle delivery runs before **`IPlayerQuestStateStore.TryMarkComplete`**, idempotent **`completed`** replay returns early without calling the router, and delivery denies passthrough router **`reasonCode`** values while leaving the quest **active**. Reward-router dependencies are threaded through **`QuestObjectiveWiring`**, **`GatherOperations`**, **`CraftOperations`**, **`EncounterCompletionOperations`**, **`QuestProgressApi`**, **`QuestAcceptApi`**, **`AbilityCastApi`**, and related call sites so wiring-driven completion exercises the same path. Five new/extended AAA integration tests lock gather-intro bundle delivery, idempotent replay (direct and wiring), and inventory-full deny on operator-chain setup; compile-fix updates propagate the expanded signatures across quest/gather/craft/encounter test harnesses. Docs (plan reconciliation, E7.M2 anchor, alignment register, backlog AC, `server/README.md`) are updated. A minimal Bruno health smoke was added (host boot check only). + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-128-implementation-plan.md` | **Matches** — kickoff deliver-then-mark and idempotent skip adopted; reconciliation accurate; AC checked. | +| `docs/plans/E7M2-prototype-backlog.md` (E7M2-05) | **Matches** — AC checkboxes complete; Bruno listed as optional in backlog (health-only smoke landed). | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-128 implementation anchor added. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-05 quest-state wiring; row correctly stays **Planned** until NEO-132 capstone. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required. | +| `server/README.md` | **Matches** — `TryMarkComplete` table row and reward-router cross-ref document deliver-then-mark, idempotent skip, deny passthrough. | +| Full-stack epic decomposition | **N/A** — E7M2-05 is server engine; client counterpart NEO-131 correctly deferred (blocked by NEO-129). | + +**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate. + +## Blocking issues + +None. + +## Suggestions + +1. **Operator-chain happy-path delivery assertions** — The plan lists integration coverage for “operator-chain → item + skill grants.” `TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull` exercises chain quest at the operations layer (deny only). `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet` completes via wiring but does not assert **`IRewardDeliveryStore`**, **`survey_drone_kit`**, or salvage +50. NEO-127 covers router apply in isolation; extending the wiring or operations happy-path test would lock end-to-end **`TryMarkComplete`** + item grant for the only bundle with **`itemGrants`**. + +2. **Inventory-full via wiring path** — Deny is tested via direct **`TryMarkComplete`** after manual chain setup. Optional: one wiring-driven test (e.g. fill bag then complete terminal **`inventory_has_item`** step) to prove **`QuestObjectiveWiring`** best-effort ignore does not mask the deny for primary ops while quest stays **active** for retry. + +## Nits + +- Nit: Kickoff adopted “skip Bruno” for bundle verification, but a **`GET /health`** smoke was added under `bruno/neon-sprawl-server/quest-progress/` with docs pointing to dotnet test for bundle asserts. Harmless and consistent with backlog “optional” wording; consider a one-line plan note if authors want kickoff vs backlog Bruno policy explicit. + +- Nit: **`TryCompleteStepIfSatisfied`** discards **`TryMarkComplete`** return value (by design per plan — wiring best-effort). Inventory-full deny therefore does not surface on gather/craft/encounter envelopes; acceptable for prototype, worth remembering for NEO-131 client surfacing. + +- Nit: Delivery succeeds then **`TryMarkComplete`** store write fails — orphan store record self-heals via **`already_delivered`** on retry (plan deferred). No action required for prototype. + +- Nit: Expanded parameter lists on **`TryAccept`** / **`TryAdvanceStep`** mirror NEO-118 threading precedent; a future facade/DI record could reduce call-site churn but is explicitly out of scope. + +## Verification + +```bash +dotnet test server/NeonSprawl.Server.Tests --filter "QuestStateOperationsTests|QuestObjectiveWiringTests" +dotnet test server/NeonSprawl.Server.Tests --filter "FullyQualifiedName~Quests|FullyQualifiedName~GatherOperations|FullyQualifiedName~CraftOperations|FullyQualifiedName~EncounterCompletion|FullyQualifiedName~EncounterCombatWiring" +``` + +All 30 quest-state/wiring tests and 141 related tests pass (verified 2026-06-07). + +Optional before merge: + +- Boot dev server; run Bruno `GET health (quest complete reward wiring NEO-128)` or `curl /health` — no new DI registration beyond existing reward stores. From 28e4a70846b761b6b6e3fb8117e087a088c2539b Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:27:10 -0400 Subject: [PATCH 4/4] =?UTF-8?q?NEO-128:=20address=20review=20=E2=80=94=20o?= =?UTF-8?q?perator-chain=20delivery=20and=20wiring=20deny=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/NEO-128-implementation-plan.md | 4 +- docs/reviews/2026-06-07-NEO-128.md | 8 +- .../Game/Quests/QuestObjectiveWiringTests.cs | 74 +++++++++++++++++++ .../Game/Quests/QuestStateOperationsTests.cs | 45 +++++++++++ 4 files changed, 125 insertions(+), 6 deletions(-) diff --git a/docs/plans/NEO-128-implementation-plan.md b/docs/plans/NEO-128-implementation-plan.md index b540c4c..758c350 100644 --- a/docs/plans/NEO-128-implementation-plan.md +++ b/docs/plans/NEO-128-implementation-plan.md @@ -19,7 +19,7 @@ | Topic | Question | Agent recommendation | Answer | |--------|----------|----------------------|--------| | **Delivery vs mark ordering** | When should bundle apply run relative to `TryMarkComplete`? | **Deliver bundle first, then mark complete** — E7M2 “transactional equivalent” to fail-closed; mirrors NEO-105 (grants before completion mark); avoids new `TryRevertCompletion` store API. | **Adopted** — deliver-then-mark | -| **Bruno smoke** | Add Bruno requests for quest-complete reward delivery? | **Skip** — server-internal wiring only; no HTTP DTO changes (NEO-129); automated integration tests cover AC; Bruno optional per backlog because verification is store/inventory/skill assertions, not a new route. | **Adopted** — skip Bruno | +| **Bruno smoke** | Add Bruno requests for quest-complete reward delivery? | **Skip** — server-internal wiring only; no HTTP DTO changes (NEO-129); automated integration tests cover AC; Bruno optional per backlog because verification is store/inventory/skill assertions, not a new route. | **Adopted** — skip Bruno (functional smoke); **`GET /health`** Bruno added only for pre-commit hook when `*Api.cs` DI signatures changed — bundle asserts remain in `dotnet test`. | **Additional defaults (no kickoff question — settled by backlog / landed code):** @@ -58,7 +58,7 @@ - **Operations:** `QuestStateOperations.TryMarkComplete` — deliver-then-mark via `RewardRouterOperations`; idempotent `completed` skip; deny passthrough from `RewardDeliveryReasonCodes`. - **Wiring:** `QuestObjectiveWiring` + `GatherOperations` / `CraftOperations` / `EncounterCompletionOperations` / quest HTTP APIs thread reward-router dependencies. -- **Tests:** `QuestStateOperationsTests` — bundle delivery, idempotent replay, inventory-full deny; `QuestObjectiveWiringTests` — gather-intro wiring + replay no double-grant. +- **Tests:** `QuestStateOperationsTests` — gather + operator-chain bundle delivery, idempotent replay, inventory-full deny; `QuestObjectiveWiringTests` — gather-intro wiring + replay no double-grant; operator-chain wiring happy path (`survey_drone_kit` + salvage +50); wiring inventory-full stay-active on terminal advance. - **Docs:** `server/README.md`; E7.M2 module anchor + alignment register updated. ## Technical approach diff --git a/docs/reviews/2026-06-07-NEO-128.md b/docs/reviews/2026-06-07-NEO-128.md index bd6f125..d5de0ad 100644 --- a/docs/reviews/2026-06-07-NEO-128.md +++ b/docs/reviews/2026-06-07-NEO-128.md @@ -32,13 +32,13 @@ None. ## Suggestions -1. **Operator-chain happy-path delivery assertions** — The plan lists integration coverage for “operator-chain → item + skill grants.” `TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull` exercises chain quest at the operations layer (deny only). `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet` completes via wiring but does not assert **`IRewardDeliveryStore`**, **`survey_drone_kit`**, or salvage +50. NEO-127 covers router apply in isolation; extending the wiring or operations happy-path test would lock end-to-end **`TryMarkComplete`** + item grant for the only bundle with **`itemGrants`**. +1. ~~**Operator-chain happy-path delivery assertions** — The plan lists integration coverage for “operator-chain → item + skill grants.” `TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull` exercises chain quest at the operations layer (deny only). `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet` completes via wiring but does not assert **`IRewardDeliveryStore`**, **`survey_drone_kit`**, or salvage +50. NEO-127 covers router apply in isolation; extending the wiring or operations happy-path test would lock end-to-end **`TryMarkComplete`** + item grant for the only bundle with **`itemGrants`**.~~ **Done.** Added `TryMarkComplete_ShouldDeliverOperatorChainBundle_WhenFirstCompletion`; extended `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet` with store, item, and skill XP asserts. -2. **Inventory-full via wiring path** — Deny is tested via direct **`TryMarkComplete`** after manual chain setup. Optional: one wiring-driven test (e.g. fill bag then complete terminal **`inventory_has_item`** step) to prove **`QuestObjectiveWiring`** best-effort ignore does not mask the deny for primary ops while quest stays **active** for retry. +2. ~~**Inventory-full via wiring path** — Deny is tested via direct **`TryMarkComplete`** after manual chain setup. Optional: one wiring-driven test (e.g. fill bag then complete terminal **`inventory_has_item`** step) to prove **`QuestObjectiveWiring`** best-effort ignore does not mask the deny for primary ops while quest stays **active** for retry.~~ **Done.** Added `ChainQuest_ShouldStayActive_WhenBagFullOnTerminalWiringComplete` (terminal advance via `TryAdvanceStep` while bag full; quest stays **active**, no store record). ## Nits -- Nit: Kickoff adopted “skip Bruno” for bundle verification, but a **`GET /health`** smoke was added under `bruno/neon-sprawl-server/quest-progress/` with docs pointing to dotnet test for bundle asserts. Harmless and consistent with backlog “optional” wording; consider a one-line plan note if authors want kickoff vs backlog Bruno policy explicit. +- ~~Nit: Kickoff adopted “skip Bruno” for bundle verification, but a **`GET /health`** smoke was added under `bruno/neon-sprawl-server/quest-progress/` with docs pointing to dotnet test for bundle asserts. Harmless and consistent with backlog “optional” wording; consider a one-line plan note if authors want kickoff vs backlog Bruno policy explicit.~~ **Done.** Plan kickoff clarifications note health-only Bruno for pre-commit hook; functional verification via `dotnet test`. - Nit: **`TryCompleteStepIfSatisfied`** discards **`TryMarkComplete`** return value (by design per plan — wiring best-effort). Inventory-full deny therefore does not surface on gather/craft/encounter envelopes; acceptable for prototype, worth remembering for NEO-131 client surfacing. @@ -53,7 +53,7 @@ dotnet test server/NeonSprawl.Server.Tests --filter "QuestStateOperationsTests|Q dotnet test server/NeonSprawl.Server.Tests --filter "FullyQualifiedName~Quests|FullyQualifiedName~GatherOperations|FullyQualifiedName~CraftOperations|FullyQualifiedName~EncounterCompletion|FullyQualifiedName~EncounterCombatWiring" ``` -All 30 quest-state/wiring tests and 141 related tests pass (verified 2026-06-07). +All 32 quest-state/wiring tests and 141 related tests pass (verified 2026-06-07; +2 after review follow-up). Optional before merge: diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs index a412e11..518342d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs @@ -148,12 +148,56 @@ public sealed class QuestObjectiveWiringTests // Act — step 2: field stim craft SeedStack(deps, "refined_plate_stock", 2); SeedStack(deps, "scrap_metal_bulk", 1); + var salvageXpBeforeComplete = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var kitBefore = CountBagItem(beforeInventory!, "survey_drone_kit"); Assert.True(TryCraft(deps, "make_field_stim_mk0").Success); // Assert — terminal inventory_has_item (contract_handoff_token from combat intro) Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var completed)); Assert.Equal(QuestProgressStatus.Completed, completed.Status); Assert.Equal(3, completed.CurrentStepIndex); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out var deliveryEvent)); + Assert.Single(deliveryEvent.GrantedItems); + Assert.Equal("survey_drone_kit", deliveryEvent.GrantedItems[0].ItemId); + Assert.Single(deliveryEvent.GrantedSkillXp); + Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount); + Assert.Equal(salvageXpBeforeComplete + 50, deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.Equal(kitBefore + 1, CountBagItem(afterInventory!, "survey_drone_kit")); + } + + [Fact] + public async Task ChainQuest_ShouldStayActive_WhenBagFullOnTerminalWiringComplete() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + AcceptAndMarkComplete(deps, GatherQuestId); + AcceptAndMarkComplete(deps, RefineQuestId); + AcceptAndMarkComplete(deps, CombatQuestId); + SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1); + Assert.True(TryAccept(deps, ChainQuestId).Success); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _)); + Assert.True(TryAdvanceStep(deps, ChainQuestId, 1).Success); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _)); + Assert.True(TryAdvanceStep(deps, ChainQuestId, 2).Success); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _)); + FillRemainingBagSlots(deps); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var kitBefore = CountBagItem(beforeInventory!, "survey_drone_kit"); + + // Act + var advance = TryAdvanceStep(deps, ChainQuestId, 3); + + // Assert + Assert.True(advance.Success); + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress)); + Assert.Equal(QuestProgressStatus.Active, progress.Status); + Assert.Equal(3, progress.CurrentStepIndex); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out _)); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.Equal(kitBefore, CountBagItem(afterInventory!, "survey_drone_kit")); } [Fact] @@ -335,6 +379,36 @@ public sealed class QuestObjectiveWiringTests deps.DeliveryStore, deps.TimeProvider); + 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 static void FillRemainingBagSlots(QuestWiringTestDependencies deps) + { + deps.InventoryStore.TryGetSnapshot(PlayerId, out var snapshot); + var emptySlots = snapshot!.BagSlots.Count(static slot => slot.IsEmpty); + for (var i = 0; i < emptySlots; i++) + { + var add = PlayerInventoryOperations.TryAddStack( + PlayerId, + "survey_drone_kit", + quantity: 1, + deps.ItemRegistry, + deps.InventoryStore); + Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind); + } + } + private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) { var outcome = PlayerInventoryOperations.TryAddStack( diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs index cb3bd4a..3d39198 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs @@ -306,6 +306,37 @@ public sealed class QuestStateOperationsTests Assert.Equal(firstEvent.DeliveredAt, replayEvent.DeliveredAt); } + [Fact] + public async Task TryMarkComplete_ShouldDeliverOperatorChainBundle_WhenFirstCompletion() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + AcceptAndComplete(deps, GatherQuestId); + AcceptAndComplete(deps, RefineQuestId); + AcceptAndComplete(deps, CombatQuestId); + Assert.True(TryAccept(deps, ChainQuestId).Success); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = TryMarkComplete(deps, ChainQuestId); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out var deliveryEvent)); + Assert.Single(deliveryEvent.GrantedItems); + Assert.Equal("survey_drone_kit", deliveryEvent.GrantedItems[0].ItemId); + Assert.Equal(1, deliveryEvent.GrantedItems[0].Quantity); + Assert.Single(deliveryEvent.GrantedSkillXp); + Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId); + Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.Equal(CountBagItem(beforeInventory!, "survey_drone_kit") + 1, CountBagItem(afterInventory!, "survey_drone_kit")); + Assert.Equal(100, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); + } + [Fact] public async Task TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull() { @@ -407,6 +438,20 @@ public sealed class QuestStateOperationsTests Assert.True(canWrite); } + 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 static void FillBag(QuestTestDependencies deps) { for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)