Merge pull request #157 from ViPro-Technologies/NEO-118-quest-objective-wiring

NEO-118: Wire quest objectives to gather, craft, and encounter events
pull/158/head
VinPropane 2026-06-07 11:28:14 -04:00 committed by GitHub
commit cbd6706e41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1367 additions and 144 deletions

View File

@ -5,7 +5,7 @@ meta {
}
docs {
NEO-82 spine: slot 1 (slot 0 may be cooling from happy seq 22). Pre-request locks prototype_npc_melee, casts pulse four times (3.2s between), fifth request expects target_defeated deny.
NEO-82 spine: slot 1 (slot 0 may be cooling from happy seq 22). Pre-request locks prototype_npc_melee, casts pulse four times (3.2s between), fifth request expects target_defeated deny. NEO-118: encounter completion grant path runs quest objective wiring (active encounter_complete quests).
}
script:pre-request {

View File

@ -5,7 +5,7 @@ meta {
}
docs {
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. No inventory POST shortcuts for spine materials.
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. NEO-118: best-effort quest objective wiring on craft success (active craft_recipe quests). No inventory POST shortcuts for spine materials.
}
script:pre-request {

View File

@ -5,7 +5,7 @@ meta {
}
docs {
NEO-63: successful interact on `resource_node` kind runs GatherOperations — inventory grant (scrap_metal_bulk), salvage XP (activity, 10), and depletion. Pre-request moves in range of alpha anchor (12, -6). Optional: GET inventory / skill-progression after this request.
NEO-63: successful interact on `resource_node` kind runs GatherOperations — inventory grant (scrap_metal_bulk), salvage XP (activity, 10), and depletion. NEO-118: best-effort quest objective wiring on gather success (active gather_item quests). Pre-request moves in range of alpha anchor (12, -6). Optional: GET inventory / skill-progression after this request.
}
script:pre-request {

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M1 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Prototype |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; objective wiring from **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; HTTP + client from **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
## Purpose
@ -84,7 +84,9 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
**Player quest state store (NEO-116):** **`IPlayerQuestStateStore`** — in-memory + Postgres (`V008__player_quest_progress.sql`) per-player rows keyed by `(playerId, questId)`; implicit `not_started` when missing ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116).
**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). HTTP read/accept deferred to E7M1-08/09; objective wiring in E7M1-07.
**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). HTTP read/accept deferred to E7M1-08/09.
**Quest objective wiring (NEO-118):** **`QuestObjectiveWiring`** — gather/craft/encounter success hooks + **`inventory_has_item`** on accept/step advance/inventory mutations; auto advance/complete via **`QuestStateOperations`** ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118).
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.

File diff suppressed because one or more lines are too long

View File

@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|---|---|---|---|---|---|
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); register row stays **Planned** until E7M1-07+ objective wiring and client capstone.
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); **E7M1-07 / NEO-118** **`QuestObjectiveWiring`** landed ([NEO-118 plan](../../plans/NEO-118-implementation-plan.md)); register row stays **Planned** until E7M1-08+ HTTP and client capstone.
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |

View File

@ -0,0 +1,22 @@
# Manual QA — NEO-118 (E7M1-07 quest objective wiring)
Server-side verification until [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) exposes **`GET /game/players/{id}/quest-progress`**.
## Prerequisites
- Server running locally (`dotnet run` from `server/NeonSprawl.Server`)
- Dev player **`dev-local-1`** with position row (default Development seed)
- Automated suite green: `dotnet test server/NeonSprawl.Server.Tests --filter FullyQualifiedName~QuestObjectiveWiringTests`
## Checklist
1. [ ] **Gather intro** — Accept is not HTTP yet; use unit test `GatherIntro_ShouldComplete_WhenScrapGatheredThreeTimes` or temporary debug accept in REPL/tests. After accept + three alpha-node gathers, progress row for **`prototype_quest_gather_intro`** is **completed** (store test / wiring test).
2. [ ] **Refine intro** — With gather intro complete, accept **`prototype_quest_refine_intro`**, craft **`refine_scrap_standard`** via `POST /game/players/dev-local-1/craft`; quest completes (wiring test `RefineIntro_ShouldComplete_WhenRefineScrapStandardCraftSucceeds`).
3. [ ] **Combat intro** — Accept **`prototype_quest_combat_intro`**, complete **`prototype_combat_pocket`** via ability cast (NEO-106 path); quest completes (wiring test `CombatIntro_ShouldComplete_WhenEncounterCompletes`).
4. [ ] **Operator chain** — With all three intros complete, accept **`prototype_quest_operator_chain`**, perform gather → refine → stim crafts per catalog; terminal **`inventory_has_item`** **`contract_handoff_token`** completes chain (wiring test `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet`).
5. [ ] **Regression** — Full server test suite passes (674+ tests).
## Notes
- Player-visible HUD verification lands in [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122).
- Capstone Godot loop: [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) / `docs/manual-qa/NEO-123.md`.

View File

@ -243,10 +243,10 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
**Acceptance criteria**
- [ ] Active **`prototype_quest_gather_intro`** advances when **`scrap_metal_bulk`** gather succeeds.
- [ ] Active **`prototype_quest_refine_intro`** advances on **`refine_scrap_standard`** craft success.
- [ ] Active **`prototype_quest_combat_intro`** advances when **`prototype_combat_pocket`** completes.
- [ ] Chain quest steps advance in order across mixed objective kinds.
- [x] Active **`prototype_quest_gather_intro`** advances when **`scrap_metal_bulk`** gather succeeds.
- [x] Active **`prototype_quest_refine_intro`** advances on **`refine_scrap_standard`** craft success.
- [x] Active **`prototype_quest_combat_intro`** advances when **`prototype_combat_pocket`** completes.
- [x] Chain quest steps advance in order across mixed objective kinds.
**Client counterpart:** progress visible via [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) GET HUD after mutations.

View File

