diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md index 7cfefd3..e42b875 100644 --- a/docs/decomposition/modules/E7_M1_QuestStateMachine.md +++ b/docs/decomposition/modules/E7_M1_QuestStateMachine.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M1 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Prototype | -| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; 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) | +| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; runtime from **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) → 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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index c306776..79e2681 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M1 | Planned | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **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 | +| E7.M1 | Planned | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **Backlog decomposed (runtime from E7M1-04+):** 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 quest runtime (E7M1-05+) lands. | [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index e0177d2..86d7cf2 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl |---|---|---|---|---|---| | E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned | -**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; register row stays **Planned** until **NEO-114** registry + quest runtime land. +**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); register row stays **Planned** until quest runtime (E7M1-05+) lands. | 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 abae91c..04aec38 100644 --- a/docs/plans/E7M1-prototype-backlog.md +++ b/docs/plans/E7M1-prototype-backlog.md @@ -128,13 +128,13 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco ### E7M1-03 — Injectable quest definition registry + DI -**Goal:** **`IQuestDefinitionRegistry`** with **`TryGet`**, startup registration from E7M1-02 catalog. +**Goal:** **`IQuestDefinitionRegistry`** with **`TryGetDefinition`**, **`TryNormalizeKnown`**, and **`GetDefinitionsInIdOrder`**, startup registration from E7M1-02 catalog. **In scope** -- Interface + in-memory implementation; **`AddQuestDefinitionRegistry`** DI extension. -- Unit tests: lookup known ids, unknown id returns false. -- Wire in host startup alongside other content registries. +- Interface + in-memory implementation; extend **`AddQuestDefinitionCatalog`** to register **`IQuestDefinitionRegistry`** after the catalog singleton. +- Unit tests: lookup known ids; unknown / null / empty id returns false; normalize trim/case; enumeration order; host DI resolve. +- Wire registry in **`AddQuestDefinitionCatalog`** alongside catalog load (catalog remains eager boot surface). **Out of scope** @@ -142,8 +142,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco **Acceptance criteria** -- [ ] Registry resolves all four frozen quest ids at runtime. -- [ ] Unknown quest id fails closed for downstream operations. +- [x] Registry resolves all four frozen quest ids at runtime. +- [x] Unknown quest id fails closed for downstream operations. **Client counterpart:** none (infrastructure-only). diff --git a/docs/plans/NEO-114-implementation-plan.md b/docs/plans/NEO-114-implementation-plan.md new file mode 100644 index 0000000..7c0bc28 --- /dev/null +++ b/docs/plans/NEO-114-implementation-plan.md @@ -0,0 +1,110 @@ +# NEO-114 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-114 | +| **Title** | E7M1-03: Injectable quest definition registry + DI | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-114/e7m1-03-injectable-quest-definition-registry-di | +| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-03** | +| **Branch** | `NEO-114-e7m1-injectable-quest-definition-registry-di` | +| **Precursor** | [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) — fail-fast quest catalog load (**landed on `main`**) | +| **Pattern** | [NEO-102](NEO-102-implementation-plan.md) / [NEO-52](NEO-52-implementation-plan.md) — thin registry adapter over startup catalog + DI; strict split after NEO-113 loader | +| **Blocks** | [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — GET `/game/world/quest-definitions`; E7M1-05+ quest runtime | +| **Client counterpart** | None — server-only registry surface; 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 | +|--------|----------|----------------------|--------| +| **Registry API surface** | Include `TryNormalizeKnown` + `GetDefinitionsInIdOrder` on `IQuestDefinitionRegistry`? | **Yes** — mirror [NEO-102](NEO-102-implementation-plan.md) / `IEncounterDefinitionRegistry`; [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) (E7M1-04 HTTP) needs enumeration; accept flow will need fail-closed id normalization. | **Adopted** — user chose full surface. | +| **Lookup naming** | `TryGetDefinition` vs `TryGetQuest`? | **`TryGetDefinition`** — item/skill/ability/recipe/encounter precedent; catalog keeps `TryGetQuest`. | **Adopted** | +| **DI wiring** | Separate `AddQuestDefinitionRegistry` extension vs extend catalog registration? | **Extend `AddQuestDefinitionCatalog`** — register `IQuestDefinitionRegistry` after `QuestDefinitionCatalog` singleton (same file as NEO-113). | **Adopted** | +| **Test scope** | Registry unit tests only, or also host DI resolve? | **Both** — mirror NEO-102; NEO-113 already covers catalog boot. | **Adopted** | +| **Program.cs eager resolve** | Eager-resolve registry at boot? | **Omit** — catalog singleton already eager-resolved (NEO-113); registry is thin adapter. | **Adopted** | +| **Call site migration** | Migrate existing consumers off `QuestDefinitionCatalog`? | **None this story** — no game code injects quest catalog yet; HTTP is NEO-115. | **Adopted** | + +## Goal, scope, and out-of-scope + +**Goal:** Provide **`IQuestDefinitionRegistry`** backed by the startup-loaded **`QuestDefinitionCatalog`**: resolve by stable quest id, enumerate definitions in id order, and expose **`TryNormalizeKnown`** for fail-closed id validation. Register in DI so **NEO-115+** (HTTP, player quest state, accept/advance engines) depend on the interface instead of the catalog type. + +**In scope (from Linear + [E7M1-03](E7M1-prototype-backlog.md#e7m1-03--injectable-quest-definition-registry--di)):** + +- `IQuestDefinitionRegistry` + `QuestDefinitionRegistry` thin adapter over NEO-113 catalog. +- DI registration in `AddQuestDefinitionCatalog`. +- Unit tests (AAA): all four frozen quest ids resolve with expected metadata; unknown id / null miss without throwing; `TryNormalizeKnown` trim/case/whitespace behavior; enumeration order; host resolves registry from DI. + +**Out of scope (from Linear + backlog):** + +- HTTP routes ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) / E7M1-04). +- Player quest state, accept/advance hooks (E7M1-05+). +- Changing loader, E7M1 gates, or catalog load semantics (NEO-113). +- Godot / client changes. + +## Acceptance criteria checklist + +- [x] Registry resolves all four frozen quest ids at runtime (`PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`). +- [x] Unknown quest id fails closed (`TryGetDefinition` / `TryNormalizeKnown` return false; no throw). + +## Implementation reconciliation (shipped) + +- **Interface:** `IQuestDefinitionRegistry` with `TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`. +- **Adapter:** `QuestDefinitionRegistry` over NEO-113 `QuestDefinitionCatalog` (cached id-order list). +- **DI:** `AddQuestDefinitionCatalog` registers `IQuestDefinitionRegistry` after catalog singleton; no `Program.cs` change. +- **Tests:** AAA tests in `QuestDefinitionRegistryTests` (unit + host DI; four frozen ids + unknown/null/normalize/order). +- **Docs:** `server/README.md` quest registry section; alignment register E7.M1 row updated. + +## Technical approach + +1. **`IQuestDefinitionRegistry`** — mirror [`IEncounterDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Encounters/IEncounterDefinitionRegistry.cs): + - `TryGetDefinition(string? questId, [NotNullWhen(true)] out QuestDefRow? definition)` — null and unknown ids return false without throwing. + - `TryNormalizeKnown(string? rawQuestId, [NotNullWhen(true)] out string normalized)` — trim + lowercase + catalog lookup; null/empty/whitespace return false. + - `GetDefinitionsInIdOrder()` — all rows ordered by `QuestDefRow.Id` (ordinal). + - XML remarks: quest runtime / accept callers (NEO-116+) and HTTP read model (NEO-115) should use this interface, not `QuestDefinitionCatalog`. + +2. **`QuestDefinitionRegistry`** — primary-constructor adapter delegating to `QuestDefinitionCatalog.TryGetQuest`; cache `GetDefinitionsInIdOrder` at construction (NEO-102 / NEO-79 pattern). + +3. **DI** — extend [`QuestCatalogServiceCollectionExtensions.AddQuestDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs): + - `services.AddSingleton(sp => new QuestDefinitionRegistry(sp.GetRequiredService()));` + - Update extension XML summary to document both catalog + registry registration. + - No change to `Program.cs` eager-resolve (catalog only). + +4. **Comments / docs** — `QuestDefinitionCatalog` summary already references NEO-114; update [server README](../../server/README.md) quest catalog section to document registry as preferred lookup surface (replace “will be” placeholder with concrete method list, mirror encounter section). + +5. **Tests** — new `QuestDefinitionRegistryTests.cs` mirroring [`EncounterDefinitionRegistryTests`](../../server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs): + - **Unit (in-memory catalog fixture):** each of four `ExpectedQuestIds` resolves via `TryGetDefinition` with non-empty `DisplayName` and step count smoke; null/unknown/empty `TryGetDefinition` false; `TryNormalizeKnown` success for trimmed mixed-case known id; false for null/whitespace/unknown; `GetDefinitionsInIdOrder` length 4 and ordinal id order. + - **Host:** `InMemoryWebApplicationFactory` + `/health` + DI resolve `IQuestDefinitionRegistry`; `TryGetDefinition("prototype_quest_gather_intro")` true; unknown id false; `TryNormalizeKnown` on a frozen id. + +6. **Alignment register** — when complete, note NEO-114 registry in `documentation_and_implementation_alignment.md` E7.M1 row (registry landed; runtime still Planned until E7M1-05+). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Quests/IQuestDefinitionRegistry.cs` | Public contract: lookup, normalize, enumerate. | +| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionRegistry.cs` | Thin adapter over `QuestDefinitionCatalog`. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs` | AAA unit + host DI tests for registry. | +| `docs/plans/NEO-114-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | Register `IQuestDefinitionRegistry` after catalog singleton; update doc comment. | +| `server/README.md` | Quest catalog section — registry is preferred lookup surface (methods + normalization note). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — note NEO-114 registry when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` true + metadata for each `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` member (at least `displayName` + `Steps.Count` smoke on gather intro and operator chain); false for null/unknown. **Unit:** `TryNormalizeKnown` trim/case success on known id; false for null/whitespace/unknown. **Unit:** `GetDefinitionsInIdOrder` returns 4 rows in ordinal id order. **Host:** `InMemoryWebApplicationFactory` resolves `IQuestDefinitionRegistry`; frozen id lookup; unknown + normalize failures. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Case sensitivity on raw lookup** | `TryGetDefinition` is case-sensitive on catalog keys; wire validation uses `TryNormalizeKnown` (document in README like encounters). | **adopted** | +| **Constants for tests** | Reuse `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` / named ids in tests; avoid duplicating id strings. | **adopted** | +| **Registry vs catalog injection in new code** | No new `QuestDefinitionCatalog` injections this repo pass; enforcement via XML + README only until analyzers exist. | **adopted** | diff --git a/docs/reviews/2026-06-01-NEO-114.md b/docs/reviews/2026-06-01-NEO-114.md new file mode 100644 index 0000000..c4058cd --- /dev/null +++ b/docs/reviews/2026-06-01-NEO-114.md @@ -0,0 +1,61 @@ +# Code review — NEO-114 (E7M1-03) + +**Date:** 2026-06-01 +**Scope:** Branch `NEO-114-e7m1-injectable-quest-definition-registry-di` vs `d236aee` (merge-base on `main`) — commits `9056a9b` … `0bcbb4b` +**Base:** `d236aee` (main at branch point, post NEO-113 merge) + +## Verdict + +**Approve with nits** + +## Summary + +NEO-114 adds **`IQuestDefinitionRegistry`** and **`QuestDefinitionRegistry`**, a thin adapter over the NEO-113 **`QuestDefinitionCatalog`**, mirroring the established NEO-102 encounter registry pattern: `TryGetDefinition`, `TryNormalizeKnown`, and cached `GetDefinitionsInIdOrder`. DI registers the registry in **`AddQuestDefinitionCatalog`** after the catalog singleton; no `Program.cs` change (catalog remains the eager boot surface). Fourteen AAA unit + host tests pass locally; documentation (implementation plan, alignment register, module register, E7.M1 status, backlog AC, `server/README.md`) is updated. Server-only infrastructure with no client counterpart (correct). Risk is low — predictable adapter with strong parity to encounter registry and good host integration coverage. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-114-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. | +| `docs/plans/E7M1-prototype-backlog.md` (E7M1-03) | **Matches** — AC checked; **In scope** wording aligned with shipped API (NEO-114 review follow-up). | +| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — Status notes E7M1-03 / NEO-114 registry landed; runtime from E7M1-04. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-114 registry; register stays Planned until E7M1-05+ runtime. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note updated for NEO-114 registry landed. | +| `server/README.md` | **Matches** — quest catalog section documents registry as preferred lookup surface with normalization note. | +| Full-stack epic decomposition | **N/A** — infrastructure-only; no client counterpart until NEO-122/NEO-123. | + +## Blocking issues + +(none) + +## Suggestions + +1. ~~**Refresh E7M1-03 backlog wording** — Update `docs/plans/E7M1-prototype-backlog.md` **In scope** to match shipped API: `TryGetDefinition` (not `TryGet`), extend `AddQuestDefinitionCatalog` (not separate `AddQuestDefinitionRegistry`), and optional mention of `TryNormalizeKnown` / `GetDefinitionsInIdOrder` per the implementation plan.~~ **Done.** + +2. ~~**`TryGetDefinition` empty-string test** — Implementation plan test table lists false for null/unknown/**empty**; tests cover null and unknown but not `""`. Encounter registry has the same gap; adding one unit test would close the plan loop and guard accidental empty-key catalog entries.~~ **Done.** — `TryGetDefinition_ShouldReturnFalse_WhenQuestIdIsEmpty`. + +## Nits + +- Nit: `GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleQuests` uses three synthetic rows (ordering smoke) while the host test validates four frozen ids — intentional split; no change required. + +- Nit: `[Theory]` over `ExpectedQuestIds` uses minimal stub rows (`DisplayName: "Test Quest"`) while gather intro / operator chain have richer metadata tests; host test asserts non-empty display names for all four — adequate coverage. + +- Nit: `QuestDefinitionCatalog` XML and README correctly steer new code to `IQuestDefinitionRegistry` — good guardrail ahead of NEO-115 HTTP. + +## Verification + +```bash +# Quest registry tests (14 — primary signal for this story) +cd server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~QuestDefinitionRegistryTests" + +# Quest catalog loader tests (unchanged; regression) +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~QuestDefinitionCatalogLoaderTests" + +# Full server suite (CI) +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +``` + +**Reviewer note:** All 14 `QuestDefinitionRegistryTests` passed locally during review. After follow-up: 15 tests (empty-string `TryGetDefinition` case). diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs new file mode 100644 index 0000000..671f6f6 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs @@ -0,0 +1,272 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public class QuestDefinitionRegistryTests +{ + private const string GatherIntroId = "prototype_quest_gather_intro"; + private const string OperatorChainId = "prototype_quest_operator_chain"; + + public static TheoryData FrozenQuestIds => + new(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal)); + + private static QuestDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new QuestDefinitionCatalog("/tmp/quests", byId, catalogJsonFileCount: 1); + return new QuestDefinitionRegistry(catalog); + } + + private static QuestDefRow CreateGatherIntroRow() => + new( + GatherIntroId, + "Intro: Salvage Run", + [], + [ + new QuestStepDefRow( + "gather_intro_step_salvage", + "Gather scrap metal", + [ + new QuestObjectiveDefRow( + "gather_intro_obj_scrap", + "gather_item", + "scrap_metal_bulk", + 3, + null, + null), + ]), + ]); + + [Theory] + [MemberData(nameof(FrozenQuestIds))] + public void TryGetDefinition_ShouldReturnTrue_WhenFrozenQuestIdExists(string questId) + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [questId] = new QuestDefRow(questId, "Test Quest", [], []), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(questId, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal(questId, def!.Id); + Assert.Equal("Test Quest", def.DisplayName); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenGatherIntroExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(GatherIntroId, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("Intro: Salvage Run", def!.DisplayName); + Assert.Single(def.Steps); + Assert.Equal("gather_intro_step_salvage", def.Steps[0].Id); + } + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndFourSteps_WhenOperatorChainExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [OperatorChainId] = new QuestDefRow( + OperatorChainId, + "Operator Chain", + ["prototype_quest_gather_intro"], + [ + new QuestStepDefRow("chain_step_gather", "Bulk salvage", []), + new QuestStepDefRow("chain_step_refine", "Refine stock", []), + new QuestStepDefRow("chain_step_stim", "Craft field stim", []), + new QuestStepDefRow("chain_step_token", "Hand off contract token", []), + ]), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(OperatorChainId, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal(4, def!.Steps.Count); + Assert.Equal("chain_step_token", def.Steps[3].Id); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenQuestIdIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(null, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenQuestIdIsEmpty() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(string.Empty, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_quest", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" Prototype_Quest_Gather_Intro ", out var normalized); + // Assert + Assert.True(found); + Assert.Equal(GatherIntroId, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(null, out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" ", out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [GatherIntroId] = CreateGatherIntroRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized); + // Assert + Assert.False(found); + Assert.Equal("prototype_unknown", normalized); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleQuests() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["z_quest"] = new QuestDefRow("z_quest", "Z", [], []), + [GatherIntroId] = CreateGatherIntroRow(), + ["a_quest"] = new QuestDefRow("a_quest", "A", [], []), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(3, list.Count); + Assert.Equal("a_quest", list[0].Id); + Assert.Equal(GatherIntroId, list[1].Id); + Assert.Equal("z_quest", list[2].Id); + } + + [Fact] + public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var registry = factory.Services.GetRequiredService(); + var gatherFound = registry.TryGetDefinition(GatherIntroId, out var gather); + var unknown = registry.TryGetDefinition("not_a_real_quest", out var missing); + var normalized = registry.TryNormalizeKnown(" Prototype_Quest_Gather_Intro ", out var questId); + var unknownNormalized = registry.TryNormalizeKnown("prototype_unknown", out var unknownQuestId); + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.True(gatherFound); + Assert.NotNull(gather); + Assert.Equal("Intro: Salvage Run", gather!.DisplayName); + Assert.False(unknown); + Assert.Null(missing); + Assert.True(normalized); + Assert.Equal(GatherIntroId, questId); + Assert.False(unknownNormalized); + Assert.Equal("prototype_unknown", unknownQuestId); + Assert.Equal(4, list.Count); + Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal), list.Select(q => q.Id)); + foreach (var expectedId in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds) + { + Assert.True(registry.TryGetDefinition(expectedId, out var def)); + Assert.NotNull(def); + Assert.False(string.IsNullOrWhiteSpace(def!.DisplayName)); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/IQuestDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Quests/IQuestDefinitionRegistry.cs new file mode 100644 index 0000000..86a2255 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/IQuestDefinitionRegistry.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E7.M1 (quest accept / progress / objective advance): callers validating quest ids and resolving quest metadata should depend on this interface +/// rather than so quest defs stay centralized. +/// NEO-115: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface IQuestDefinitionRegistry +{ + /// Attempts to resolve a quest by stable id (see quest-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? questId, [NotNullWhen(true)] out QuestDefRow? definition); + + /// Trims and lowercases ; returns true when the normalized id exists in the loaded catalog. null, empty, and whitespace return false without throwing. + bool TryNormalizeKnown(string? rawQuestId, [NotNullWhen(true)] out string normalized); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs index 10e3be7..622e726 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs @@ -8,10 +8,10 @@ using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Quests; -/// DI registration for the fail-fast quest catalog (NEO-113). +/// DI registration for the fail-fast quest catalog and registry (NEO-113, NEO-114). public static class QuestCatalogServiceCollectionExtensions { - /// Binds and registers as a singleton. + /// Binds and registers and as singletons. public static IServiceCollection AddQuestDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -58,6 +58,9 @@ public static class QuestCatalogServiceCollectionExtensions logger); }); + services.AddSingleton(sp => + new QuestDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionRegistry.cs new file mode 100644 index 0000000..b6c2d3b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionRegistry.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Quests; + +/// Adapter over (NEO-114). +public sealed class QuestDefinitionRegistry(QuestDefinitionCatalog catalog) : IQuestDefinitionRegistry +{ + private readonly IReadOnlyList _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog); + + /// + public bool TryGetDefinition(string? questId, [NotNullWhen(true)] out QuestDefRow? definition) + { + if (questId is null) + { + definition = null; + return false; + } + + return catalog.TryGetQuest(questId, out definition); + } + + /// + public bool TryNormalizeKnown(string? rawQuestId, [NotNullWhen(true)] out string normalized) + { + if (rawQuestId is null) + { + normalized = string.Empty; + return false; + } + + normalized = rawQuestId.Trim().ToLowerInvariant(); + if (normalized.Length == 0) + { + normalized = string.Empty; + return false; + } + + return catalog.TryGetQuest(normalized, out _); + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() => _definitionsInIdOrder; + + private static IReadOnlyList BuildDefinitionsInIdOrder(QuestDefinitionCatalog catalog) + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +} diff --git a/server/README.md b/server/README.md index 7ab6957..a9b85e7 100644 --- a/server/README.md +++ b/server/README.md @@ -146,7 +146,7 @@ On startup the host loads every **`*_quests.json`** under the quests directory * **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. +On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** 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 encounter/ability routes. ## Encounter definitions (NEO-103)