diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru new file mode 100644 index 0000000..164412e --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru @@ -0,0 +1,44 @@ +meta { + name: GET quest progress default + type: http + seq: 2 +} + +docs { + NEO-119: fresh dev-local-1 row before any quest accept — all four catalog quests not_started. +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/quest-progress + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); + expect(body.quests).to.be.an("array"); + expect(body.quests.length).to.equal(4); + }); + + test("all prototype quests are not_started with empty counters", function () { + const body = res.getBody(); + const expectedIds = [ + "prototype_quest_combat_intro", + "prototype_quest_gather_intro", + "prototype_quest_operator_chain", + "prototype_quest_refine_intro", + ]; + const actualIds = body.quests.map((row) => row.questId); + expect(actualIds).to.eql(expectedIds); + for (const row of body.quests) { + expect(row.status).to.equal("not_started"); + expect(row.currentStepIndex).to.equal(0); + expect(row.objectiveCounters).to.eql({}); + expect(row.completedAt).to.equal(undefined); + } + }); +} diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md index c0a8d83..638a5a9 100644 --- a/docs/decomposition/modules/E7_M1_QuestStateMachine.md +++ b/docs/decomposition/modules/E7_M1_QuestStateMachine.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M1 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Prototype | -| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; HTTP + client from **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) | +| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) per-player GET **landed**; accept + client from **E7M1-09** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) | | **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) | ## Purpose @@ -84,10 +84,12 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del **Player quest state store (NEO-116):** **`IPlayerQuestStateStore`** — in-memory + Postgres (`V008__player_quest_progress.sql`) per-player rows keyed by `(playerId, questId)`; implicit `not_started` when missing ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). -**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). HTTP read/accept deferred to E7M1-08/09. +**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). HTTP accept deferred to E7M1-09. **Quest objective wiring (NEO-118):** **`QuestObjectiveWiring`** — gather/craft/encounter success hooks + **`inventory_has_item`** on accept/step advance/inventory mutations; auto advance/complete via **`QuestStateOperations`** ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). +**Per-player quest progress GET (NEO-119):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; registry + store merge; GET-side **`inventory_has_item`** refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. + **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 e226078..3096646 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M1 | Planned | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **Backlog decomposed (HTTP + client from E7M1-08+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until E7M1-08+ HTTP and client capstone. | [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | +| E7.M1 | Planned | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **Backlog decomposed (accept + client from E7M1-09+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until E7M1-09+ accept HTTP and client capstone. | [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 3660ea5..d59f337 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl |---|---|---|---|---|---| | E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned | -**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); **E7M1-07 / NEO-118** **`QuestObjectiveWiring`** landed ([NEO-118 plan](../../plans/NEO-118-implementation-plan.md)); register row stays **Planned** until E7M1-08+ HTTP and client capstone. +**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); **E7M1-07 / NEO-118** **`QuestObjectiveWiring`** landed ([NEO-118 plan](../../plans/NEO-118-implementation-plan.md)); **E7M1-08 / NEO-119** per-player GET landed ([NEO-119 plan](../../plans/NEO-119-implementation-plan.md)); register row stays **Planned** until E7M1-09+ accept HTTP and client capstone. | E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned | | E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned | diff --git a/docs/plans/E7M1-prototype-backlog.md b/docs/plans/E7M1-prototype-backlog.md index a848b48..200a4e2 100644 --- a/docs/plans/E7M1-prototype-backlog.md +++ b/docs/plans/E7M1-prototype-backlog.md @@ -269,8 +269,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco **Acceptance criteria** -- [ ] GET returns stable v1 envelope for **`dev-local-1`** player. -- [ ] Completed quests show **`completed`** with step count at terminal step. +- [x] GET returns stable v1 envelope for **`dev-local-1`** player. +- [x] Completed quests show **`completed`** with step count at terminal step. **Client counterpart:** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — **`quest_progress_client.gd`** + HUD labels. diff --git a/docs/plans/NEO-119-implementation-plan.md b/docs/plans/NEO-119-implementation-plan.md new file mode 100644 index 0000000..4503ed2 --- /dev/null +++ b/docs/plans/NEO-119-implementation-plan.md @@ -0,0 +1,162 @@ +# NEO-119 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-119 | +| **Title** | E7M1-08: GET /game/players/{id}/quest-progress | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-119/e7m1-08-get-gameplayersidquest-progress | +| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-08** | +| **Branch** | `NEO-119-get-quest-progress` | +| **Precursor** | [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) — `QuestObjectiveWiring` gather/craft/encounter + `inventory_has_item` (**landed on `main`**) | +| **Pattern** | [NEO-108](NEO-108-implementation-plan.md) — registry-backed player GET + position gate + Bruno; [NEO-115](NEO-115-implementation-plan.md) — quest registry ordering | +| **Blocks** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — client quest progress HUD (E7M1-11) | +| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — **`quest_progress_client.gd`** + HUD labels poll this GET after gather/craft/encounter mutations (E7M1-11). Bruno-only verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **GET-side `inventory_has_item` re-eval** | Should GET re-run inventory snapshot wiring before building the response? (NEO-118 deferred “GET polling” to NEO-119.) | **Re-eval on GET** — call wiring inventory refresh + step completion pass so HUD reflects pre-held tokens (e.g. chain terminal **`contract_handoff_token`** from combat intro) without another inventory mutation. | **Adopted** — user confirmed | + +**Additional defaults (no kickoff question — settled by backlog / precedent):** + +- **`status`** strings: `not_started` / `active` / `completed` (NEO-116 implicit missing row → `not_started` at HTTP layer). +- **`objectiveCounters`**: object map `{ objectiveId: count }` — mirrors store JSONB and current-step-only semantics. +- **Player gate:** `IPositionStateStore.TryGetPosition` → **404** for unknown/blank ids (encounter-progress / gig-progression precedent). +- **Quest roster:** all catalog quests via **`GetDefinitionsInIdOrder()`**; ordinal `id` order matches NEO-115. +- **Manual QA doc:** skip `docs/manual-qa/NEO-119.md` — server-only; Bruno + API integration tests (NEO-108 / NEO-115 precedent). Accept flows in Bruno wait for [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120). + +## Goal, scope, and out-of-scope + +**Goal:** Expose authoritative per-player quest progress over HTTP so clients and Bruno can read server-owned status, step index, objective counters, and completion timestamp without local objective math. + +**In scope (from Linear + [E7M1-08](E7M1-prototype-backlog.md#e7m1-08--get-gameplayersidquest-progress)):** + +- **`GET /game/players/{id}/quest-progress`** with versioned envelope (`schemaVersion` **1**, **`playerId`**, **`quests`** array). +- Per-quest row: **`questId`**, **`status`**, **`currentStepIndex`**, **`objectiveCounters`**, optional **`completedAt`** when **`completed`**. +- Projection from **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`** (missing row ⇒ **`not_started`**). +- **GET hook:** best-effort **`inventory_has_item`** refresh + step auto-advance/complete before snapshot (kickoff decision). +- Known-player gate (position store). +- Bruno `bruno/neon-sprawl-server/quest-progress/` — default read + shape assertions. +- Integration tests (AAA): unknown player 404; default all `not_started`; accept → partial counter → completed terminal step index. +- `server/README.md` route section. + +**Out of scope (from Linear + backlog):** + +- Quest definition catalog GET ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — already landed). +- **`POST …/quests/{questId}/accept`** ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) / E7M1-09). +- Godot HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)). +- Telemetry hook comments ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)). +- Dev fixture reset API for quest rows. +- `docs/manual-qa/NEO-119.md` (server-only per kickoff). + +## Acceptance criteria checklist + +- [x] GET returns stable v1 envelope for **`dev-local-1`** player (four quests, `schemaVersion` 1). +- [x] Completed quests show **`completed`** with **`currentStepIndex`** at terminal step (0-based; e.g. operator chain index **3**). +- [x] Bruno + integration tests pass in CI. + +## Implementation reconciliation (shipped) + +- **Route:** `GET /game/players/{id}/quest-progress` — `QuestProgressApi.BuildSnapshot`; 404 via `IPositionStateStore`. +- **GET hook:** `QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer` — inventory refresh + step completion before snapshot. +- **DTOs:** `QuestProgressListResponse` (schema v1); row `status` not_started/active/completed; `objectiveCounters` map; `completedAt` when completed. +- **Tests:** seven AAA cases in `QuestProgressApiTests`. +- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru`. +- **Docs:** `server/README.md`; E7.M1 module snapshot + alignment register + backlog updated. + +## Technical approach + +1. **Route:** **`GET /game/players/{id}/quest-progress`** — inject **`IPositionStateStore`**, **`IQuestDefinitionRegistry`**, **`IPlayerQuestStateStore`**, **`IPlayerInventoryStore`**, **`IItemDefinitionRegistry`**, **`TimeProvider`**. + +2. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`EncounterProgressApi`**). + +3. **GET-side wiring (kickoff):** Before building JSON, best-effort call a new public helper on **`QuestObjectiveWiring`** (e.g. **`TryRefreshInventoryProgressForPlayer`**) that: + - wraps existing **`RefreshInventoryHasItemCountersForPlayer`** + - then runs the same step-completion sweep as gather/craft paths (**`TryCompleteAllActiveQuests`** — extract from private to shared internal/public entry) + - swallows/logs at debug; never fails the HTTP response + +4. **Status derivation** (per catalog quest id): + + | Store row | **`status`** | **`currentStepIndex`** | **`objectiveCounters`** | **`completedAt`** | + |-----------|--------------|------------------------|-------------------------|-------------------| + | Missing | `not_started` | `0` | `{}` | omitted | + | Active | `active` | row index | row counters map | omitted | + | Completed | `completed` | terminal index (unchanged at complete) | last-step counters (may be empty after terminal satisfy) | ISO-8601 from row | + +5. **Ordering:** Build rows from **`questRegistry.GetDefinitionsInIdOrder()`** — ids match **`PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`** ordinal order. + +6. **Implementation:** **`QuestProgressApi`** + **`QuestProgressListDtos.cs`** with internal **`BuildSnapshot(playerId, …)`** testable like **`EncounterProgressApi.BuildSnapshot`**. Register **`app.MapQuestProgressApi()`** in **`Program.cs`** after **`MapEncounterProgressApi()`**. + +7. **Bruno:** `bruno/neon-sprawl-server/quest-progress/` + - **`Get quest progress default.bru`** — GET **`dev-local-1`** → 200, `schemaVersion` 1, `quests.length === 4`, all `not_started`, empty counters. + - Keep existing health smoke request. + - Accept / partial-progress Bruno flows deferred until NEO-120 POST lands. + +8. **Docs:** Add **`server/README.md`** GET section (curl example, GET inventory refresh note). Update NEO-118 README deferral line. Module snapshot / alignment register / E7M1 backlog on story land. + +### Expected v1 envelope (prototype default) + +```json +{ + "schemaVersion": 1, + "playerId": "dev-local-1", + "quests": [ + { "questId": "prototype_quest_combat_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} }, + { "questId": "prototype_quest_gather_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} }, + { "questId": "prototype_quest_operator_chain", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} }, + { "questId": "prototype_quest_refine_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} } + ] +} +``` + +### Wiring flow (GET) + +```mermaid +flowchart TD + R[GET quest-progress] --> G{Known player?} + G -->|no| N404[404] + G -->|yes| W[QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer] + W --> B[BuildSnapshot registry + store] + B --> J[JSON v1 response] +``` + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | `Map*` extension; player gate; GET inventory refresh hook; `BuildSnapshot`. | +| `server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs` | Versioned list response + per-quest row DTOs (`status`, counters map). | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | AAA HTTP integration: 404, default not_started, active partial, completed terminal index, GET inventory refresh. | +| `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru` | Bruno default-state verification. | +| `docs/plans/NEO-119-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Public GET-hook entry (`TryRefreshInventoryProgressForPlayer`) sharing refresh + step-completion sweep. | +| `server/NeonSprawl.Server/Program.cs` | Register `MapQuestProgressApi()` after encounter progress API. | +| `server/README.md` | Document GET route, response shape, GET-side `inventory_has_item` refresh; replace E7M1-08 deferral stubs. | +| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | HTTP quest-progress read bullet (on story complete). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — NEO-119 HTTP (on story complete). | +| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-119 (on story complete). | +| `docs/plans/E7M1-prototype-backlog.md` | E7M1-08 acceptance checkboxes (on story complete). | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `QuestProgressApiTests.cs` | **404:** unknown player + whitespace id. **Default:** GET `dev-local-1` → schema v1, four quests in ordinal id order, all `not_started`, `currentStepIndex` 0, empty `objectiveCounters`. **Active partial:** `QuestStateOperations.TryAccept` gather intro + `TryUpdateObjectiveCounter` → GET shows `active`, counter 2/3 on gather objective. **Completed:** accept + satisfy gather intro via operations/wiring helpers → GET shows `completed`, `currentStepIndex` 0, `completedAt` present. **Chain terminal:** operator chain completed → `currentStepIndex` 3. **GET refresh:** active chain on terminal `inventory_has_item` step with token pre-seeded in inventory (no new grant) → GET triggers complete (side-effecting read). | +| Existing quest test suite | Regression smoke — no behavior change to wiring stores when GET not called. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Side-effecting GET** | Adopted per kickoff — document in README; mirror deferred NEO-118 “polling hook” intent for client HUD | **adopted** | +| **Private `TryCompleteAllActiveQuests`** | Extract shared internal method invoked by GET hook and existing gather/craft/encounter paths | **adopted** | +| **Bruno without accept POST** | Default-state Bruno only this story; accept spine Bruno joins NEO-120 | **adopted** | +| **Completed row counters** | Project store counters as-is (terminal step may show satisfied counts or empty after advance logic) — assert `status` + `currentStepIndex` + `completedAt` in tests | **adopted** | diff --git a/docs/reviews/2026-06-07-NEO-119.md b/docs/reviews/2026-06-07-NEO-119.md new file mode 100644 index 0000000..76f3167 --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-119.md @@ -0,0 +1,65 @@ +# Code review — NEO-119 (E7M1-08) + +**Date:** 2026-06-07 +**Scope:** Branch `NEO-119-get-quest-progress` vs `origin/main` — commits `78e5de3` … `9e4c52f` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-119 adds **`GET /game/players/{id}/quest-progress`** — a versioned v1 snapshot merging **`IQuestDefinitionRegistry`** catalog rows with **`IPlayerQuestStateStore`** progress (missing row → `not_started`). The handler mirrors **`EncounterProgressApi`**: trim id, position-store gate → **404**, then JSON. Before snapshot build it best-effort calls **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** (inventory refresh + step completion sweep), adopting the kickoff side-effecting GET decision for HUD polling. Seven AAA integration tests cover 404 gates, default `not_started`, active partial counters, completed gather/chain terminal index, and GET-triggered chain completion with a pre-held token. Bruno default-state smoke, **`server/README.md`**, plan reconciliation, and E7.M1 decomposition docs are updated. Server-only slice — client HUD correctly deferred to NEO-122. Risk is low; no blocking correctness or contract issues found. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-119-implementation-plan.md` | **Matches** — kickoff GET refresh adopted; acceptance checklist checked; reconciliation section accurate. | +| `docs/plans/E7M1-prototype-backlog.md` (E7M1-08) | **Matches** — acceptance checkboxes checked; client counterpart NEO-122 noted. | +| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-119 GET bullet + README/Bruno links. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-08 / NEO-119 landed note; register stays **Planned** until E7M1-09+ (correct). | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — NEO-119 note appended. | +| `server/README.md` | **Matches** — route, envelope, side-effecting GET note, sample JSON. | +| Full-stack epic decomposition | **Matches** — server-only story; plan explicitly defers Godot to NEO-122; does not claim prototype slice complete. | + +**Register / tracking:** E7.M1 row correctly remains **Planned** until accept HTTP (NEO-120) and client capstone (NEO-123); no further register edits required for this merge. + +## Blocking issues + +(none) + +## Suggestions + +1. ~~**Assert response array order explicitly** — `GetQuestProgress_ShouldReturnNotStartedForAllQuests_WhenNoProgress` and the Bruno default test compare **sorted** quest ids. The plan and DTO doc promise catalog **`id`** ordinal order (same as **`GetDefinitionsInIdOrder()`**). For the fixed four-quest prototype, sorted vs sequence is equivalent today, but asserting sequence (as in `QuestDefinitionRegistryTests`) would lock the contract if ids ever stop sorting identically to registry order.~~ **Done.** Test asserts `GetDefinitionsInIdOrder()` sequence; Bruno compares response order without sort. + +2. ~~**Side-effecting GET test store read-back** — `GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet` asserts HTTP `completed` and pre-GET `Active`, but does not re-read **`IPlayerQuestStateStore`** after GET. Adding a post-GET store assertion would strengthen coverage of the documented mutating read semantics (mirrors the `beforeGet` guard already in Arrange).~~ **Done.** Post-GET `TryGetProgress` asserts `Completed`, terminal step index, and `CompletedAt`. + +## Nits + +- Nit: **`MapRow`** throws **`InvalidOperationException`** on unknown **`QuestProgressStatus`** (would surface as 500). Acceptable defensive guard; optional direct **`BuildSnapshot`** unit test if parity with **`EncounterProgressApiTests`** corrupt-state tests is desired later. + +- Nit: **`QuestProgressApiTests`** helper block (~200 lines) duplicates patterns from **`QuestObjectiveWiringTests`** / encounter quest helpers — acceptable for integration isolation; consider shared test fixture only if NEO-120 adds more HTTP quest tests. + +## Verification + +```bash +# Primary signal for this story +cd server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~QuestProgressApiTests" + +# Quest module regression +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~NeonSprawl.Server.Tests.Game.Quests" + +# Full server suite (CI) +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +``` + +**Bruno:** run `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru` against a local server with fresh `dev-local-1`. + +**Reviewer note:** All 7 `QuestProgressApiTests` and 85 quest-namespace tests passed locally during review. + +**Manual:** None required beyond Bruno for this server-only story; Godot HUD verification belongs to NEO-122. diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs new file mode 100644 index 0000000..ef1d379 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -0,0 +1,361 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public sealed class QuestProgressApiTests +{ + private const string PlayerId = "dev-local-1"; + private const string AlphaNodeId = "prototype_resource_node_alpha"; + private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; + private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; + private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId; + private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; + private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId; + private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; + + [Fact] + public async Task GetQuestProgress_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/%20/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnNotStartedForAllQuests_WhenNoProgress() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var registry = factory.Services.GetRequiredService(); + var expectedQuestOrder = registry.GetDefinitionsInIdOrder().Select(static d => d.Id).ToArray(); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(QuestProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal(PlayerId, body.PlayerId); + Assert.Equal(expectedQuestOrder.Length, body.Quests.Count); + Assert.Equal(expectedQuestOrder, body.Quests.Select(static row => row.QuestId).ToArray()); + foreach (var row in body.Quests) + { + Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status); + Assert.Equal(0, row.CurrentStepIndex); + Assert.Empty(row.ObjectiveCounters); + Assert.Null(row.CompletedAt); + } + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnActivePartialCounters_WhenGatherIntroInProgress() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + Assert.True(TryAccept(deps, GatherQuestId).Success); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter( + PlayerId, + GatherQuestId, + GatherObjectiveId, + 2, + out _)); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId); + Assert.Equal(QuestProgressApi.StatusActive, row.Status); + Assert.Equal(0, row.CurrentStepIndex); + Assert.Equal(2, row.ObjectiveCounters[GatherObjectiveId]); + Assert.Null(row.CompletedAt); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnCompletedGatherIntro_WhenObjectivesSatisfied() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + Assert.True(TryAccept(deps, GatherQuestId).Success); + for (var i = 0; i < 3; i++) + { + Assert.True(TryGather(deps, AlphaNodeId).Success); + } + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId); + Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); + Assert.Equal(0, row.CurrentStepIndex); + Assert.NotNull(row.CompletedAt); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnCompletedChainAtTerminalStepIndex_WhenOperatorChainFinished() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + CompleteOperatorChain(deps); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == ChainQuestId); + Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); + Assert.Equal(3, row.CurrentStepIndex); + Assert.NotNull(row.CompletedAt); + } + + [Fact] + public async Task GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + AcceptAndMarkComplete(deps, GatherQuestId); + AcceptAndMarkComplete(deps, RefineQuestId); + AcceptAndMarkComplete(deps, CombatQuestId); + SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1); + Assert.True(TryAccept(deps, ChainQuestId).Success); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _)); + Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 1, out _)); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _)); + Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 2, out _)); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _)); + Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 3, out _)); + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var beforeGet)); + Assert.Equal(QuestProgressStatus.Active, beforeGet.Status); + Assert.Equal(3, beforeGet.CurrentStepIndex); + + // Act + var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == ChainQuestId); + Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); + Assert.Equal(3, row.CurrentStepIndex); + Assert.NotNull(row.CompletedAt); + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var afterGet)); + Assert.Equal(QuestProgressStatus.Completed, afterGet.Status); + Assert.Equal(3, afterGet.CurrentStepIndex); + Assert.NotNull(afterGet.CompletedAt); + } + + private static void CompleteOperatorChain(QuestWiringTestDependencies deps) + { + AcceptAndMarkComplete(deps, GatherQuestId); + AcceptAndMarkComplete(deps, RefineQuestId); + CompleteCombatEncounter(deps); + Assert.True(TryAccept(deps, ChainQuestId).Success); + Assert.True(TryGather(deps, "prototype_urban_bulk_delta").Success); + SeedStack(deps, "scrap_metal_bulk", 5); + Assert.True(TryCraft(deps, "refine_scrap_standard").Success); + SeedStack(deps, "refined_plate_stock", 2); + SeedStack(deps, "scrap_metal_bulk", 1); + Assert.True(TryCraft(deps, "make_field_stim_mk0").Success); + Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress)); + Assert.Equal(QuestProgressStatus.Completed, progress.Status); + Assert.Equal(3, progress.CurrentStepIndex); + } + + private static void CompleteCombatEncounter(QuestWiringTestDependencies deps) + { + Assert.True(TryAccept(deps, CombatQuestId).Success); + DefeatAllRequiredTargets(deps); + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + "prototype_combat_pocket", + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.EncounterProgressStore, + deps.CompletionStore, + deps.CompleteEventStore, + deps.ItemRegistry, + deps.InventoryStore, + deps.QuestRegistry, + deps.ProgressStore, + deps.TimeProvider); + Assert.True(result.Success); + } + + private static void DefeatAllRequiredTargets(QuestWiringTestDependencies deps) + { + foreach (var npcId in new[] { "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" }) + { + _ = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + npcId, + deps.EncounterRegistry, + deps.EncounterProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + npcId, + deps.EncounterRegistry, + deps.EncounterProgressStore, + deps.CompletionStore); + } + } + + private static QuestStateOperationResult TryAccept(QuestWiringTestDependencies deps, string questId) => + QuestStateOperations.TryAccept( + PlayerId, + questId, + deps.QuestRegistry, + deps.ProgressStore, + deps.InventoryStore, + deps.ItemRegistry, + deps.TimeProvider); + + private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) + { + Assert.True(TryAccept(deps, questId).Success); + Assert.True(QuestStateOperations.TryMarkComplete( + PlayerId, + questId, + deps.QuestRegistry, + deps.ProgressStore, + deps.TimeProvider).Success); + } + + private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) => + GatherOperations.TryGather( + PlayerId, + nodeId, + deps.NodeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.InstanceStore, + deps.QuestRegistry, + deps.ProgressStore, + deps.TimeProvider); + + private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => + CraftOperations.TryCraft( + PlayerId, + recipeId, + quantity: 1, + deps.RecipeRegistry, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.XpStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.QuestRegistry, + deps.ProgressStore, + deps.TimeProvider); + + private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) + { + var outcome = PlayerInventoryOperations.TryAddStack( + PlayerId, + itemId, + quantity, + deps.ItemRegistry, + deps.InventoryStore); + Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind); + } + + private static QuestWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new QuestWiringTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + TimeProvider.System); + } + + private readonly record struct QuestWiringTestDependencies( + IQuestDefinitionRegistry QuestRegistry, + IPlayerQuestStateStore ProgressStore, + IPlayerInventoryStore InventoryStore, + IItemDefinitionRegistry ItemRegistry, + IResourceNodeDefinitionRegistry NodeRegistry, + IResourceNodeInstanceStore InstanceStore, + IRecipeDefinitionRegistry RecipeRegistry, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore XpStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IEncounterDefinitionRegistry EncounterRegistry, + IRewardTableDefinitionRegistry RewardTableRegistry, + IEncounterProgressStore EncounterProgressStore, + IEncounterCompletionStore CompletionStore, + IEncounterCompleteEventStore CompleteEventStore, + TimeProvider TimeProvider); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs index 09da6da..1113ab1 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs @@ -199,6 +199,42 @@ public static class QuestObjectiveWiring } } + /// + /// GET polling hook (NEO-119): refresh counters and + /// auto-advance/complete active quests before a progress snapshot is returned. + /// + public static void TryRefreshInventoryProgressForPlayer( + string playerId, + IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore, + IPlayerInventoryStore inventoryStore, + IItemDefinitionRegistry itemRegistry, + TimeProvider timeProvider, + ILogger? logger = null) + { + try + { + RefreshInventoryHasItemCountersForPlayer( + playerId, + questRegistry, + progressStore, + inventoryStore, + itemRegistry); + + TryCompleteAllActiveQuests( + playerId, + questRegistry, + progressStore, + inventoryStore, + itemRegistry, + timeProvider); + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Quest progress GET refresh skipped for player {PlayerId}", playerId); + } + } + /// Re-evaluates on the active step for one quest. public static void TryProcessInventoryHasItemForQuest( string playerId, diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs new file mode 100644 index 0000000..6403e75 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -0,0 +1,99 @@ +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Quests; + +/// Maps GET /game/players/{{id}}/quest-progress (NEO-119). +public static class QuestProgressApi +{ + public const string StatusNotStarted = "not_started"; + public const string StatusActive = "active"; + public const string StatusCompleted = "completed"; + + public static WebApplication MapQuestProgressApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/quest-progress", + (string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, + IItemDefinitionRegistry itemRegistry, TimeProvider timeProvider, ILogger? logger) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer( + trimmedId, + questRegistry, + progressStore, + inventoryStore, + itemRegistry, + timeProvider, + logger); + + return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore)); + }); + + return app; + } + + internal static QuestProgressListResponse BuildSnapshot( + string playerId, + IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore) + { + var defs = questRegistry.GetDefinitionsInIdOrder(); + var rows = new List(defs.Count); + foreach (var def in defs) + { + rows.Add(MapRow(playerId, def.Id, progressStore)); + } + + return new QuestProgressListResponse + { + PlayerId = playerId, + Quests = rows, + SchemaVersion = QuestProgressListResponse.CurrentSchemaVersion, + }; + } + + private static QuestProgressRowJson MapRow( + string playerId, + string questId, + IPlayerQuestStateStore progressStore) + { + if (!progressStore.TryGetProgress(playerId, questId, out var snapshot)) + { + return new QuestProgressRowJson + { + QuestId = questId, + Status = StatusNotStarted, + CurrentStepIndex = 0, + ObjectiveCounters = new Dictionary(StringComparer.Ordinal), + }; + } + + var counters = new Dictionary(snapshot.ObjectiveCounters, StringComparer.Ordinal); + return snapshot.Status switch + { + QuestProgressStatus.Active => new QuestProgressRowJson + { + QuestId = questId, + Status = StatusActive, + CurrentStepIndex = snapshot.CurrentStepIndex, + ObjectiveCounters = counters, + }, + QuestProgressStatus.Completed => new QuestProgressRowJson + { + QuestId = questId, + Status = StatusCompleted, + CurrentStepIndex = snapshot.CurrentStepIndex, + ObjectiveCounters = counters, + CompletedAt = snapshot.CompletedAt, + }, + _ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."), + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs new file mode 100644 index 0000000..a0f8803 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Quests; + +/// JSON body for GET /game/players/{{id}}/quest-progress (NEO-119). +public sealed class QuestProgressListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + /// Per-player rows ordered by catalog quest id (ordinal). + [JsonPropertyName("quests")] + public required IReadOnlyList Quests { get; init; } +} + +/// Authoritative per-player progress for one quest definition. +public sealed class QuestProgressRowJson +{ + [JsonPropertyName("questId")] + public required string QuestId { get; init; } + + /// not_started, active, or completed. + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("currentStepIndex")] + public int CurrentStepIndex { get; init; } + + /// Objective id → accumulated count for the current step only. + [JsonPropertyName("objectiveCounters")] + public required IReadOnlyDictionary ObjectiveCounters { get; init; } + + [JsonPropertyName("completedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? CompletedAt { get; init; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 70118b9..9c65166 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -77,6 +77,7 @@ app.MapPlayerCraftApi(); app.MapSkillProgressionSnapshotApi(); app.MapGigProgressionSnapshotApi(); app.MapEncounterProgressApi(); +app.MapQuestProgressApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") || diff --git a/server/README.md b/server/README.md index d5957ec..9a66e3d 100644 --- a/server/README.md +++ b/server/README.md @@ -158,7 +158,7 @@ curl -sS -i "http://localhost:5253/game/world/quest-definitions" ## Quest progress store (NEO-116) -Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Game code should call **`QuestStateOperations`** (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP read/accept routes land in E7M1-08/09 (NEO-119/NEO-120). +Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Game code should call **`QuestStateOperations`** (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP accept lands in E7M1-09 (NEO-120). **Store interface methods:** @@ -171,7 +171,7 @@ Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by * Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids are validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** in **`QuestStateOperations`**. -**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresPlayerQuestStateStore`** persists to **`player_quest_progress`** ([`V008__player_quest_progress.sql`](../db/migrations/V008__player_quest_progress.sql)) with **`objective_counters`** JSONB for the current step. Plan: [NEO-116 implementation plan](../../docs/plans/NEO-116-implementation-plan.md). Bruno startup smoke: `bruno/neon-sprawl-server/quest-progress/` (health only until E7M1-08 HTTP). +**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresPlayerQuestStateStore`** persists to **`player_quest_progress`** ([`V008__player_quest_progress.sql`](../db/migrations/V008__player_quest_progress.sql)) with **`objective_counters`** JSONB for the current step. Plan: [NEO-116 implementation plan](../../docs/plans/NEO-116-implementation-plan.md). Bruno: `bruno/neon-sprawl-server/quest-progress/`. ### Quest state operations (NEO-117) @@ -206,7 +206,23 @@ Completed rows cannot regress to active without a reset API (none in prototype). | **`EncounterCompletionOperations.TryCompleteAndGrant`** success | **`encounter_complete`** | Encounter id (counter → 1). | | Inventory mutations above + **`QuestStateOperations.TryAccept`** / **`TryAdvanceStep`** | **`inventory_has_item`** | Bag snapshot read; counter set to `min(held, required)`. | -**`inventory_has_item`** is **not** re-evaluated for other objective kinds on accept/step entry (event-driven only for gather/craft/encounter). HTTP **`GET …/quest-progress`** is E7M1-08 ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)). Plan: [NEO-118 implementation plan](../../docs/plans/NEO-118-implementation-plan.md). +**`inventory_has_item`** is **not** re-evaluated for other objective kinds on accept/step entry (event-driven only for gather/craft/encounter). Plan: [NEO-118 implementation plan](../../docs/plans/NEO-118-implementation-plan.md). + +### Per-player quest progress (NEO-119) + +**`GET /game/players/{id}/quest-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`quests`**) backed by **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`**. Unknown or blank player ids return **404** (position gate, same as encounter-progress). Each row includes **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`** (objective id → count for the current step only), and optional **`completedAt`** when **`completed`**. Quests are ordered by catalog **`id`** (ordinal). + +Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md). + +```bash +curl -sS "http://localhost:5253/game/players/dev-local-1/quest-progress" +``` + +Sample default response (no quests accepted): + +```json +{"schemaVersion":1,"playerId":"dev-local-1","quests":[{"questId":"prototype_quest_combat_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_gather_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_operator_chain","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_refine_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}}]} +``` ## Encounter definitions (NEO-103)