@ -0,0 +1,167 @@
# NEO-118 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-118 |
| **Title** | E7M1-07: Quest objective wiring (gather, craft, encounter) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-118/e7m1-07-quest-objective-wiring-gather-craft-encounter |
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-07** |
| **Branch** | `NEO-118-quest-objective-wiring` |
| **Precursor** | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) — `QuestStateOperations` accept/advance/complete (**Done** on `main`) |
| **Pattern** | [NEO-106](NEO-106-implementation-plan.md) — `EncounterCombatWiring` best-effort side effect on success paths; [NEO-117](NEO-117-implementation-plan.md) — `QuestStateOperations` for advance/complete |
| **Blocks** | [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) — GET quest-progress; [NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) — telemetry hook sites |
| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — quest progress HUD reads GET snapshot after gather/craft/encounter mutations (E7M1-11). Bruno-only progress verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **`inventory_has_item` triggers** | When to evaluate beyond gather/craft/encounter grant paths? | Inventory mutations **+ quest accept + after step advance** — backlog “post-grant or on polling hook from accept”; chain step 4 token often pre-held from combat intro | **Adopted** — user confirmed |
| **Accept / step-advance re-eval scope** | Which objective kinds to reconcile on accept/step entry? | **`inventory_has_item` only**; `gather_item` / `craft_recipe` / `encounter_complete` stay event-driven | **Adopted** — user confirmed |
**Additional defaults (no kickoff question — settled by backlog / NEO-116):**
- Counter accumulation uses **event quantity** (gather yield qty, craft batch qty); cap stored counter at objective `quantity` for satisfaction.
- **All active quests** matching the event are credited (e.g. gather intro + chain step 1 on same gather).
- Wiring is **best-effort** — quest evaluation failures must not fail gather/craft/encounter (mirror `EncounterCombatWiring`).
- `encounter_complete` objectives treat satisfaction as counter **≥ 1** (no quantity field in catalog).
- Step completion: when **all** objectives on the current step are satisfied → `TryAdvanceStep` to next index, or `TryMarkComplete` on terminal step; after advance, run **`inventory_has_item`** snapshot pass for the new step.
## Goal, scope, and out-of-scope
**Goal:** Advance active quest steps when server-side gather, craft, and encounter events succeed; evaluate **`inventory_has_item`** from inventory snapshots on grant paths, quest accept, and step advance.
**In scope (from Linear + [E7M1-07](E7M1-prototype-backlog.md#e7m1-07--quest-objective-wiring-gather-craft-encounter)):**
- **`QuestObjectiveWiring`** (static) — evaluate active quests against incoming events; update counters via store; auto advance/complete via **`QuestStateOperations`**.
- Hook from **`GatherOperations.TryGather`** success — **`gather_item`** objectives (item id + quantity delta).
- Hook from **`CraftOperations.TryCraft`** success — **`craft_recipe`** objectives (recipe id; quantity = craft batch size).
- Hook from **`EncounterCompletionOperations.TryCompleteAndGrant`** success — **`encounter_complete`** objectives (encounter id).
- **`inventory_has_item`** — inventory snapshot read after successful inventory mutations; **`TryProcessInventoryHasItem`** on quest accept and after step advance (current/new step only).
- Counter accumulation for quantity objectives; auto **`TryAdvanceStep`** / **`TryMarkComplete`** when step objectives satisfied.
- Integration-style tests (AAA): accept quest → simulate gather/craft/encounter → progress row advances through chain.
- `server/README.md` — objective wiring subsection + hook table.
- `docs/manual-qa/NEO-118.md` — Bruno/server-side verification checklist (no HTTP quest-progress yet).
**Out of scope (from Linear + backlog):**
- Client-side objective tracking; item consume on turn-in; E7.M2 reward bundles.
- HTTP **`GET …/quest-progress`** ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)) and accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)).
- Telemetry hook comments ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) / E7M1-10).
- Retroactive **`encounter_complete`** credit on accept (encounter already done before quest accept).
- GET polling for **`inventory_has_item`** (deferred to NEO-119 if needed).
## Acceptance criteria checklist
- [x] Active **`prototype_quest_gather_intro`** advances when **`scrap_metal_bulk`** gather succeeds.
- [x] Active **`prototype_quest_refine_intro`** advances on **`refine_scrap_standard`** craft success.
- [x] Active **`prototype_quest_combat_intro`** advances when **`prototype_combat_pocket`** completes.
- [x] Chain quest steps advance in order across mixed objective kinds.
## Implementation reconciliation (shipped)
- **Types:** `QuestObjectiveKinds`, `QuestObjectiveWiring` (`TryProcessGatherSuccess`, `TryProcessCraftSuccess`, `TryProcessEncounterCompleteSuccess`, `TryProcessInventoryHasItemForQuest`, `TryCompleteStepIfSatisfied`).
- **Hooks:** `GatherOperations`, `CraftOperations`, `EncounterCompletionOperations`, `QuestStateOperations.TryAccept` / `TryAdvanceStep`.
- **Tests:** `QuestObjectiveWiringTests` (6 AAA cases); existing gather/craft/encounter/state tests updated for new parameters.
- **Docs:** `server/README.md` objective wiring section; `docs/manual-qa/NEO-118.md`.
## Technical approach
1. **`QuestObjectiveKinds`** — string constants: `gather_item`, `craft_recipe`, `encounter_complete`, `inventory_has_item` (mirror schema enum).
2. **`QuestObjectiveWiring`** (`Game/Quests/`)
- **`TryProcessGatherSuccess(playerId, itemId, quantityGranted, …)`** — scan all catalog quest ids; for each **active** row, match current-step **`gather_item`** objectives by `itemId`; bump counter by `quantityGranted` (capped at objective `quantity`).
- **`TryProcessCraftSuccess(playerId, recipeId, batchQuantity, …)`** — match **`craft_recipe`** by `recipeId`; increment counter by `batchQuantity` (capped).
- **`TryProcessEncounterCompleteSuccess(playerId, encounterId, …)`** — match **`encounter_complete`**; set counter to **1** when encounter id matches.
- **`TryProcessInventoryHasItemForPlayer(playerId, …)`** / **`TryProcessInventoryHasItemForQuest(playerId, questId, …)`** — read **`IPlayerInventoryStore.TryGetSnapshot`**; for current-step **`inventory_has_item`** objectives, set counter to `min(bagQuantity, objective.quantity)`; used after grants, on accept, after step advance.
- Shared private **`TryEvaluateActiveQuests`** / **`TryCompleteStepIfSatisfied`**: when every objective on the current step meets threshold → if last step → **`QuestStateOperations.TryMarkComplete`**; else → **`TryAdvanceStep(currentIndex + 1)`** then **`TryProcessInventoryHasItemForQuest`** for the advanced quest (chain terminal token case).
- Best-effort: swallow/log at debug; never throw to callers.
3. **Inventory quantity helper**
- Private static on wiring (or small internal helper): sum **`PlayerInventorySnapshot`** bag slot quantities for an `itemId` (same semantics as craft/encounter tests).
4. **Call sites**
- **`GatherOperations.TryGather`** — before successful return, call **`TryProcessGatherSuccess`** with `yield.ItemId` / `yield.Quantity` (add quest + inventory deps to method signature).
- **`CraftOperations.TryCraft`** — before successful return, call **`TryProcessCraftSuccess`** with normalized recipe id and batch `quantity`.
- **`EncounterCompletionOperations.TryCompleteAndGrant`** — after successful grant + completion mark, call **`TryProcessEncounterCompleteSuccess`** with normalized encounter id.
- **`QuestStateOperations.TryAccept`** — after successful activation, call **`TryProcessInventoryHasItemForQuest`** (add `IPlayerInventoryStore` + `TimeProvider` params).
- **`QuestStateOperations.TryAdvanceStep`** — after successful advance, call **`TryProcessInventoryHasItemForQuest`** for the same quest id.
- Propagate new dependencies through **`InteractionApi`**, **`PlayerCraftApi`**, **`EncounterCombatWiring`**, and **`AbilityCastApi`** DI resolves (factory tests already resolve full host).
5. **Tests** (`QuestObjectiveWiringTests.cs`)
- Use **`InMemoryWebApplicationFactory`** + real catalog/registry/store.
- **Gather intro:** accept → N gathers until counter ≥ 3 → quest **completed** (single-step).
- **Refine intro:** prerequisite setup → accept → craft **`refine_scrap_standard`** → **completed**.
- **Combat intro:** accept → **`TryProcessEncounterCompleteSuccess`** for **`prototype_combat_pocket`** → **completed**.
- **Chain:** accept chain → gather 5 scrap → advance to step 1 → craft refine → step 2 → craft stim → step 3 → seed **`contract_handoff_token`** in inventory → advance triggers step 4 **`inventory_has_item`** → **completed**.
- **Parallel credit:** gather intro + chain step 0 both active; one gather credits both counters appropriately.
- Update **`QuestStateOperationsTests`** signatures if accept/advance gain inventory/time params (pass real store from factory).
6. **Docs**
- `server/README.md` — replace “Objective wiring is E7M1-07” stub with wiring summary, hook table, **`inventory_has_item`** trigger list.
- Alignment register / module doc / E7M1 backlog checkboxes on story land.
### Wiring flow (gather success)
```mermaid
flowchart TD
G[GatherOperations.TryGather success] --> W[QuestObjectiveWiring.TryProcessGatherSuccess]
W --> A{For each active quest}
A --> M{Current step has matching gather_item?}
M -->|yes| C[TryUpdateObjectiveCounter capped]
M -->|no| A
C --> S{All objectives satisfied?}
S -->|terminal step| MC[QuestStateOperations.TryMarkComplete]
S -->|more steps| AS[QuestStateOperations.TryAdvanceStep]
AS --> I[TryProcessInventoryHasItemForQuest]
S -->|no| A
```
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveKinds.cs` | Stable objective kind string constants. |
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Event + inventory snapshot evaluation; step auto-advance/complete. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | AAA integration-style tests for all four prototype quests + chain. |
| `docs/manual-qa/NEO-118.md` | Server-side Bruno/unit verification checklist until NEO-119 HTTP. |
| `docs/plans/NEO-118-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Call quest wiring on successful gather; add quest/inventory/time deps. |
| `server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs` | Call quest wiring on successful craft. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Call quest wiring after successful completion + grants. |
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Invoke `inventory_has_item` pass after accept/advance; add inventory + time deps. |
| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | Pass quest wiring deps into `GatherOperations.TryGather`. |
| `server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs` | Pass quest wiring deps into `CraftOperations.TryCraft`. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Pass quest wiring deps through to `EncounterCompletionOperations`. |
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Pass quest wiring deps into `EncounterCombatWiring` (if signature changes). |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Update `TryAccept` / `TryAdvanceStep` call sites for new parameters. |
| `server/README.md` | Document objective wiring hooks and `inventory_has_item` triggers. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — NEO-118 wiring landed (on story complete). |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | NEO-118 wiring bullet + README link (on story complete). |
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-118 (on story complete). |
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-07 acceptance checkboxes (on story complete). |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `QuestObjectiveWiringTests.cs` | **Gather intro:** accept → gather **`scrap_metal_bulk`** until complete. **Refine intro:** prerequisites met → accept → craft **`refine_scrap_standard`** → complete. **Combat intro:** accept → encounter complete event → complete. **Chain:** four-step mixed kinds including **`inventory_has_item`** terminal after token present. **Parallel:** gather intro + chain step 0 simultaneous credit. **Inventory on advance:** token pre-seeded → step advance to terminal → auto-complete without new grant. |
| `QuestStateOperationsTests.cs` | Regression — existing accept/advance/complete cases with updated method signatures (inventory store + time provider from factory). |
| Existing gather/craft/encounter tests | Smoke regression — no behavior change when no active quests (full suite in CI). |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **`QuestStateOperations` signature growth** | Add `IPlayerInventoryStore` + `TimeProvider` to accept/advance for wiring; keep complete unchanged | **adopted** |
| **Terminal step auto-complete without explicit advance call** | Wiring calls `TryMarkComplete` when last step objectives satisfied (operations layer idempotent) | **adopted** |
| **Multi-objective steps** | Implement generic all-objectives-satisfied check even though prototype steps are single-objective | **adopted** |
| **Encounter done before combat quest accept** | Out of scope; manual QA accepts before combat | **deferred** |
| **NEO-117 review nits reused ids** | Use `PrototypeE7M1QuestCatalogRules` constants in wiring tests | **adopted** |

