diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru index 99d328d..44fe54b 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -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 { diff --git a/bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru b/bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru index ee244de..4fe7cf6 100644 --- a/bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru +++ b/bruno/neon-sprawl-server/craft/Post craft gather refine make spine.bru @@ -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 { diff --git a/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru index 87f9581..626818a 100644 --- a/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru +++ b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru @@ -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 { diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md index 94f7392..c0a8d83 100644 --- a/docs/decomposition/modules/E7_M1_QuestStateMachine.md +++ b/docs/decomposition/modules/E7_M1_QuestStateMachine.md @@ -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. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 5ce3c40..e226078 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.M1 | Planned | **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). **Backlog decomposed (objective wiring from E7M1-07+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until E7M1-07+ objective wiring and client capstone. | [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 | +| E7.M1 | Planned | **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). **Backlog decomposed (HTTP + client from E7M1-08+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until E7M1-08+ HTTP and client capstone. | [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/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index c19f60f..3660ea5 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -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 | diff --git a/docs/manual-qa/NEO-118.md b/docs/manual-qa/NEO-118.md new file mode 100644 index 0000000..2592dd6 --- /dev/null +++ b/docs/manual-qa/NEO-118.md @@ -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`. diff --git a/docs/plans/E7M1-prototype-backlog.md b/docs/plans/E7M1-prototype-backlog.md index 1956779..a848b48 100644 --- a/docs/plans/E7M1-prototype-backlog.md +++ b/docs/plans/E7M1-prototype-backlog.md @@ -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. diff --git a/docs/plans/NEO-118-implementation-plan.md b/docs/plans/NEO-118-implementation-plan.md index db4343c..e24ff9c 100644 --- a/docs/plans/NEO-118-implementation-plan.md +++ b/docs/plans/NEO-118-implementation-plan.md @@ -55,10 +55,17 @@ ## Acceptance criteria checklist -- [ ] 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. + +## 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 diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs index 51d2129..8dabf13 100644 --- a/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } 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 { diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs index deeda5d..7bfdb4d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } 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); } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs index 8d3183c..82529c5 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } 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 { diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs index 6094dbb..c400b44 100644 --- a/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } 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 { diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index 7a1ae90..6e95f37 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -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(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs new file mode 100644 index 0000000..9be4aaa --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs @@ -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(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + 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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs index d4e66a9..b017539 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs @@ -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(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + TimeProvider.System); } + + private readonly record struct QuestTestDependencies( + IQuestDefinitionRegistry QuestRegistry, + IPlayerQuestStateStore ProgressStore, + IPlayerInventoryStore InventoryStore, + IItemDefinitionRegistry ItemRegistry, + TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 2765880..14d7ec7 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -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))); diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs index ad7e117..c7e6193 100644 --- a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs +++ b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs @@ -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, diff --git a/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs b/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs index 2a6dbc5..3fd8482 100644 --- a/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs +++ b/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs @@ -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) || diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs index 4d8c06e..3b8152b 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -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) diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs index c725eff..e100d21 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -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, diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs index 8605bf9..308a664 100644 --- a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs +++ b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs @@ -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, diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs index 35f502e..d111e19 100644 --- a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs @@ -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) { diff --git a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveKinds.cs b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveKinds.cs new file mode 100644 index 0000000..b5cef59 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveKinds.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Quest objective kind strings aligned with quest-objective-def.schema.json (NEO-118). +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"; +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs new file mode 100644 index 0000000..09da6da --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs @@ -0,0 +1,482 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Quests; + +/// +/// NEO-118: best-effort quest objective evaluation on gather/craft/encounter success and +/// snapshot passes (accept / step advance / inventory mutations). +/// +public static class QuestObjectiveWiring +{ + private const int MaxStepCompletionIterations = 8; + + /// Credits objectives after a successful gather grant. + 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); + } + } + + /// Credits objectives after a successful craft. + 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); + } + } + + /// Credits objectives after encounter completion. + 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); + } + } + + /// Re-evaluates counters for every active quest. + 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); + } + } + + /// Re-evaluates on the active step for one quest. + 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 _); + } + } + + /// Advances or completes the quest when the active step's objectives are satisfied. + 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 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 counters, string objectiveId) => + counters.TryGetValue(objectiveId, out var count) ? count : 0; +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs index 4fd4bf3..f21d092 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Items; + namespace NeonSprawl.Server.Game.Quests; /// @@ -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); } diff --git a/server/README.md b/server/README.md index b658051..d5957ec 100644 --- a/server/README.md +++ b/server/README.md @@ -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)