diff --git a/bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru b/bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru new file mode 100644 index 0000000..a9ca7c8 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (quest catalog boot NEO-113) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-113 loads content/quests/*_quests.json at startup (fail-fast). No quest HTTP API in this story — use this request to confirm the host started after catalog validation. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/bruno/neon-sprawl-server/quest-catalog/folder.bru b/bruno/neon-sprawl-server/quest-catalog/folder.bru new file mode 100644 index 0000000..3683531 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: quest-catalog +} diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md index c639563..7cfefd3 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); runtime from **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) → 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**; registry + runtime from **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) → 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 @@ -78,6 +78,8 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del **CI enforcement (NEO-112):** `scripts/validate_content.py` requires **exactly** these four quest ids; objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; chain quest terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01). +**Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). + **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. ## Risks and telemetry diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index fbd0a06..c306776 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). **Backlog decomposed (not started beyond catalog):** 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 **NEO-113** server load lands. | [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). **Backlog decomposed (runtime from E7M1-03+):** 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 **NEO-114** registry + quest runtime land. | [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 45546db..e0177d2 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); register row stays **Planned** until **NEO-113** server load lands. +**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; register row stays **Planned** until **NEO-114** registry + quest runtime land. | 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/plans/E7M1-prototype-backlog.md b/docs/plans/E7M1-prototype-backlog.md index ec395c0..abae91c 100644 --- a/docs/plans/E7M1-prototype-backlog.md +++ b/docs/plans/E7M1-prototype-backlog.md @@ -119,8 +119,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco **Acceptance criteria** -- [ ] Host fails startup on invalid quest JSON (mirror CI rules). -- [ ] Loader tests cover allowlist + cross-ref failures. +- [x] Host fails startup on invalid quest JSON (mirror CI rules). +- [x] Loader tests cover allowlist + cross-ref failures. **Client counterpart:** none (infrastructure-only). diff --git a/docs/plans/NEO-113-implementation-plan.md b/docs/plans/NEO-113-implementation-plan.md new file mode 100644 index 0000000..d093187 --- /dev/null +++ b/docs/plans/NEO-113-implementation-plan.md @@ -0,0 +1,145 @@ +# NEO-113 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-113 | +| **Title** | E7M1-02: Server quest catalog load (fail-fast) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-113/e7m1-02-server-quest-catalog-load-fail-fast | +| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-02** | +| **Branch** | `NEO-113-e7m1-server-quest-catalog-load-fail-fast` | +| **Precursor** | [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) — quest schemas, `prototype_quests.json`, CI gates (**landed on `main`**) | +| **Pattern** | [NEO-101](NEO-101-implementation-plan.md) — row JSON Schema + catalog envelope + in-process C# gates mirroring `scripts/validate_content.py` | +| **Blocks** | [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) — injectable quest definition registry + DI | +| **Client counterpart** | None — server-only catalog load; player-visible work starts at [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) / [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Test scope** | Loader unit tests only, or also `WebApplicationFactory` host boot failure / DI resolve tests? | **Both** — mirror [NEO-101](NEO-101-implementation-plan.md); Linear AC “host fails on invalid quest JSON” is stronger with an integration boot test. | **User:** loader + host boot tests. | +| **NEO-113 vs NEO-114 split** | Ship `IQuestDefinitionRegistry` on this story? | **Strict split** — E7M1-02: loader + catalog singleton + eager fail-fast boot; [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) adds injectable registry (E7M1-03). Same pattern as NEO-101/102. | **Adopted** — backlog out-of-scope; no separate question needed. | +| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` E7M1 gates (NEO-51/101 precedent; no Python on server images). | **Adopted** | +| **Cross-ref at load** | Validate objective `itemId` / `recipeId` / `encounterId` against loaded catalogs? | **Yes** — quest loader takes `knownItemIds`, `knownRecipeIds`, `knownEncounterIds` from resolved singletons; load **after** item, recipe, and encounter catalogs (same order as CI `main()`). | **Adopted** | + +## Goal, scope, and out-of-scope + +**Goal:** Disk → host: startup load of `content/quests/*_quests.json` with CI-parity validation; process refuses to listen when catalogs are missing, malformed, or fail prototype E7M1 gates. + +**In scope (from Linear + [E7M1-02](E7M1-prototype-backlog.md#e7m1-02--server-quest-catalog-load-fail-fast)):** + +- Loader + catalog types under `server/NeonSprawl.Server/Game/Quests/`. +- Fail-fast on malformed files, duplicate ids (quest / step / objective), allowlist mismatch with CI, broken cross-refs (items, recipes, encounters), acyclic prerequisite graph, chain terminal token gate. +- Unit tests (AAA) for loader happy path and failure modes; host boot tests (AAA). +- `server/README.md` section for quest catalog. + +**Out of scope (from Linear + backlog):** + +- Injectable registry interface (**NEO-114** / E7M1-03). +- HTTP projection, player quest state, runtime state machine (E7M1-04+). +- Godot client. + +## Acceptance criteria checklist + +- [x] Host fails startup on invalid quest JSON (mirror CI rules). +- [x] Loader tests cover allowlist + cross-ref failures (and host boot per kickoff). + +## Implementation reconciliation (shipped) + +- **Loaders:** `QuestDefinitionCatalogLoader` + `PrototypeE7M1QuestCatalogRules` under `Game/Quests/` with CI-parity gates. +- **Catalog:** `QuestDefinitionCatalog` singleton; eager resolve in `Program.cs` after encounter catalogs. +- **DI:** `AddQuestDefinitionCatalog` — loads after item, recipe, and encounter catalogs for cross-refs. +- **Config:** `ContentPathsOptions` quest path keys; `InMemoryWebApplicationFactory` pins repo `content/quests`. +- **Tests:** 20 AAA tests in `QuestDefinitionCatalogLoaderTests` (loader + host boot, incl. row schema violation + chain terminal quantity). +- **Docs:** `server/README.md` quest catalog section; alignment register E7.M1 row updated; `E7_M1_QuestStateMachine.md` Status + server-load line. +- **Bruno:** `bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru` (boot smoke; no quest HTTP in this story). + +## Technical approach + +1. **`Game/Quests/` load-time DTOs** — nested rows mirroring catalog JSON: + - `QuestObjectiveDefRow` — `Id`, `Kind`, optional `ItemId`, `Quantity`, `RecipeId`, `EncounterId` (populated per kind). + - `QuestStepDefRow` — `Id`, `DisplayName`, `Objectives`. + - `QuestDefRow` — `Id`, `DisplayName`, `PrerequisiteQuestIds`, `Steps`. + +2. **`QuestDefinitionCatalog`** — immutable snapshot: directory path, `ById`, `DistinctQuestCount`, `CatalogJsonFileCount`; `TryGetQuest(id, out QuestDefRow?)`. + +3. **`QuestDefinitionCatalogLoader.Load`** — parameters: `questsDirectory`, `questDefSchemaPath`, `questStepDefSchemaPath`, `questObjectiveDefSchemaPath`, `knownItemIds`, `knownRecipeIds`, `knownEncounterIds`, `logger`: + - Enumerate `*_quests.json` (top-level only), ordered by path. + - Per file: parse JSON; require `schemaVersion == 1` and top-level `quests` array. + - Register `quest-objective-def.schema.json`, `quest-step-def.schema.json`, then `quest-def.schema.json` in `SchemaRegistry.Global` before row validation (recipe I/O row pattern). + - Per row: JsonSchema.Net validation; track quest `id`; reject duplicate quest `id` across files. + - Post-schema: reject duplicate step `id` within a quest; reject duplicate objective `id` within a quest (across all steps — same as CI). + - Run **`PrototypeE7M1QuestCatalogRules`** gates (sync comment → `PROTOTYPE_E7M1_*` in `validate_content.py`): + - Four-id allowlist gate. + - Prerequisite known-id + acyclic DFS gate. + - Objective cross-ref gate (`gather_item` / `inventory_has_item` → item ids; `craft_recipe` → recipe ids; `encounter_complete` → encounter ids). + - Operator chain terminal step: sole objective `inventory_has_item` **`contract_handoff_token`** ×1. + - Throw **`InvalidOperationException`** with aggregated sorted messages prefixed `Quest catalog validation failed:`. + - Information log: absolute quests directory, quest count, JSON file count. + +4. **`PrototypeE7M1QuestCatalogRules`** — frozen constants + gate helpers: + - `ExpectedQuestIds` — sync → `PROTOTYPE_E7M1_QUEST_IDS`. + - `ChainQuestId`, `ChainTerminalItemId` — sync → `PROTOTYPE_E7M1_CHAIN_QUEST_ID`, `PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID`. + - Cross-ref sets reuse `PrototypeSlice1ItemCatalogRules.ExpectedItemIds`, `PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds`, `PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds` where applicable (or pass-through from loaded catalogs — loader receives live id sets from DI). + +5. **Path resolution** — `QuestCatalogPathResolution`: `TryDiscoverQuestsDirectory`, `ResolveQuestsDirectory`, `ResolveQuestDefSchemaPath`, `ResolveQuestStepDefSchemaPath`, `ResolveQuestObjectiveDefSchemaPath` (ancestor walk from `AppContext.BaseDirectory` for `content/quests`; schema defaults under sibling `content/schemas/`). + +6. **`ContentPathsOptions`** — add `QuestsDirectory`, `QuestDefSchemaPath`, `QuestStepDefSchemaPath`, `QuestObjectiveDefSchemaPath`. + +7. **`QuestCatalogServiceCollectionExtensions.AddQuestDefinitionCatalog`**: + - Factory for `QuestDefinitionCatalog` depends on `ItemDefinitionCatalog`, `RecipeDefinitionCatalog`, `EncounterDefinitionCatalog` (known id sets from `.ById.Keys`). + - **No** `IQuestDefinitionRegistry` yet (NEO-114). + +8. **`Program.cs`** — register extension after encounter catalogs; eager-resolve `QuestDefinitionCatalog` before `Run()`. + +9. **`InMemoryWebApplicationFactory`** — pin `Content:QuestsDirectory` to repo path so existing tests keep booting. + +10. **`appsettings.json`** — optional empty `Content:QuestsDirectory` + schema override keys for documentation. + +11. **Tests** — temp-dir fixtures copying repo schemas; valid JSON from [prototype_quests.json](../../content/quests/prototype_quests.json); negative cases for schema violation, duplicate quest/step/objective id, wrong allowlist id, unknown prerequisite, cyclic prerequisites, unknown item/recipe/encounter id, wrong chain terminal objective, `schemaVersion` ≠ 1, missing schema file; host resolve via DI + `/health`; host fail with empty quests dir. + +12. **Docs** — `server/README.md` quest catalog section (config keys, discovery, fail-fast, load order); `documentation_and_implementation_alignment.md` E7.M1 row note server load landed when complete. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs` | Load-time DTO for a single quest objective row. | +| `server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs` | Load-time DTO for a quest step (objectives array). | +| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Load-time DTO for a single `QuestDef` row. | +| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs` | In-memory quest catalog + `TryGetQuest`. | +| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Disk I/O, schema validation, duplicate id gates, E7M1 rules, logging. | +| `server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs` | Directory/schema discovery for quests. | +| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs` | Frozen quest ids + prerequisite / cross-ref / chain terminal gates (sync → Python). | +| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | `AddQuestDefinitionCatalog` DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Repo `content/quests` + schema discovery for tests. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | AAA loader + host boot tests for quest catalog. | +| `docs/plans/NEO-113-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add quest directory and schema path options under `Content` section. | +| `server/NeonSprawl.Server/Program.cs` | Register quest catalog; eager-resolve after build. | +| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:QuestsDirectory` / schema override keys for documentation. | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin quest content path so test host boots with repo catalog. | +| `server/README.md` | New quest catalog section (config, discovery, fail-fast, load order). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — note NEO-113 server load when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | **Unit:** valid prototype quest catalog loads (4 quests); `quests` not array; schema violation; duplicate quest `id`; duplicate step `id` within quest; duplicate objective `id` within quest; wrong/missing E7M1 quest id; unknown prerequisite id; cyclic `prerequisiteQuestIds`; unknown `itemId` / `recipeId` / `encounterId` in objective; wrong chain terminal objective (kind / item / quantity); `schemaVersion` ≠ 1; missing schema file. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI resolve `QuestDefinitionCatalog` (quest `prototype_quest_gather_intro`); `WebApplicationFactory` with empty quests dir fails startup with actionable message. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Constants drift vs Python** | Comment on `PrototypeE7M1QuestCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_E7M1_*` constants and gate functions. | **adopted** | +| **Load order in DI** | Register quest catalog factory after item, recipe, and encounter singletons; pass `.ById.Keys` as known id sets. | **adopted** | +| **Test cwd / CI** | `InMemoryWebApplicationFactory` must set `Content:QuestsDirectory` explicitly. | **adopted** | +| **Three-schema $ref registration** | Register objective → step → def schemas in `SchemaRegistry.Global` before evaluating quest rows (same as CI `_quest_def_validator`). | **adopted** | +| **Objective id uniqueness scope** | Duplicate objective ids rejected **within quest** (all steps), matching NEO-112 CI gate — not per-step. | **adopted** | diff --git a/docs/reviews/2026-05-31-NEO-113.md b/docs/reviews/2026-05-31-NEO-113.md new file mode 100644 index 0000000..fe4c9d0 --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-113.md @@ -0,0 +1,63 @@ +# Code review — NEO-113 (E7M1-02) + +**Date:** 2026-05-31 +**Scope:** Branch `NEO-113-e7m1-server-quest-catalog-load-fail-fast` vs `df24281` (merge-base on `main`) — commits `cb4761b` … `1044547` +**Base:** `df24281` (main at branch point, post NEO-112 merge) + +## Verdict + +**Approve with nits** + +## Summary + +NEO-113 delivers fail-fast server startup loading for `content/quests/*_quests.json`, mirroring the NEO-101 / NEO-112 pattern: row DTOs, `QuestDefinitionCatalogLoader` with JsonSchema.Net validation, duplicate id gates, and `PrototypeE7M1QuestCatalogRules` for CI-parity E7M1 gates (four-id allowlist, acyclic prerequisites, item/recipe/encounter cross-refs against upstream catalogs, operator-chain terminal token). DI registers `QuestDefinitionCatalog` after item, recipe, and encounter catalogs; `Program.cs` eager-resolves before `Run()`. Eighteen AAA loader + host boot tests pass locally; documentation (implementation plan, alignment register, module register note, backlog AC, `server/README.md`) is thorough. No client work (correct). Risk is low — established catalog-load pattern with strong negative coverage; Bruno health smoke is a nice bonus. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-113-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. | +| `docs/plans/E7M1-prototype-backlog.md` (E7M1-02) | **Matches** — AC checked; client counterpart none. | +| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — freeze table, CI enforcement + server-load line, Status notes NEO-113 landed. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-113 server load; register stays Planned until NEO-114. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note updated for NEO-113 server load. | +| `server/README.md` | **Matches** — quest catalog section (config keys, discovery, fail-fast, load order, NEO-114 forward reference). | +| Full-stack epic decomposition | **N/A** — infrastructure-only; no client counterpart until NEO-122/NEO-123. | + +## Blocking issues + +(none) + +## Suggestions + +1. ~~**Refresh `E7_M1_QuestStateMachine.md` Status line** — After merge, note that **E7M1-02 / NEO-113** server load is **landed** and runtime/registry work continues from **NEO-114** (mirror the alignment register wording). Optionally add a one-line “server load (NEO-113)” bullet under **CI enforcement** pointing at `server/README.md#quest-catalog-contentquests-neo-113`.~~ **Done.** + +2. ~~**Add `Load_ShouldThrow_WhenRowViolatesSchema` test** — The implementation plan and NEO-101 precedent (`EncounterDefinitionCatalogLoaderTests.Load_ShouldThrow_WhenRowViolatesSchema`) call for a row-level schema failure case (e.g. empty `displayName` or missing required objective field). Other negative paths are well covered; this is the main gap vs the plan’s test table.~~ **Done.** + +3. ~~**Chain terminal quantity negative test** — Plan lists “wrong chain terminal objective (kind / item / quantity)”; kind and item are covered; adding a quantity ≠ 1 case would close the loop on `TryGetChainTerminalGateError`.~~ **Done.** + +## Nits + +- ~~Nit: Bruno `bruno/neon-sprawl-server/quest-catalog/` is not listed in the implementation plan file table — harmless smoke; consider a one-line mention in plan reconciliation if you want docs to mirror NEO-103/NEO-78 Bruno folders.~~ **Done.** — plan reconciliation lists Bruno boot smoke. + +- Nit: E7M1 four-id gate error strings differ slightly from Python (`'id'` quoted list vs `repr` list) — acceptable; both are actionable. + +- Nit: `QuestDefinitionCatalog` XML doc correctly defers callers to `IQuestDefinitionRegistry` (NEO-114) — good guardrail for the strict NEO-113/114 split. + +## Verification + +```bash +# Quest catalog tests (20 — primary signal for this story) +cd server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~QuestDefinitionCatalogLoaderTests" + +# Full server suite (CI; requires Docker for Postgres integration tests) +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj + +# CI parity — Python content gate still passes with repo catalog +python3 -m pip install -r scripts/requirements-content.txt +python3 scripts/validate_content.py +``` + +**Reviewer note:** All 20 `QuestDefinitionCatalogLoaderTests` passed locally after review follow-up. Full server suite: 602 passed; 12 Postgres/docker integration tests skipped/failed in this environment (no Docker daemon) — unrelated to NEO-113. diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs new file mode 100644 index 0000000..043ab31 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs @@ -0,0 +1,29 @@ +using NeonSprawl.Server.Game.Quests; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +internal static class QuestCatalogTestPaths +{ + internal static string DiscoverRepoQuestsDirectory() => + QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoQuestDefSchemaPath() => + QuestCatalogPathResolution.ResolveQuestDefSchemaPath( + DiscoverRepoQuestsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static string DiscoverRepoQuestStepDefSchemaPath() => + QuestCatalogPathResolution.ResolveQuestStepDefSchemaPath( + DiscoverRepoQuestsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static string DiscoverRepoQuestObjectiveDefSchemaPath() => + QuestCatalogPathResolution.ResolveQuestObjectiveDefSchemaPath( + DiscoverRepoQuestsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..03595ae --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -0,0 +1,626 @@ +using System.Collections.Frozen; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public class QuestDefinitionCatalogLoaderTests +{ + private static readonly FrozenSet KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds; + + private static readonly FrozenSet KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds; + + private static readonly FrozenSet KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds; + + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "quests": [ + { + "id": "prototype_quest_gather_intro", + "displayName": "Intro: Salvage Run", + "prerequisiteQuestIds": [], + "steps": [ + { + "id": "gather_intro_step_salvage", + "displayName": "Gather scrap metal", + "objectives": [ + { + "id": "gather_intro_obj_scrap", + "kind": "gather_item", + "itemId": "scrap_metal_bulk", + "quantity": 3 + } + ] + } + ] + }, + { + "id": "prototype_quest_refine_intro", + "displayName": "Intro: Refine Stock", + "prerequisiteQuestIds": ["prototype_quest_gather_intro"], + "steps": [ + { + "id": "refine_intro_step_craft", + "displayName": "Refine scrap", + "objectives": [ + { + "id": "refine_intro_obj_recipe", + "kind": "craft_recipe", + "recipeId": "refine_scrap_standard", + "quantity": 1 + } + ] + } + ] + }, + { + "id": "prototype_quest_combat_intro", + "displayName": "Intro: Clear the Pocket", + "prerequisiteQuestIds": [], + "steps": [ + { + "id": "combat_intro_step_encounter", + "displayName": "Clear the combat pocket", + "objectives": [ + { + "id": "combat_intro_obj_encounter", + "kind": "encounter_complete", + "encounterId": "prototype_combat_pocket" + } + ] + } + ] + }, + { + "id": "prototype_quest_operator_chain", + "displayName": "Operator Chain", + "prerequisiteQuestIds": [ + "prototype_quest_gather_intro", + "prototype_quest_refine_intro", + "prototype_quest_combat_intro" + ], + "steps": [ + { + "id": "chain_step_gather", + "displayName": "Bulk salvage", + "objectives": [ + { + "id": "chain_obj_gather", + "kind": "gather_item", + "itemId": "scrap_metal_bulk", + "quantity": 5 + } + ] + }, + { + "id": "chain_step_refine", + "displayName": "Refine stock", + "objectives": [ + { + "id": "chain_obj_refine", + "kind": "craft_recipe", + "recipeId": "refine_scrap_standard", + "quantity": 1 + } + ] + }, + { + "id": "chain_step_stim", + "displayName": "Craft field stim", + "objectives": [ + { + "id": "chain_obj_stim", + "kind": "craft_recipe", + "recipeId": "make_field_stim_mk0", + "quantity": 1 + } + ] + }, + { + "id": "chain_step_token", + "displayName": "Hand off contract token", + "objectives": [ + { + "id": "chain_obj_token", + "kind": "inventory_has_item", + "itemId": "contract_handoff_token", + "quantity": 1 + } + ] + } + ] + } + ] + } + """; + + private static (string Root, string QuestsDir, string DefSchemaPath, string StepSchemaPath, string ObjectiveSchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-questcat-"); + var questsDir = Path.Combine(root.FullName, "content", "quests"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(questsDir); + Directory.CreateDirectory(schemaDir); + var defSchemaPath = Path.Combine(schemaDir, "quest-def.schema.json"); + var stepSchemaPath = Path.Combine(schemaDir, "quest-step-def.schema.json"); + var objectiveSchemaPath = Path.Combine(schemaDir, "quest-objective-def.schema.json"); + File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(), objectiveSchemaPath, overwrite: true); + return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); + } + + private static void WriteCatalog(string questsDir, string catalogJson) => + File.WriteAllText(Path.Combine(questsDir, "prototype_quests.json"), catalogJson, Encoding.UTF8); + + private static JsonObject GetQuestRow(JsonObject catalogRoot, string questId) + { + var quests = catalogRoot["quests"] as JsonArray + ?? throw new InvalidOperationException("expected quests array"); + foreach (var node in quests) + { + if (node is JsonObject row && row["id"]?.GetValue() == questId) + return row; + } + + throw new InvalidOperationException($"quest id not found: {questId}"); + } + + private static QuestDefinitionCatalog LoadCatalog( + string questsDir, + string defSchemaPath, + string stepSchemaPath, + string objectiveSchemaPath) => + QuestDefinitionCatalogLoader.Load( + questsDir, + defSchemaPath, + stepSchemaPath, + objectiveSchemaPath, + KnownItemIds, + KnownRecipeIds, + KnownEncounterIds, + NullLogger.Instance); + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile() + { + // Arrange + var questsDir = QuestCatalogTestPaths.DiscoverRepoQuestsDirectory(); + var defSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(); + var stepSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(); + var objectiveSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(); + // Act + var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); + // Assert + Assert.Equal(4, catalog.DistinctQuestCount); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + WriteCatalog(questsDir, ValidPrototypeCatalogJson); + // Act + var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); + // Assert + Assert.Equal(4, catalog.DistinctQuestCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); + Assert.NotNull(gather); + Assert.Equal("Intro: Salvage Run", gather!.DisplayName); + Assert.Empty(gather.PrerequisiteQuestIds); + Assert.Single(gather.Steps); + Assert.Equal("gather_item", gather.Steps[0].Objectives[0].Kind); + Assert.True(catalog.TryGetQuest("prototype_quest_operator_chain", out var chain)); + Assert.NotNull(chain); + Assert.Equal(4, chain!.Steps.Count); + Assert.Equal("inventory_has_item", chain.Steps[^1].Objectives[0].Kind); + Assert.Equal("contract_handoff_token", chain.Steps[^1].Objectives[0].ItemId); + } + + [Fact] + public void Load_ShouldThrow_WhenQuestsIsNotArray() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(questsDir, "bad_quests.json"), + """{"schemaVersion": 1, "quests": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'quests' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(questsDir, "bad_quests.json"), + """{"schemaVersion": 2, "quests": []}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRowViolatesSchema() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + const string bad = """ + { + "schemaVersion": 1, + "quests": [ + { + "id": "prototype_quest_gather_intro", + "displayName": "", + "prerequisiteQuestIds": [], + "steps": [ + { + "id": "gather_intro_step_salvage", + "displayName": "Gather scrap metal", + "objectives": [ + { + "id": "gather_intro_obj_scrap", + "kind": "gather_item", + "itemId": "scrap_metal_bulk", + "quantity": 3 + } + ] + } + ] + } + ] + } + """; + File.WriteAllText(Path.Combine(questsDir, "bad_quests.json"), bad, Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("Quest catalog validation failed", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateQuestIdAcrossFiles() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(questsDir, "extra_quests.json"), + """ + { + "schemaVersion": 1, + "quests": [ + { + "id": "prototype_quest_gather_intro", + "displayName": "Duplicate", + "prerequisiteQuestIds": [], + "steps": [ + { + "id": "dup_step", + "displayName": "Dup", + "objectives": [ + { + "id": "dup_obj", + "kind": "gather_item", + "itemId": "scrap_metal_bulk", + "quantity": 1 + } + ] + } + ] + } + ] + } + """, + Encoding.UTF8); + WriteCatalog(questsDir, ValidPrototypeCatalogJson); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate quest id 'prototype_quest_gather_intro'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateStepIdWithinQuest() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + var steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + steps.Add(steps[0]!.DeepClone()); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate step id 'gather_intro_step_salvage'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateObjectiveIdWithinQuest() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var firstStep = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = firstStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + objectives[0]!["id"] = "chain_obj_refine"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate objective id 'chain_obj_refine'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenMissingFrozenQuestId() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var quests = root["quests"] as JsonArray ?? throw new InvalidOperationException("expected quests"); + quests.RemoveAt(3); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E7M1 expects exactly quest ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownPrerequisiteQuestId() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var refine = GetQuestRow(root, "prototype_quest_refine_intro"); + refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_missing"); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prerequisiteQuestIds references unknown quest id", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenCyclicPrerequisiteQuestIds() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + gather["prerequisiteQuestIds"] = new JsonArray("prototype_quest_refine_intro"); + var refine = GetQuestRow(root, "prototype_quest_refine_intro"); + refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_gather_intro"); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("cyclic prerequisiteQuestIds", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownItemIdInObjective() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + var steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["itemId"] = "unknown_item_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("itemId 'unknown_item_id'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownRecipeIdInObjective() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var refine = GetQuestRow(root, "prototype_quest_refine_intro"); + var steps = refine["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["recipeId"] = "unknown_recipe_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("recipeId 'unknown_recipe_id'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownEncounterIdInObjective() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var combat = GetQuestRow(root, "prototype_quest_combat_intro"); + var steps = combat["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["encounterId"] = "unknown_encounter_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("encounterId 'unknown_encounter_id'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenChainTerminalObjectiveIsWrongKind() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["kind"] = "craft_recipe"; + obj["recipeId"] = "refine_scrap_standard"; + obj["quantity"] = 1; + obj.Remove("itemId"); + obj.Remove("encounterId"); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("terminal objective kind must be 'inventory_has_item'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenChainTerminalItemIsWrong() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["itemId"] = "scrap_metal_bulk"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("terminal itemId must be 'contract_handoff_token'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenChainTerminalQuantityIsWrong() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps"); + var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step"); + var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives"); + var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective"); + obj["quantity"] = 2; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("terminal quantity must be 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenMissingSchemaFile() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + WriteCatalog(questsDir, ValidPrototypeCatalogJson); + File.Delete(defSchemaPath); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Host_ShouldResolveQuestCatalogFromDi() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/health"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var questCatalog = factory.Services.GetRequiredService(); + Assert.Equal(4, questCatalog.DistinctQuestCount); + Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); + Assert.NotNull(gather); + Assert.Equal("Intro: Salvage Run", gather!.DisplayName); + } + + [Fact] + public void Host_ShouldFailStartup_WhenQuestsDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-quests-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + try + { + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:QuestsDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Quest catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 23560bf..d7f3cf7 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Skills; @@ -58,6 +59,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs new file mode 100644 index 0000000..d76ac3c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs @@ -0,0 +1,182 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M1_QUEST_IDS, +/// PROTOTYPE_E7M1_CHAIN_QUEST_ID, PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID, +/// and _prototype_e7m1_* gate functions. +/// +public static class PrototypeE7M1QuestCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_QUEST_IDS. + public static readonly FrozenSet ExpectedQuestIds = FrozenSet.ToFrozenSet( + [ + "prototype_quest_gather_intro", + "prototype_quest_refine_intro", + "prototype_quest_combat_intro", + "prototype_quest_operator_chain", + ], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_QUEST_ID. + public const string ChainQuestId = "prototype_quest_operator_chain"; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID. + public const string ChainTerminalItemId = "contract_handoff_token"; + + /// Returns a human-readable error if the E7M1 quest contract fails, otherwise . + public static string? TryGetE7M1GateError(IReadOnlyDictionary questIdToSourceFile) + { + var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedQuestIds)) + { + return + "error: prototype E7M1 expects exactly quest ids " + + $"[{string.Join(", ", ExpectedQuestIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error when prerequisite ids are unknown or cyclic, otherwise . + public static string? TryGetPrerequisiteGateError(IReadOnlyDictionary rowsById) + { + var known = rowsById.Keys.ToFrozenSet(StringComparer.Ordinal); + foreach (var (qid, row) in rowsById) + { + foreach (var pid in row.PrerequisiteQuestIds) + { + if (!known.Contains(pid)) + { + return + $"error: quest '{qid}': prerequisiteQuestIds references unknown quest id '{pid}'"; + } + } + } + + var visiting = new HashSet(StringComparer.Ordinal); + var visited = new HashSet(StringComparer.Ordinal); + + string? Dfs(string node) + { + if (visiting.Contains(node)) + return $"error: cyclic prerequisiteQuestIds detected involving quest '{node}'"; + + if (visited.Contains(node)) + return null; + + visiting.Add(node); + if (rowsById.TryGetValue(node, out var row)) + { + foreach (var pid in row.PrerequisiteQuestIds) + { + var err = Dfs(pid); + if (err is not null) + return err; + } + } + + visiting.Remove(node); + visited.Add(node); + return null; + } + + foreach (var qid in known) + { + var err = Dfs(qid); + if (err is not null) + return err; + } + + return null; + } + + /// Returns a human-readable error when objective catalog cross-refs fail, otherwise . + public static string? TryGetCrossRefError( + IReadOnlyDictionary rowsById, + IReadOnlySet knownItemIds, + IReadOnlySet knownRecipeIds, + IReadOnlySet knownEncounterIds) + { + foreach (var (qid, row) in rowsById) + { + for (var si = 0; si < row.Steps.Count; si++) + { + var step = row.Steps[si]; + for (var oi = 0; oi < step.Objectives.Count; oi++) + { + var obj = step.Objectives[oi]; + if (obj.ItemId is not null && + obj.Kind is "gather_item" or "inventory_has_item" && + !knownItemIds.Contains(obj.ItemId)) + { + return + $"error: quest '{qid}' steps[{si}] objectives[{oi}]: itemId '{obj.ItemId}' " + + "is not in the frozen prototype item catalog"; + } + + if (obj.RecipeId is not null && + obj.Kind == "craft_recipe" && + !knownRecipeIds.Contains(obj.RecipeId)) + { + return + $"error: quest '{qid}' steps[{si}] objectives[{oi}]: recipeId '{obj.RecipeId}' " + + "is not in the frozen prototype recipe catalog"; + } + + if (obj.EncounterId is not null && + obj.Kind == "encounter_complete" && + !knownEncounterIds.Contains(obj.EncounterId)) + { + return + $"error: quest '{qid}' steps[{si}] objectives[{oi}]: encounterId '{obj.EncounterId}' " + + "is not in the frozen prototype encounter catalog"; + } + } + } + } + + return null; + } + + /// Returns a human-readable error when the operator chain terminal step fails, otherwise . + public static string? TryGetChainTerminalGateError(IReadOnlyDictionary rowsById) + { + if (!rowsById.TryGetValue(ChainQuestId, out var chain)) + return $"error: missing chain quest '{ChainQuestId}'"; + + if (chain.Steps.Count == 0) + return $"error: chain quest '{ChainQuestId}' must have at least one step"; + + var lastStep = chain.Steps[^1]; + if (lastStep.Objectives.Count != 1) + { + return + $"error: chain quest '{ChainQuestId}' terminal step must have exactly one objective"; + } + + var obj = lastStep.Objectives[0]; + if (obj.Kind != "inventory_has_item") + { + return + $"error: chain quest '{ChainQuestId}' terminal objective kind must be 'inventory_has_item'"; + } + + if (obj.ItemId != ChainTerminalItemId) + { + return + $"error: chain quest '{ChainQuestId}' terminal itemId must be '{ChainTerminalItemId}', got '{obj.ItemId}'"; + } + + if (obj.Quantity != 1) + { + return + $"error: chain quest '{ChainQuestId}' terminal quantity must be 1, got {obj.Quantity}"; + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs new file mode 100644 index 0000000..96cec83 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs @@ -0,0 +1,96 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Resolves quest catalog paths for local dev, tests, and container layouts (NEO-113). +public static class QuestCatalogPathResolution +{ + /// Walks and parents for an existing content/quests directory. + public static string? TryDiscoverQuestsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "quests"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the quests catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveQuestsDirectory(string? configuredQuestsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredQuestsDirectory)) + { + var discovered = TryDiscoverQuestsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:QuestsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/quests'). " + + "Set Content:QuestsDirectory in configuration or environment (e.g. Content__QuestsDirectory)."); + } + + var trimmed = configuredQuestsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single quest row (Draft 2020-12). + public static string ResolveQuestDefSchemaPath( + string questsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-def.schema.json")); + } + + /// Resolves JSON Schema path for a quest step row. + public static string ResolveQuestStepDefSchemaPath( + string questsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-step-def.schema.json")); + } + + /// Resolves JSON Schema path for a quest objective row. + public static string ResolveQuestObjectiveDefSchemaPath( + string questsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-objective-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..10e3be7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Quests; + +/// DI registration for the fail-fast quest catalog (NEO-113). +public static class QuestCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddQuestDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var itemCatalog = sp.GetRequiredService(); + var recipeCatalog = sp.GetRequiredService(); + var encounterCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog"); + + var questsDir = QuestCatalogPathResolution.ResolveQuestsDirectory( + opts.QuestsDirectory, + hostEnv.ContentRootPath); + var questDefSchemaPath = QuestCatalogPathResolution.ResolveQuestDefSchemaPath( + questsDir, + opts.QuestDefSchemaPath, + hostEnv.ContentRootPath); + var questStepDefSchemaPath = QuestCatalogPathResolution.ResolveQuestStepDefSchemaPath( + questsDir, + opts.QuestStepDefSchemaPath, + hostEnv.ContentRootPath); + var questObjectiveDefSchemaPath = QuestCatalogPathResolution.ResolveQuestObjectiveDefSchemaPath( + questsDir, + opts.QuestObjectiveDefSchemaPath, + hostEnv.ContentRootPath); + + var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + + return QuestDefinitionCatalogLoader.Load( + questsDir, + questDefSchemaPath, + questStepDefSchemaPath, + questObjectiveDefSchemaPath, + knownItemIds, + knownRecipeIds, + knownEncounterIds, + logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs new file mode 100644 index 0000000..9456b65 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs @@ -0,0 +1,17 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Load-time quest row (NEO-113). +public sealed class QuestDefRow( + string id, + string displayName, + IReadOnlyList prerequisiteQuestIds, + IReadOnlyList steps) +{ + public string Id { get; } = id; + + public string DisplayName { get; } = displayName; + + public IReadOnlyList PrerequisiteQuestIds { get; } = prerequisiteQuestIds; + + public IReadOnlyList Steps { get; } = steps; +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs new file mode 100644 index 0000000..df02f2b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs @@ -0,0 +1,25 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Quests; + +/// In-memory quest catalog loaded at startup (NEO-113). Game callers should use (NEO-114). +public sealed class QuestDefinitionCatalog( + string questsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_quests.json catalogs. + public string QuestsDirectory { get; } = questsDirectory; + + public IReadOnlyDictionary ById { get; } = + new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctQuestCount => ById.Count; + + /// Number of *_quests.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable quest . + public bool TryGetQuest(string id, out QuestDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs new file mode 100644 index 0000000..7d099f0 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -0,0 +1,386 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Quests; + +/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113). +public static class QuestDefinitionCatalogLoader +{ + private static readonly Lock SchemaLoadLock = new(); + private static JsonSchema? _questObjectiveDefSchema; + private static JsonSchema? _questStepDefSchema; + private static JsonSchema? _questDefSchema; + /// Loads catalogs from disk or throws with actionable messages. + public static QuestDefinitionCatalog Load( + string questsDirectory, + string questDefSchemaPath, + string questStepDefSchemaPath, + string questObjectiveDefSchemaPath, + IReadOnlySet knownItemIds, + IReadOnlySet knownRecipeIds, + IReadOnlySet knownEncounterIds, + ILogger logger) + { + questsDirectory = Path.GetFullPath(questsDirectory); + questDefSchemaPath = Path.GetFullPath(questDefSchemaPath); + questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath); + questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath); + + var errors = new List(); + + if (!File.Exists(questDefSchemaPath)) + errors.Add($"error: missing schema file {questDefSchemaPath}"); + + if (!File.Exists(questStepDefSchemaPath)) + errors.Add($"error: missing schema file {questStepDefSchemaPath}"); + + if (!File.Exists(questObjectiveDefSchemaPath)) + errors.Add($"error: missing schema file {questObjectiveDefSchemaPath}"); + + if (!Directory.Exists(questsDirectory)) + errors.Add($"error: missing directory {questsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(questsDirectory, "*_quests.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_quests.json files under {questsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + EnsureQuestSchemasLoaded(questObjectiveDefSchemaPath, questStepDefSchemaPath, questDefSchemaPath); + var defSchema = _questDefSchema!; + + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var questIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + + foreach (var path in jsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var questsNode = rootObj["quests"]; + if (questsNode is not JsonArray questsArray) + { + errors.Add($"error: {path}: expected top-level 'quests' array"); + continue; + } + + for (var i = 0; i < questsArray.Count; i++) + { + var quest = questsArray[i]; + if (quest is not JsonObject rowObj) + { + errors.Add($"error: {path}: quests[{i}] must be an object"); + continue; + } + + var eval = defSchema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} quests[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var qid = (rowObj["id"] as JsonValue)?.GetValue(); + if (qid is not null && eval.IsValid) + { + if (questIdToSourceFile.TryGetValue(qid, out var prevPath)) + { + errors.Add($"error: duplicate quest id '{qid}' in {prevPath} and {path}"); + continue; + } + + var stepParseErrors = TryParseSteps(rowObj, path, i, out var steps, out _, out _); + errors.AddRange(stepParseErrors); + + questIdToSourceFile[qid] = path; + rows[qid] = ParseRow(rowObj, steps); + } + } + } + + ThrowIfAny(errors); + + var e7m1 = PrototypeE7M1QuestCatalogRules.TryGetE7M1GateError(questIdToSourceFile); + if (e7m1 is not null) + { + errors.Add(e7m1); + ThrowIfAny(errors); + } + + var prereq = PrototypeE7M1QuestCatalogRules.TryGetPrerequisiteGateError(rows); + if (prereq is not null) + { + errors.Add(prereq); + ThrowIfAny(errors); + } + + var crossRef = PrototypeE7M1QuestCatalogRules.TryGetCrossRefError( + rows, + knownItemIds, + knownRecipeIds, + knownEncounterIds); + if (crossRef is not null) + { + errors.Add(crossRef); + ThrowIfAny(errors); + } + + var chainTerminal = PrototypeE7M1QuestCatalogRules.TryGetChainTerminalGateError(rows); + if (chainTerminal is not null) + { + errors.Add(chainTerminal); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded quest catalog from {QuestsDirectory}: {QuestCount} quest(s) across {CatalogFileCount} JSON catalog file(s).", + questsDirectory, + rows.Count, + jsonFiles.Length); + } + + return new QuestDefinitionCatalog(questsDirectory, rows, jsonFiles.Length); + } + + private static List TryParseSteps( + JsonObject rowObj, + string path, + int questIndex, + out IReadOnlyList steps, + out HashSet stepIdsSeen, + out HashSet objectiveIdsSeen) + { + var errors = new List(); + stepIdsSeen = new HashSet(StringComparer.Ordinal); + objectiveIdsSeen = new HashSet(StringComparer.Ordinal); + var parsedSteps = new List(); + + var stepsNode = rowObj["steps"] as JsonArray; + if (stepsNode is null) + { + steps = parsedSteps; + return errors; + } + + for (var si = 0; si < stepsNode.Count; si++) + { + if (stepsNode[si] is not JsonObject stepObj) + continue; + + var sid = (stepObj["id"] as JsonValue)?.GetValue(); + if (sid is not null) + { + if (stepIdsSeen.Contains(sid)) + { + errors.Add($"error: {path} quests[{questIndex}] steps[{si}]: duplicate step id '{sid}'"); + } + else + { + stepIdsSeen.Add(sid); + } + } + + var objectivesNode = stepObj["objectives"] as JsonArray; + if (objectivesNode is null) + continue; + + for (var oi = 0; oi < objectivesNode.Count; oi++) + { + if (objectivesNode[oi] is not JsonObject objObj) + continue; + + var oid = (objObj["id"] as JsonValue)?.GetValue(); + if (oid is not null) + { + if (objectiveIdsSeen.Contains(oid)) + { + errors.Add( + $"error: {path} quests[{questIndex}] steps[{si}] objectives[{oi}]: " + + $"duplicate objective id '{oid}' within quest"); + } + else + { + objectiveIdsSeen.Add(oid); + } + } + } + } + + for (var si = 0; si < stepsNode.Count; si++) + { + if (stepsNode[si] is JsonObject stepObj) + parsedSteps.Add(ParseStep(stepObj)); + } + + steps = parsedSteps; + return errors; + } + + private static QuestDefRow ParseRow(JsonObject rowObj, IReadOnlyList steps) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray); + return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps); + } + + private static QuestStepDefRow ParseStep(JsonObject stepObj) + { + var id = (stepObj["id"] as JsonValue)!.GetValue(); + var displayName = (stepObj["displayName"] as JsonValue)!.GetValue(); + var objectivesNode = stepObj["objectives"] as JsonArray ?? []; + var objectives = new List(objectivesNode.Count); + foreach (var node in objectivesNode) + { + if (node is JsonObject objObj) + objectives.Add(ParseObjective(objObj)); + } + + return new QuestStepDefRow(id, displayName, objectives); + } + + private static QuestObjectiveDefRow ParseObjective(JsonObject objObj) + { + var id = (objObj["id"] as JsonValue)!.GetValue(); + var kind = (objObj["kind"] as JsonValue)!.GetValue(); + string? itemId = null; + int? quantity = null; + string? recipeId = null; + string? encounterId = null; + + if (objObj["itemId"] is JsonValue itemValue && itemValue.TryGetValue(out var item)) + itemId = item; + + if (objObj["quantity"] is JsonValue qtyValue && qtyValue.TryGetValue(out var qty)) + quantity = qty; + + if (objObj["recipeId"] is JsonValue recipeValue && recipeValue.TryGetValue(out var recipe)) + recipeId = recipe; + + if (objObj["encounterId"] is JsonValue encounterValue && encounterValue.TryGetValue(out var encounter)) + encounterId = encounter; + + return new QuestObjectiveDefRow(id, kind, itemId, quantity, recipeId, encounterId); + } + + private static IReadOnlyList ParseStringArray(JsonArray? array) + { + if (array is null) + return []; + + var values = new List(array.Count); + foreach (var node in array) + { + if (node is JsonValue value && value.TryGetValue(out var s)) + values.Add(s); + } + + return values; + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} quests[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + /// Registers objective → step → def in once per process (NEO-113). + private static void EnsureQuestSchemasLoaded( + string questObjectiveDefSchemaPath, + string questStepDefSchemaPath, + string questDefSchemaPath) + { + lock (SchemaLoadLock) + { + if (_questDefSchema is not null) + return; + + var objectiveSchema = JsonSchema.FromText(File.ReadAllText(questObjectiveDefSchemaPath)); + SchemaRegistry.Global.Register(objectiveSchema); + _questObjectiveDefSchema = objectiveSchema; + + var stepSchema = JsonSchema.FromText(File.ReadAllText(questStepDefSchemaPath)); + SchemaRegistry.Global.Register(stepSchema); + _questStepDefSchema = stepSchema; + + var defSchema = JsonSchema.FromText(File.ReadAllText(questDefSchemaPath)); + SchemaRegistry.Global.Register(defSchema); + _questDefSchema = defSchema; + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Quest catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs new file mode 100644 index 0000000..e595e1e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs @@ -0,0 +1,23 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Load-time quest objective row (NEO-113). +public sealed class QuestObjectiveDefRow( + string id, + string kind, + string? itemId, + int? quantity, + string? recipeId, + string? encounterId) +{ + public string Id { get; } = id; + + public string Kind { get; } = kind; + + public string? ItemId { get; } = itemId; + + public int? Quantity { get; } = quantity; + + public string? RecipeId { get; } = recipeId; + + public string? EncounterId { get; } = encounterId; +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs new file mode 100644 index 0000000..8285a5d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Load-time quest step row (NEO-113). +public sealed class QuestStepDefRow( + string id, + string displayName, + IReadOnlyList objectives) +{ + public string Id { get; } = id; + + public string DisplayName { get; } = displayName; + + public IReadOnlyList Objectives { get; } = objectives; +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 5d89b7f..e3cccbc 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -130,4 +130,28 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of encounters directory}/schemas/encounter-def.schema.json. /// public string? EncounterDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/quests directory. + /// + public string? QuestsDirectory { get; set; } + + /// + /// Optional override for quest-def.schema.json. + /// When unset, resolved as {parent of quests directory}/schemas/quest-def.schema.json. + /// + public string? QuestDefSchemaPath { get; set; } + + /// + /// Optional override for quest-step-def.schema.json. + /// When unset, resolved as {parent of quests directory}/schemas/quest-step-def.schema.json. + /// + public string? QuestStepDefSchemaPath { get; set; } + + /// + /// Optional override for quest-objective-def.schema.json. + /// When unset, resolved as {parent of quests directory}/schemas/quest-objective-def.schema.json. + /// + public string? QuestObjectiveDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 7bf916a..f664013 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -9,6 +9,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 NeonSprawl.Server.Game.Targeting; @@ -27,6 +28,7 @@ builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); +builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddThreatStateStore(); builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddPlayerCombatHealthStore(); @@ -41,6 +43,7 @@ _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index 572c74a..7ed0461 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -22,7 +22,11 @@ "RewardTableDefSchemaPath": "", "RewardGrantRowSchemaPath": "", "EncountersDirectory": "", - "EncounterDefSchemaPath": "" + "EncounterDefSchemaPath": "", + "QuestsDirectory": "", + "QuestDefSchemaPath": "", + "QuestStepDefSchemaPath": "", + "QuestObjectiveDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index 019be62..7ab6957 100644 --- a/server/README.md +++ b/server/README.md @@ -133,6 +133,21 @@ On startup the host loads every **`*_encounters.json`** under the encounters dir On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as ability/hotbar routes. +## Quest catalog (`content/quests`, NEO-113) + +On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, and encounter catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`** and **`quest-objective-def.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, and enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:QuestsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/quests`**. | +| **`Content:QuestDefSchemaPath`** | Optional override for **`quest-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-def.schema.json`**. | +| **`Content:QuestStepDefSchemaPath`** | Optional override for **`quest-step-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-step-def.schema.json`**. | +| **`Content:QuestObjectiveDefSchemaPath`** | Optional override for **`quest-objective-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-objective-def.schema.json`**. | + +**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**), and the three quest schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. + +On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. **`IQuestDefinitionRegistry`** (NEO-114) will be the lookup surface for game code; until then the **`QuestDefinitionCatalog`** singleton is registered for fail-fast startup only—do not inject it in new game code. + ## Encounter definitions (NEO-103) **`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.