View File

@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -31,7 +32,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -72,7 +76,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -104,7 +111,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -138,7 +148,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -169,7 +182,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -201,7 +217,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -230,7 +249,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
@ -255,7 +277,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
@ -283,7 +308,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -313,7 +341,10 @@ public sealed class CraftOperationsTests
deps.SkillRegistry,
failingXpStore,
deps.LevelCurve,
deps.PerkUnlockEngine);
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -359,7 +390,9 @@ public sealed class CraftOperationsTests
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>());
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -394,7 +427,9 @@ public sealed class CraftOperationsTests
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore XpStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine);
PerkUnlockEngine PerkUnlockEngine,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore);
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
{

View File

@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
@ -34,6 +35,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -63,6 +66,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -96,6 +101,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -127,6 +134,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -151,6 +160,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
@ -167,6 +178,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -197,6 +210,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -236,6 +251,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -272,6 +289,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -292,6 +311,8 @@ public sealed class EncounterCombatWiringTests
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
}
@ -340,7 +361,9 @@ public sealed class EncounterCombatWiringTests
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -364,5 +387,7 @@ public sealed class EncounterCombatWiringTests
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore);
}

View File

@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
@ -34,6 +35,8 @@ public sealed class EncounterCompletionOperationsTests
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
timeProvider);
// Assert
@ -75,6 +78,8 @@ public sealed class EncounterCompletionOperationsTests
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
@ -89,6 +94,8 @@ public sealed class EncounterCompletionOperationsTests
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
timeProvider);
// Assert
@ -145,6 +152,8 @@ public sealed class EncounterCompletionOperationsTests
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -177,6 +186,8 @@ public sealed class EncounterCompletionOperationsTests
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -211,6 +222,8 @@ public sealed class EncounterCompletionOperationsTests
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -243,6 +256,8 @@ public sealed class EncounterCompletionOperationsTests
deps.EventStore,
deps.ItemRegistry,
inventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -273,6 +288,8 @@ public sealed class EncounterCompletionOperationsTests
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
TimeProvider.System);
// Assert
@ -333,7 +350,9 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -371,7 +390,9 @@ public sealed class EncounterCompletionOperationsTests
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore EventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{

View File

@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -32,7 +33,10 @@ public sealed class GatherOperationsTests
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore);
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
var salvageXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
@ -83,7 +87,10 @@ public sealed class GatherOperationsTests
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore);
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
var salvageXpAfter = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
@ -116,7 +123,10 @@ public sealed class GatherOperationsTests
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore);
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
Assert.True(gather.Success);
}
@ -134,7 +144,10 @@ public sealed class GatherOperationsTests
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore);
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
var salvageXpAfter = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
@ -166,7 +179,10 @@ public sealed class GatherOperationsTests
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore);
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _);
@ -196,7 +212,10 @@ public sealed class GatherOperationsTests
failingXpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore);
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
deps.InstanceStore.TryGetRemainingGathers(AlphaId, out var nodeSnapshot);
@ -227,7 +246,10 @@ public sealed class GatherOperationsTests
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore);
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -249,7 +271,9 @@ public sealed class GatherOperationsTests
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IResourceNodeInstanceStore>());
services.GetRequiredService<IResourceNodeInstanceStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>());
}
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
@ -285,7 +309,9 @@ public sealed class GatherOperationsTests
IPlayerSkillProgressionStore XpStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IResourceNodeInstanceStore InstanceStore);
IResourceNodeInstanceStore InstanceStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore);
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
{

View File

@ -13,6 +13,7 @@ using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using Npgsql;
@ -69,6 +70,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
d.ServiceType == typeof(IPlayerInventoryStore) ||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
@ -89,6 +91,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
});

View File

@ -0,0 +1,332 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public sealed class QuestObjectiveWiringTests
{
private const string PlayerId = "dev-local-1";
private const string AlphaNodeId = "prototype_resource_node_alpha";
private const string DeltaNodeId = "prototype_urban_bulk_delta";
private const string CombatEncounterId = "prototype_combat_pocket";
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId;
private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
[Fact]
public async Task GatherIntro_ShouldComplete_WhenScrapGatheredThreeTimes()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act
for (var i = 0; i < 3; i++)
{
Assert.True(TryGather(deps, AlphaNodeId).Success);
}
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
}
[Fact]
public async Task RefineIntro_ShouldComplete_WhenRefineScrapStandardCraftSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndMarkComplete(deps, GatherQuestId);
Assert.True(TryAccept(deps, RefineQuestId).Success);
SeedStack(deps, "scrap_metal_bulk", 5);
// Act
var craft = TryCraft(deps, "refine_scrap_standard");
// Assert
Assert.True(craft.Success);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, RefineQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
}
[Fact]
public async Task CombatIntro_ShouldComplete_WhenEncounterCompletes()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, CombatQuestId).Success);
// Act
QuestObjectiveWiring.TryProcessEncounterCompleteSuccess(
PlayerId,
CombatEncounterId,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider);
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, CombatQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
}
[Fact]
public async Task ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndMarkComplete(deps, GatherQuestId);
AcceptAndMarkComplete(deps, RefineQuestId);
CompleteCombatEncounter(deps);
Assert.True(TryAccept(deps, ChainQuestId).Success);
// Act — step 0: gather 5 scrap (delta node yield)
Assert.True(TryGather(deps, DeltaNodeId).Success);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var afterGather));
Assert.Equal(1, afterGather.CurrentStepIndex);
// Act — step 1: refine craft
SeedStack(deps, "scrap_metal_bulk", 5);
Assert.True(TryCraft(deps, "refine_scrap_standard").Success);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var afterRefine));
Assert.Equal(2, afterRefine.CurrentStepIndex);
// Act — step 2: field stim craft
SeedStack(deps, "refined_plate_stock", 2);
SeedStack(deps, "scrap_metal_bulk", 1);
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);
}
[Fact]
public async Task GatherIntro_ShouldCompleteInOneGather_WhenNodeYieldExceedsObjectiveQuantity()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act
Assert.True(TryGather(deps, DeltaNodeId).Success);
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
Assert.Equal(3, progress.ObjectiveCounters[GatherObjectiveId]);
}
[Fact]
public async Task InventoryHasItem_ShouldCompleteChainTerminalStep_WhenTokenAlreadyHeldOnStepAdvance()
{
// 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(QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider).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(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);
// Assert
Assert.True(advance.Success);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
}
private static void CompleteCombatEncounter(QuestWiringTestDependencies deps)
{
Assert.True(TryAccept(deps, CombatQuestId).Success);
DefeatAllRequiredTargets(deps);
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
CombatEncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.EncounterProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider);
Assert.True(result.Success);
}
private static void DefeatAllRequiredTargets(QuestWiringTestDependencies deps)
{
foreach (var npcId in new[] { "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" })
{
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
npcId,
deps.EncounterRegistry,
deps.EncounterProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
npcId,
deps.EncounterRegistry,
deps.EncounterProgressStore,
deps.CompletionStore);
}
}
private static QuestStateOperationResult TryAccept(QuestWiringTestDependencies deps, string questId) =>
QuestStateOperations.TryAccept(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
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);
}
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
GatherOperations.TryGather(
PlayerId,
nodeId,
deps.NodeRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
CraftOperations.TryCraft(
PlayerId,
recipeId,
quantity: 1,
deps.RecipeRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider);
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
{
var outcome = PlayerInventoryOperations.TryAddStack(
PlayerId,
itemId,
quantity,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
}
private static QuestWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new QuestWiringTestDependencies(
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IResourceNodeDefinitionRegistry>(),
services.GetRequiredService<IResourceNodeInstanceStore>(),
services.GetRequiredService<IRecipeDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
TimeProvider.System);
}
private readonly record struct QuestWiringTestDependencies(
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore,
IPlayerInventoryStore InventoryStore,
IItemDefinitionRegistry ItemRegistry,
IResourceNodeDefinitionRegistry NodeRegistry,
IResourceNodeInstanceStore InstanceStore,
IRecipeDefinitionRegistry RecipeRegistry,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore XpStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore EncounterProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore,
TimeProvider TimeProvider);
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
@ -24,11 +25,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAccept(deps, GatherQuestId);
// Assert
Assert.True(result.Success);
@ -46,11 +43,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
RefineQuestId,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAccept(deps, RefineQuestId);
// Assert
Assert.False(result.Success);
@ -65,7 +58,7 @@ public sealed class QuestStateOperationsTests
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
@ -74,11 +67,7 @@ public sealed class QuestStateOperationsTests
timeProvider).Success);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
RefineQuestId,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAccept(deps, RefineQuestId);
// Assert
Assert.True(result.Success);
@ -94,11 +83,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
"prototype_quest_missing",
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAccept(deps, "prototype_quest_missing");
// Assert
Assert.False(result.Success);
@ -111,14 +96,10 @@ public sealed class QuestStateOperationsTests
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAccept(deps, GatherQuestId);
// Assert
Assert.False(result.Success);
@ -134,7 +115,7 @@ public sealed class QuestStateOperationsTests
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
@ -143,11 +124,7 @@ public sealed class QuestStateOperationsTests
timeProvider).Success);
// Act
var result = QuestStateOperations.TryAccept(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAccept(deps, GatherQuestId);
// Assert
Assert.False(result.Success);
@ -163,11 +140,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
UnknownPlayerId,
RefineQuestId,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAccept(deps, UnknownPlayerId, RefineQuestId);
// Assert
Assert.False(result.Success);
@ -182,11 +155,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
UnknownPlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAccept(deps, UnknownPlayerId, GatherQuestId);
// Assert
Assert.False(result.Success);
@ -200,19 +169,14 @@ public sealed class QuestStateOperationsTests
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
AcceptAndComplete(deps, GatherQuestId, timeProvider);
AcceptAndComplete(deps, RefineQuestId, timeProvider);
AcceptAndComplete(deps, CombatQuestId, timeProvider);
Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
AcceptAndComplete(deps, GatherQuestId);
AcceptAndComplete(deps, RefineQuestId);
AcceptAndComplete(deps, CombatQuestId);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainObjectiveId, 5, out _));
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAdvanceStep(deps, ChainQuestId, 1);
// Assert
Assert.True(result.Success);
@ -230,18 +194,13 @@ public sealed class QuestStateOperationsTests
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
AcceptAndComplete(deps, GatherQuestId, timeProvider);
AcceptAndComplete(deps, RefineQuestId, timeProvider);
AcceptAndComplete(deps, CombatQuestId, timeProvider);
Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
AcceptAndComplete(deps, GatherQuestId);
AcceptAndComplete(deps, RefineQuestId);
AcceptAndComplete(deps, CombatQuestId);
Assert.True(TryAccept(deps, ChainQuestId).Success);
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
newStepIndex,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAdvanceStep(deps, ChainQuestId, newStepIndex);
// Assert
Assert.False(result.Success);
@ -256,12 +215,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
"prototype_quest_missing",
1,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAdvanceStep(deps, "prototype_quest_missing", 1);
// Assert
Assert.False(result.Success);
@ -296,12 +250,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
GatherQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAdvanceStep(deps, GatherQuestId, 1);
// Assert
Assert.False(result.Success);
@ -315,7 +264,7 @@ public sealed class QuestStateOperationsTests
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
@ -324,12 +273,7 @@ public sealed class QuestStateOperationsTests
timeProvider).Success);
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
GatherQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore);
var result = TryAdvanceStep(deps, GatherQuestId, 1);
// Assert
Assert.False(result.Success);
@ -343,7 +287,7 @@ public sealed class QuestStateOperationsTests
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act
var first = QuestStateOperations.TryMarkComplete(
@ -398,7 +342,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var accept = QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore);
var accept = TryAccept(deps, GatherQuestId);
var complete = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
@ -436,26 +380,56 @@ public sealed class QuestStateOperationsTests
Assert.True(canWrite);
}
private static void AcceptAndComplete(
(IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) deps,
string questId,
TimeProvider timeProvider)
private static void AcceptAndComplete(QuestTestDependencies deps, string questId)
{
Assert.True(QuestStateOperations.TryAccept(PlayerId, questId, deps.QuestRegistry, deps.ProgressStore).Success);
Assert.True(TryAccept(deps, questId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
deps.TimeProvider).Success);
}
private static (IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) ResolveDependencies(
InMemoryWebApplicationFactory factory)
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) =>
TryAccept(deps, PlayerId, questId);
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string playerId, string questId) =>
QuestStateOperations.TryAccept(
playerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
QuestStateOperations.TryAdvanceStep(
PlayerId,
questId,
newStepIndex,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider);
private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return (
return new QuestTestDependencies(
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>());
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
TimeProvider.System);
}
private readonly record struct QuestTestDependencies(
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore,
IPlayerInventoryStore InventoryStore,
IItemDefinitionRegistry ItemRegistry,
TimeProvider TimeProvider);
}

View File

@ -5,6 +5,7 @@ using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World;
@ -78,6 +79,8 @@ public static class AbilityCastApi
IEncounterCompleteEventStore encounterCompleteEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
{
@ -277,6 +280,8 @@ public static class AbilityCastApi
encounterCompleteEventStore,
itemRegistry,
inventoryStore,
questRegistry,
questProgressStore,
clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));

View File

@ -1,5 +1,6 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Crafting;
@ -26,7 +27,10 @@ public static class CraftOperations
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine)
PerkUnlockEngine perkUnlockEngine,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
TimeProvider timeProvider)
{
if (quantity <= 0)
{
@ -153,6 +157,16 @@ public static class CraftOperations
// (skillId, amount, sourceKind). No ingest or ILogger here (comments-only). Successful output grants also pass
// through PlayerInventoryOperations (`item_created` hook, NEO-56). PlayerCraftApi delegates here (no duplicate hooks).
QuestObjectiveWiring.TryProcessCraftSuccess(
playerId,
recipeKey,
quantity,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
return new CraftResult(
Success: true,
ReasonCode: null,

View File

@ -1,6 +1,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.Skills;
namespace NeonSprawl.Server.Game.Crafting;
@ -16,7 +17,8 @@ public static class PlayerCraftApi
IRecipeDefinitionRegistry recipeRegistry, IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine) =>
PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion)
{
@ -45,7 +47,10 @@ public static class PlayerCraftApi
skillRegistry,
xpStore,
levelCurve,
perkUnlockEngine);
perkUnlockEngine,
questRegistry,
progressStore,
timeProvider);
if (!result.Success &&
(string.Equals(result.ReasonCode, CraftReasonCodes.InventoryStoreMissing, StringComparison.Ordinal) ||

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Game.Encounters;
@ -18,11 +19,13 @@ public static class EncounterCombatWiring
bool targetDefeated,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterProgressStore encounterProgressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
@ -32,7 +35,7 @@ public static class EncounterCombatWiring
playerId,
targetNpcInstanceId,
encounterRegistry,
progressStore,
encounterProgressStore,
completionStore);
}
@ -45,7 +48,7 @@ public static class EncounterCombatWiring
playerId,
targetNpcInstanceId,
encounterRegistry,
progressStore,
encounterProgressStore,
completionStore))
{
return;
@ -64,7 +67,7 @@ public static class EncounterCombatWiring
playerId,
encounterId,
encounterRegistry,
progressStore))
encounterProgressStore))
{
return;
}
@ -74,11 +77,13 @@ public static class EncounterCombatWiring
encounterId,
encounterRegistry,
rewardTableRegistry,
progressStore,
encounterProgressStore,
completionStore,
completeEventStore,
itemRegistry,
inventoryStore,
questRegistry,
questProgressStore,
timeProvider);
if (!completion.Success)

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Game.Encounters;
@ -26,6 +27,8 @@ public static class EncounterCompletionOperations
IEncounterCompleteEventStore completeEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore,
TimeProvider timeProvider)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
@ -135,6 +138,15 @@ public static class EncounterCompletionOperations
// Planned payload: playerId, encounterId, rewardTableId, grantedItems[] (EncounterGrantApplied batch).
// Inventory layer still emits item_created per stack via PlayerInventoryOperations (NEO-56).
QuestObjectiveWiring.TryProcessEncounterCompleteSuccess(
normalizedPlayerId,
normalizedEncounterId,
questRegistry,
questProgressStore,
inventoryStore,
itemRegistry,
timeProvider);
return new EncounterCompletionResult(
Success: true,
ReasonCode: null,

View File

@ -1,5 +1,6 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Gathering;
@ -27,7 +28,10 @@ public static class GatherOperations
IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IResourceNodeInstanceStore instanceStore)
IResourceNodeInstanceStore instanceStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
TimeProvider timeProvider)
{
var key = ResourceNodeInstanceIds.Normalize(interactableId);
if (key.Length == 0 ||
@ -128,6 +132,16 @@ public static class GatherOperations
// (last gatherer), maxGathers from definition when wiring E9.M1.
}
QuestObjectiveWiring.TryProcessGatherSuccess(
playerId,
yield.ItemId,
yield.Quantity,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
return new GatherResult(
Success: true,
ReasonCode: null,

View File

@ -2,6 +2,7 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World;
@ -21,7 +22,8 @@ public static class InteractionApi
IResourceNodeDefinitionRegistry nodeRegistry, IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
IResourceNodeInstanceStore nodeInstanceStore) =>
IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
{
@ -86,7 +88,10 @@ public static class InteractionApi
xpStore,
levelCurve,
perkUnlockEngine,
nodeInstanceStore);
nodeInstanceStore,
questRegistry,
progressStore,
timeProvider);
if (!gather.Success)
{

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Quest objective kind strings aligned with <c>quest-objective-def.schema.json</c> (NEO-118).</summary>
public static class QuestObjectiveKinds
{
public const string GatherItem = "gather_item";
public const string CraftRecipe = "craft_recipe";
public const string EncounterComplete = "encounter_complete";
public const string InventoryHasItem = "inventory_has_item";
}

View File

@ -0,0 +1,482 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// NEO-118: best-effort quest objective evaluation on gather/craft/encounter success and
/// <see cref="QuestObjectiveKinds.InventoryHasItem"/> snapshot passes (accept / step advance / inventory mutations).
/// </summary>
public static class QuestObjectiveWiring
{
private const int MaxStepCompletionIterations = 8;
/// <summary>Credits <see cref="QuestObjectiveKinds.GatherItem"/> objectives after a successful gather grant.</summary>
public static void TryProcessGatherSuccess(
string playerId,
string itemId,
int quantityGranted,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider,
ILogger? logger = null)
{
if (quantityGranted <= 0 || itemId.Trim().Length == 0)
{
return;
}
try
{
var normalizedItemId = itemId.Trim();
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
{
if (!TryGetActiveProgress(playerId, definition.Id, progressStore, out var progress))
{
continue;
}
ApplyGatherItemObjectives(
playerId,
definition,
progress,
normalizedItemId,
quantityGranted,
progressStore);
}
RefreshInventoryHasItemCountersForPlayer(
playerId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
TryCompleteAllActiveQuests(
playerId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
}
catch (Exception ex)
{
logger?.LogDebug(ex, "Quest gather wiring skipped for player {PlayerId}", playerId);
}
}
/// <summary>Credits <see cref="QuestObjectiveKinds.CraftRecipe"/> objectives after a successful craft.</summary>
public static void TryProcessCraftSuccess(
string playerId,
string recipeId,
int batchQuantity,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider,
ILogger? logger = null)
{
if (batchQuantity <= 0 || recipeId.Trim().Length == 0)
{
return;
}
try
{
var normalizedRecipeId = recipeId.Trim();
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
{
if (!TryGetActiveProgress(playerId, definition.Id, progressStore, out var progress))
{
continue;
}
ApplyCraftRecipeObjectives(
playerId,
definition,
progress,
normalizedRecipeId,
batchQuantity,
progressStore);
}
RefreshInventoryHasItemCountersForPlayer(
playerId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
TryCompleteAllActiveQuests(
playerId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
}
catch (Exception ex)
{
logger?.LogDebug(ex, "Quest craft wiring skipped for player {PlayerId}", playerId);
}
}
/// <summary>Credits <see cref="QuestObjectiveKinds.EncounterComplete"/> objectives after encounter completion.</summary>
public static void TryProcessEncounterCompleteSuccess(
string playerId,
string encounterId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider,
ILogger? logger = null)
{
if (encounterId.Trim().Length == 0)
{
return;
}
try
{
var normalizedEncounterId = encounterId.Trim();
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
{
if (!TryGetActiveProgress(playerId, definition.Id, progressStore, out var progress))
{
continue;
}
ApplyEncounterCompleteObjectives(
playerId,
definition,
progress,
normalizedEncounterId,
progressStore);
}
RefreshInventoryHasItemCountersForPlayer(
playerId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
TryCompleteAllActiveQuests(
playerId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
}
catch (Exception ex)
{
logger?.LogDebug(ex, "Quest encounter wiring skipped for player {PlayerId}", playerId);
}
}
/// <summary>Re-evaluates <see cref="QuestObjectiveKinds.InventoryHasItem"/> counters for every active quest.</summary>
public static void RefreshInventoryHasItemCountersForPlayer(
string playerId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry)
{
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
{
TryProcessInventoryHasItemForQuest(
playerId,
definition.Id,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
}
}
/// <summary>Re-evaluates <see cref="QuestObjectiveKinds.InventoryHasItem"/> on the active step for one quest.</summary>
public static void TryProcessInventoryHasItemForQuest(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry)
{
if (!TryGetActiveProgress(playerId, questId, progressStore, out var progress) ||
!questRegistry.TryGetDefinition(questId, out var definition) ||
!inventoryStore.TryGetSnapshot(playerId, out var inventory))
{
return;
}
var stepIndex = progress.CurrentStepIndex;
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
{
return;
}
foreach (var objective in definition.Steps[stepIndex].Objectives)
{
if (!string.Equals(objective.Kind, QuestObjectiveKinds.InventoryHasItem, StringComparison.Ordinal) ||
objective.ItemId is null)
{
continue;
}
var required = GetRequiredQuantity(objective);
var held = PlayerInventoryOperations.GetHeldQuantity(inventory, objective.ItemId, itemRegistry);
var newCount = Math.Min(held, required);
var current = GetCounter(progress, objective.Id);
if (newCount == current)
{
continue;
}
progressStore.TryUpdateObjectiveCounter(playerId, questId, objective.Id, newCount, out _);
}
}
/// <summary>Advances or completes the quest when the active step's objectives are satisfied.</summary>
public static void TryCompleteStepIfSatisfied(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider)
{
for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++)
{
if (!TryGetActiveProgress(playerId, questId, progressStore, out var progress) ||
!questRegistry.TryGetDefinition(questId, out var definition))
{
return;
}
var stepIndex = progress.CurrentStepIndex;
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
{
return;
}
inventoryStore.TryGetSnapshot(playerId, out var inventory);
var step = definition.Steps[stepIndex];
if (!IsStepSatisfied(step, progress.ObjectiveCounters, inventory, itemRegistry))
{
return;
}
if (stepIndex >= definition.Steps.Count - 1)
{
QuestStateOperations.TryMarkComplete(
playerId,
questId,
questRegistry,
progressStore,
timeProvider);
return;
}
var advance = QuestStateOperations.TryAdvanceStep(
playerId,
questId,
stepIndex + 1,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
if (!advance.Success)
{
return;
}
}
}
private static void TryCompleteAllActiveQuests(
string playerId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider)
{
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
{
if (TryGetActiveProgress(playerId, definition.Id, progressStore, out _))
{
TryCompleteStepIfSatisfied(
playerId,
definition.Id,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
}
}
}
private static void ApplyGatherItemObjectives(
string playerId,
QuestDefRow definition,
QuestStepState progress,
string itemId,
int quantityGranted,
IPlayerQuestStateStore progressStore)
{
var stepIndex = progress.CurrentStepIndex;
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
{
return;
}
foreach (var objective in definition.Steps[stepIndex].Objectives)
{
if (!string.Equals(objective.Kind, QuestObjectiveKinds.GatherItem, StringComparison.Ordinal) ||
objective.ItemId is null ||
!string.Equals(objective.ItemId, itemId, StringComparison.Ordinal))
{
continue;
}
var required = GetRequiredQuantity(objective);
var current = GetCounter(progress, objective.Id);
var newCount = Math.Min(current + quantityGranted, required);
if (newCount == current)
{
continue;
}
progressStore.TryUpdateObjectiveCounter(playerId, definition.Id, objective.Id, newCount, out _);
progressStore.TryGetProgress(playerId, definition.Id, out progress);
}
}
private static void ApplyCraftRecipeObjectives(
string playerId,
QuestDefRow definition,
QuestStepState progress,
string recipeId,
int batchQuantity,
IPlayerQuestStateStore progressStore)
{
var stepIndex = progress.CurrentStepIndex;
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
{
return;
}
foreach (var objective in definition.Steps[stepIndex].Objectives)
{
if (!string.Equals(objective.Kind, QuestObjectiveKinds.CraftRecipe, StringComparison.Ordinal) ||
objective.RecipeId is null ||
!string.Equals(objective.RecipeId, recipeId, StringComparison.Ordinal))
{
continue;
}
var required = GetRequiredQuantity(objective);
var current = GetCounter(progress, objective.Id);
var newCount = Math.Min(current + batchQuantity, required);
if (newCount == current)
{
continue;
}
progressStore.TryUpdateObjectiveCounter(playerId, definition.Id, objective.Id, newCount, out _);
progressStore.TryGetProgress(playerId, definition.Id, out progress);
}
}
private static void ApplyEncounterCompleteObjectives(
string playerId,
QuestDefRow definition,
QuestStepState progress,
string encounterId,
IPlayerQuestStateStore progressStore)
{
var stepIndex = progress.CurrentStepIndex;
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
{
return;
}
foreach (var objective in definition.Steps[stepIndex].Objectives)
{
if (!string.Equals(objective.Kind, QuestObjectiveKinds.EncounterComplete, StringComparison.Ordinal) ||
objective.EncounterId is null ||
!string.Equals(objective.EncounterId, encounterId, StringComparison.Ordinal))
{
continue;
}
if (GetCounter(progress, objective.Id) >= 1)
{
continue;
}
progressStore.TryUpdateObjectiveCounter(playerId, definition.Id, objective.Id, 1, out _);
progressStore.TryGetProgress(playerId, definition.Id, out progress);
}
}
private static bool IsStepSatisfied(
QuestStepDefRow step,
IReadOnlyDictionary<string, int> counters,
PlayerInventorySnapshot inventory,
IItemDefinitionRegistry itemRegistry)
{
foreach (var objective in step.Objectives)
{
var required = GetRequiredQuantity(objective);
if (string.Equals(objective.Kind, QuestObjectiveKinds.InventoryHasItem, StringComparison.Ordinal))
{
if (objective.ItemId is null)
{
return false;
}
var held = PlayerInventoryOperations.GetHeldQuantity(inventory, objective.ItemId, itemRegistry);
if (held < required)
{
return false;
}
continue;
}
if (GetCounter(counters, objective.Id) < required)
{
return false;
}
}
return step.Objectives.Count > 0;
}
private static bool TryGetActiveProgress(
string playerId,
string questId,
IPlayerQuestStateStore progressStore,
out QuestStepState progress) =>
progressStore.TryGetProgress(playerId, questId, out progress!) &&
progress.Status == QuestProgressStatus.Active;
private static int GetRequiredQuantity(QuestObjectiveDefRow objective) =>
objective.Quantity is > 0 ? objective.Quantity.Value : 1;
private static int GetCounter(QuestStepState progress, string objectiveId) =>
GetCounter(progress.ObjectiveCounters, objectiveId);
private static int GetCounter(IReadOnlyDictionary<string, int> counters, string objectiveId) =>
counters.TryGetValue(objectiveId, out var count) ? count : 0;
}

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
@ -13,7 +15,10 @@ public static class QuestStateOperations
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore)
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
@ -47,6 +52,27 @@ public static class QuestStateOperations
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
{
QuestObjectiveWiring.TryProcessInventoryHasItemForQuest(
playerId,
normalizedQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
QuestObjectiveWiring.TryCompleteStepIfSatisfied(
playerId,
normalizedQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
{
return Success(refreshed);
}
return Success(snapshot);
}
@ -61,7 +87,10 @@ public static class QuestStateOperations
string questId,
int newStepIndex,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore)
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
@ -91,6 +120,27 @@ public static class QuestStateOperations
if (progressStore.TryAdvanceStep(playerId, normalizedQuestId, newStepIndex, out var snapshot))
{
QuestObjectiveWiring.TryProcessInventoryHasItemForQuest(
playerId,
normalizedQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
QuestObjectiveWiring.TryCompleteStepIfSatisfied(
playerId,
normalizedQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
{
return Success(refreshed);
}
return Success(snapshot);
}

View File

@ -195,7 +195,18 @@ Completed rows cannot regress to active without a reset API (none in prototype).
| **`not_active`** | Advance or complete when quest was never accepted or is not **active**. |
| **`invalid_step_index`** | Advance when index does not increase or is past the last step. |
Objective wiring (gather/craft/encounter hooks) is E7M1-07 ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)). Plan: [NEO-117 implementation plan](../../docs/plans/NEO-117-implementation-plan.md).
### Quest objective wiring (NEO-118)
**`QuestObjectiveWiring`** (static, best-effort) evaluates active quests on server-side gather/craft/encounter success and on **`inventory_has_item`** snapshot passes. When every objective on the current step is satisfied, wiring calls **`QuestStateOperations.TryAdvanceStep`** or **`TryMarkComplete`** (terminal step). Failures are logged at debug and never fail the primary gather/craft/encounter operation.
| Hook site | Objective kind | Trigger |
|-----------|----------------|---------|
| **`GatherOperations.TryGather`** success | **`gather_item`** | Item id + yield quantity credited to matching active quests; counter capped at objective **`quantity`**. |
| **`CraftOperations.TryCraft`** success | **`craft_recipe`** | Recipe id + batch quantity. |
| **`EncounterCompletionOperations.TryCompleteAndGrant`** success | **`encounter_complete`** | Encounter id (counter → 1). |
| Inventory mutations above + **`QuestStateOperations.TryAccept`** / **`TryAdvanceStep`** | **`inventory_has_item`** | Bag snapshot read; counter set to `min(held, required)`. |
**`inventory_has_item`** is **not** re-evaluated for other objective kinds on accept/step entry (event-driven only for gather/craft/encounter). HTTP **`GET …/quest-progress`** is E7M1-08 ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)). Plan: [NEO-118 implementation plan](../../docs/plans/NEO-118-implementation-plan.md).
## Encounter definitions (NEO-103)