Merge pull request #154 from ViPro-Technologies/NEO-115-get-world-quest-definitions
NEO-115: GET /game/world/quest-definitionspull/157/head
commit
91d6cba34c
|
|
@ -0,0 +1,57 @@
|
|||
meta {
|
||||
name: GET quest definitions
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/world/quest-definitions
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 JSON with schema v1 and quests array", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
expect(res.getHeader("content-type")).to.contain("application/json");
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.quests).to.be.an("array");
|
||||
expect(body.quests.length).to.equal(4);
|
||||
});
|
||||
|
||||
test("quests are ascending by id (ordinal)", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.quests.map((x) => x.id);
|
||||
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
expect(ids).to.eql(sorted);
|
||||
});
|
||||
|
||||
test("prototype_quest_gather_intro row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.quests.find((x) => x.id === "prototype_quest_gather_intro");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Intro: Salvage Run");
|
||||
expect(row.prerequisiteQuestIds).to.eql([]);
|
||||
expect(row.steps).to.have.length(1);
|
||||
expect(row.steps[0].objectives[0]).to.eql({
|
||||
id: "gather_intro_obj_scrap",
|
||||
kind: "gather_item",
|
||||
itemId: "scrap_metal_bulk",
|
||||
quantity: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("prototype_quest_operator_chain has four steps and terminal token objective", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.quests.find((x) => x.id === "prototype_quest_operator_chain");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.steps).to.have.length(4);
|
||||
expect(row.steps[3].objectives[0]).to.eql({
|
||||
id: "chain_obj_token",
|
||||
kind: "inventory_has_item",
|
||||
itemId: "contract_handoff_token",
|
||||
quantity: 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: quest-definitions
|
||||
}
|
||||
|
|
@ -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**; 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) |
|
||||
| **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**; runtime from **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) → 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
|
||||
|
|
@ -80,6 +80,8 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
|
|||
|
||||
**Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113).
|
||||
|
||||
**HTTP read model (NEO-115):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs in `Game/Quests/` ([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/`.
|
||||
|
||||
**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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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)); register row stays **Planned** until quest runtime (E7M1-05+) lands.
|
||||
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **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)); 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 |
|
||||
|
|
|
|||
|
|
@ -166,8 +166,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] GET returns all four prototype quests with stable JSON v1 envelope.
|
||||
- [ ] Bruno request documents example response shape.
|
||||
- [x] GET returns all four prototype quests with stable JSON v1 envelope.
|
||||
- [x] Bruno request documents example response shape.
|
||||
|
||||
**Client counterpart:** consumed by [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) for display names (optional cache).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
# NEO-115 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-115 |
|
||||
| **Title** | E7M1-04: GET /game/world/quest-definitions |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-115/e7m1-04-get-gameworldquest-definitions |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-04** |
|
||||
| **Branch** | `NEO-115-get-world-quest-definitions` |
|
||||
| **Precursor** | [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) — injectable quest definition registry + DI (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-103](NEO-103-implementation-plan.md) — world definition GET + Bruno; [NEO-68](NEO-68-implementation-plan.md) — registry-backed projection |
|
||||
| **Blocks** | [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) — player quest state store (E7M1-05); E7M1-06+ runtime |
|
||||
| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — optional cache of display names / step summaries (E7M1-11); not wired this story |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Objective JSON shape** | Flat optional fields vs nested per-kind objects? | **Flat mirror content** — `id` + `kind` + only relevant fields; omit unused keys (`JsonIgnoreCondition.WhenWritingNull`) — matches `quest-objective-def.schema.json` and `content/quests/prototype_quests.json` for NEO-122. | **Adopted** — flat mirror |
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-115.md`? | **Skip** — server-only route; Bruno + API integration tests ([NEO-103](NEO-103-implementation-plan.md) precedent). | **Adopted** — skip |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Expose a stable, read-only JSON endpoint that returns the frozen prototype quest definitions (four quests) for Bruno, tooling, and optional client display ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)) — backed by **`IQuestDefinitionRegistry`** without duplicating catalog truth.
|
||||
|
||||
**In scope (from Linear + [E7M1-04](E7M1-prototype-backlog.md#e7m1-04--get-gameworldquest-definitions)):**
|
||||
|
||||
- **`GET /game/world/quest-definitions`** with versioned envelope (`schemaVersion` **1**, **`quests`** array).
|
||||
- Quest row fields: **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective projection).
|
||||
- Quests ordered by **`id`** ordinal, matching **`GetDefinitionsInIdOrder()`**.
|
||||
- Bruno folder `bruno/neon-sprawl-server/quest-definitions/`.
|
||||
- `server/README.md` route section.
|
||||
- API integration tests (AAA).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Per-player progress ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) / E7M1-08).
|
||||
- Accept/advance hooks, stores (E7M1-05+).
|
||||
- Godot / client wiring ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)).
|
||||
- `docs/manual-qa/NEO-115.md` (server-only per kickoff).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] GET returns all four prototype quests with stable JSON v1 envelope (`schemaVersion` 1, `quests[]`).
|
||||
- [x] Bruno request documents example response shape and passes in CI Bruno step.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Route:** `GET /game/world/quest-definitions` — injects `IQuestDefinitionRegistry`.
|
||||
- **DTOs:** `QuestDefinitionsListResponse` (schema v1, `quests[]`); nested `steps` / flat `objectives` with `WhenWritingNull` on unused objective fields.
|
||||
- **Wiring:** `MapQuestDefinitionsWorldApi()` in `Program.cs` after encounter definitions.
|
||||
- **Tests:** AAA integration test in `QuestDefinitionsWorldApiTests`.
|
||||
- **Bruno:** `bruno/neon-sprawl-server/quest-definitions/`.
|
||||
- **Docs:** `server/README.md` section; E7.M1 module snapshot + alignment register + backlog updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`GET /game/world/quest-definitions`** — parameterless read; inject **`IQuestDefinitionRegistry`** only (not `QuestDefinitionCatalog`).
|
||||
|
||||
2. **Response shape:** Top-level **`schemaVersion`** (`QuestDefinitionsListResponse.CurrentSchemaVersion` = **1**) plus **`quests`** array. Each row maps from **`QuestDefRow`**:
|
||||
- **`id`**, **`displayName`**, **`prerequisiteQuestIds`** (preserve catalog order)
|
||||
- **`steps`**: array of `{ id, displayName, objectives[] }`
|
||||
- **`objectives`**: flat mirror of content — **`id`**, **`kind`**, and only populated fields for that kind:
|
||||
- `gather_item` / `inventory_has_item`: **`itemId`**, **`quantity`**
|
||||
- `craft_recipe`: **`recipeId`**, **`quantity`**
|
||||
- `encounter_complete`: **`encounterId`**
|
||||
- Optional objective properties use **`[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`** so unused keys are omitted (not `null`).
|
||||
|
||||
3. **Ordering:** Build list from **`questRegistry.GetDefinitionsInIdOrder()`**. Prototype freeze: four ids in **`PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`** (ordinal by `id`).
|
||||
|
||||
4. **Implementation:** New **`QuestDefinitionsWorldApi`** + **`QuestDefinitionsListDtos.cs`** in `Game/Quests/` (mirror [`EncounterDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs)). Wire **`app.MapQuestDefinitionsWorldApi()`** from **`Program.cs`** after **`MapEncounterDefinitionsWorldApi()`** (quest catalog loads after encounters).
|
||||
|
||||
5. **Bruno:** `bruno/neon-sprawl-server/quest-definitions/` with **`Get quest definitions.bru`** + **`folder.bru`**. Tests: 200, JSON, **`schemaVersion` 1**, **`quests.length === 4`**, ascending id order, spot-check **`prototype_quest_gather_intro`** (gather objective) and **`prototype_quest_operator_chain`** (four steps, terminal **`inventory_has_item`** objective).
|
||||
|
||||
6. **Docs:** Add **`server/README.md`** section (GET + curl). Update [E7_M1](E7_M1_QuestStateMachine.md) implementation snapshot and [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M1 row when landed.
|
||||
|
||||
### Expected prototype roster (from content)
|
||||
|
||||
| `id` | `displayName` | `prerequisiteQuestIds` | Steps |
|
||||
|------|---------------|------------------------|-------|
|
||||
| `prototype_quest_gather_intro` | Intro: Salvage Run | `[]` | 1 — gather `scrap_metal_bulk` ×3 |
|
||||
| `prototype_quest_refine_intro` | Intro: Refine Stock | `[prototype_quest_gather_intro]` | 1 — craft `refine_scrap_standard` ×1 |
|
||||
| `prototype_quest_combat_intro` | Intro: Clear the Pocket | `[]` | 1 — encounter `prototype_combat_pocket` |
|
||||
| `prototype_quest_operator_chain` | Operator Chain | all three intros | 4 — gather ×5 → refine → stim → token ×1 |
|
||||
|
||||
**HTTP `quests` order (ordinal `id`):** `prototype_quest_combat_intro`, `prototype_quest_gather_intro`, `prototype_quest_operator_chain`, `prototype_quest_refine_intro`.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs` | `Map*` extension registering GET route; maps registry → JSON. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs` | Versioned response + quest/step/objective DTOs for JSON serialization. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs` | HTTP integration: 200, schema v1, four quests, frozen rows + chain depth. |
|
||||
| `bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru` | Manual / CI Bruno verification against dev server. |
|
||||
| `bruno/neon-sprawl-server/quest-definitions/folder.bru` | Bruno folder metadata. |
|
||||
| `docs/plans/NEO-115-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapQuestDefinitionsWorldApi()` after encounter definitions world API. |
|
||||
| `server/README.md` | Document `GET /game/world/quest-definitions`, curl example, registry injection note. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | Implementation snapshot — HTTP read model bullet (NEO-115). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — note NEO-115 HTTP projection when landed. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` | Mark E7M1-04 acceptance criteria landed when complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs` | **Arrange:** `InMemoryWebApplicationFactory`. **Act:** `GET /game/world/quest-definitions`. **Assert:** 200 OK; `schemaVersion` 1; `quests` length 4; ids match `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` in ordinal order; gather intro: `displayName` Intro: Salvage Run, one step, `gather_item` objective with `scrap_metal_bulk` quantity 3 (no spurious null keys on objective JSON where testable via deserialize); refine intro prerequisite contains `prototype_quest_gather_intro`; operator chain: four steps, last objective `inventory_has_item` + `contract_handoff_token` quantity 1; combat intro objective `encounter_complete` + `prototype_combat_pocket`. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Objective null omission** | Use `WhenWritingNull` on optional objective DTO properties; integration test deserializes into typed DTOs (not raw JSON key scan) unless a dedicated raw-body assert is added for one objective. | **adopted** |
|
||||
| **No second registry** | Unlike NEO-103, quest rows are self-contained — only `IQuestDefinitionRegistry`; cross-refs validated at catalog load (NEO-113). | **adopted** |
|
||||
| **Constants in tests** | Reuse `PrototypeE7M1QuestCatalogRules` ids / `ChainTerminalItemId`; avoid duplicating id strings. | **adopted** |
|
||||
| **Bruno vs C# test drift** | Keep frozen spot-check values in sync with `content/quests/prototype_quests.json` and Bruno tests (same pattern as encounter Bruno). | **adopted** |
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# Code review — NEO-115 (E7M1-04)
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Scope:** Branch `NEO-115-get-world-quest-definitions` vs `origin/main` — commits `a5be355` … `b3f90de`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-115 adds **`GET /game/world/quest-definitions`**, a versioned read-only projection of the frozen prototype quest catalog via **`IQuestDefinitionRegistry`**. Implementation mirrors the established encounter/item world-definition pattern: `QuestDefinitionsWorldApi` maps registry rows to flat objective DTOs with **`WhenWritingNull`** on unused fields, wired in **`Program.cs`** after encounter definitions. One AAA integration test covers schema v1, four quests in ordinal id order, and spot-checks for gather, refine prerequisite, combat, and operator-chain terminal objectives. Bruno folder validates the same frozen rows and confirms null-key omission on a gather objective via strict `eql`. Documentation (implementation plan, backlog AC, E7.M1 snapshot, alignment register, `server/README.md`) is updated. Server-only infrastructure with optional client consumer NEO-122 — correct scope. Risk is low.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-115-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-04) | **Matches** — AC checked; in/out of scope aligned with shipped route. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — HTTP read model bullet documents NEO-115 route + Bruno. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-115 HTTP projection; register stays **Planned** until E7M1-05+ runtime. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note includes NEO-115 HTTP read (Suggestion 2 addressed). |
|
||||
| `server/README.md` | **Matches** — quest definitions section with curl, registry note, plan/Bruno links. |
|
||||
| Full-stack epic decomposition | **N/A** — server-only; client counterpart NEO-122 explicitly deferred. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Reuse frozen id ordering constant in tests** — Plan adopted reusing **`PrototypeE7M1QuestCatalogRules`** for ids. `QuestDefinitionsWorldApiTests` defines a parallel **`FrozenQuestIdsInOrdinalOrder`** array instead of deriving from **`ExpectedQuestIds.Order(StringComparer.Ordinal)`** (as **`QuestDefinitionRegistryTests`** already does). Consolidating avoids drift if the roster changes.~~ **Done.** `QuestDefinitionsWorldApiTests` now asserts id order via **`ExpectedQuestIds.Order(StringComparer.Ordinal)`**.
|
||||
|
||||
2. ~~**Optional: refresh `module_dependency_register` E7.M1 note** — E7.M1 module page and alignment register mention NEO-115; the register table note still stops at NEO-114. A one-line append (“**E7M1-04 / NEO-115** HTTP read landed”) would keep the dependency register consistent with NEO-114 follow-up style.~~ **Done.** E7.M1 note appended in **`module_dependency_register.md`**.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: C# integration test asserts **`refine_intro`** prerequisite only, not the **`craft_recipe`** objective shape; Bruno likewise skips refine/combat spot-checks. Adequate for prototype freeze given gather + operator-chain + combat coverage, but a single **`craft_recipe`** assert would close the objective-kind loop in C#.~~ **Done.** `refine_intro` **`craft_recipe`** objective asserted in **`QuestDefinitionsWorldApiTests`**.
|
||||
|
||||
- Nit: **`MapObjective`** default switch arm copies all optional fields for unknown kinds — unreachable for validated catalog content but reasonable defensive fallback.
|
||||
|
||||
- Nit: Single large integration test (78 lines of asserts) follows the encounter world API test style; acceptable for a frozen four-quest roster.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Quest definitions world API (primary signal for this story)
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~QuestDefinitionsWorldApiTests"
|
||||
|
||||
# Quest registry regression (NEO-114)
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~QuestDefinitionRegistryTests"
|
||||
|
||||
# Full server suite (CI)
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
**Reviewer note:** `QuestDefinitionsWorldApiTests` (1 test) passed locally during review.
|
||||
|
||||
**Manual / CI Bruno:** Run `bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru` against a dev server on port 5253.
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
public class QuestDefinitionsWorldApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFourFrozenQuestsInIdOrder()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/world/quest-definitions");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestDefinitionsListResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.NotNull(body.Quests);
|
||||
Assert.Equal(4, body.Quests.Count);
|
||||
Assert.Equal(
|
||||
PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal).ToArray(),
|
||||
body.Quests.Select(q => q.Id).ToArray());
|
||||
|
||||
var gatherIntro = body.Quests.Single(q => q.Id == "prototype_quest_gather_intro");
|
||||
Assert.Equal("Intro: Salvage Run", gatherIntro.DisplayName);
|
||||
Assert.Empty(gatherIntro.PrerequisiteQuestIds);
|
||||
Assert.Single(gatherIntro.Steps);
|
||||
var gatherStep = gatherIntro.Steps[0];
|
||||
Assert.Equal("gather_intro_step_salvage", gatherStep.Id);
|
||||
Assert.Equal("Gather scrap metal", gatherStep.DisplayName);
|
||||
Assert.Single(gatherStep.Objectives);
|
||||
var gatherObjective = gatherStep.Objectives[0];
|
||||
Assert.Equal("gather_intro_obj_scrap", gatherObjective.Id);
|
||||
Assert.Equal("gather_item", gatherObjective.Kind);
|
||||
Assert.Equal("scrap_metal_bulk", gatherObjective.ItemId);
|
||||
Assert.Equal(3, gatherObjective.Quantity);
|
||||
Assert.Null(gatherObjective.RecipeId);
|
||||
Assert.Null(gatherObjective.EncounterId);
|
||||
|
||||
var refineIntro = body.Quests.Single(q => q.Id == "prototype_quest_refine_intro");
|
||||
Assert.Equal(["prototype_quest_gather_intro"], refineIntro.PrerequisiteQuestIds);
|
||||
var refineObjective = refineIntro.Steps[0].Objectives[0];
|
||||
Assert.Equal("refine_intro_obj_recipe", refineObjective.Id);
|
||||
Assert.Equal("craft_recipe", refineObjective.Kind);
|
||||
Assert.Equal("refine_scrap_standard", refineObjective.RecipeId);
|
||||
Assert.Equal(1, refineObjective.Quantity);
|
||||
Assert.Null(refineObjective.ItemId);
|
||||
Assert.Null(refineObjective.EncounterId);
|
||||
|
||||
var combatIntro = body.Quests.Single(q => q.Id == "prototype_quest_combat_intro");
|
||||
Assert.Single(combatIntro.Steps);
|
||||
var combatObjective = combatIntro.Steps[0].Objectives[0];
|
||||
Assert.Equal("encounter_complete", combatObjective.Kind);
|
||||
Assert.Equal("prototype_combat_pocket", combatObjective.EncounterId);
|
||||
Assert.Null(combatObjective.ItemId);
|
||||
Assert.Null(combatObjective.Quantity);
|
||||
Assert.Null(combatObjective.RecipeId);
|
||||
|
||||
var operatorChain = body.Quests.Single(q => q.Id == PrototypeE7M1QuestCatalogRules.ChainQuestId);
|
||||
Assert.Equal(4, operatorChain.Steps.Count);
|
||||
Assert.Equal(
|
||||
[
|
||||
"prototype_quest_gather_intro",
|
||||
"prototype_quest_refine_intro",
|
||||
"prototype_quest_combat_intro",
|
||||
],
|
||||
operatorChain.PrerequisiteQuestIds);
|
||||
var terminalObjective = operatorChain.Steps[3].Objectives[0];
|
||||
Assert.Equal("inventory_has_item", terminalObjective.Kind);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, terminalObjective.ItemId);
|
||||
Assert.Equal(1, terminalObjective.Quantity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Json.Schema;
|
||||
|
||||
namespace NeonSprawl.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe registration for <see cref="SchemaRegistry.Global"/> (JsonSchema.Net registry is not concurrent).
|
||||
/// Used by content catalog loaders when parallel integration tests spin up multiple hosts.
|
||||
/// </summary>
|
||||
internal static class CatalogSchemaRegistry
|
||||
{
|
||||
private static readonly Lock RegisterLock = new();
|
||||
private static readonly ConcurrentDictionary<string, JsonSchema> SchemasByPath =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Parses <paramref name="schemaFilePath"/> once per process and registers it under a global lock.</summary>
|
||||
public static JsonSchema GetOrRegisterFromFile(string schemaFilePath)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(schemaFilePath);
|
||||
if (SchemasByPath.TryGetValue(fullPath, out var cached))
|
||||
return cached;
|
||||
|
||||
lock (RegisterLock)
|
||||
{
|
||||
if (SchemasByPath.TryGetValue(fullPath, out cached))
|
||||
return cached;
|
||||
|
||||
var schema = JsonSchema.FromText(File.ReadAllText(fullPath));
|
||||
SchemaRegistry.Global.Register(schema);
|
||||
SchemasByPath[fullPath] = schema;
|
||||
return schema;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,12 +45,8 @@ public static class RecipeDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var ioSchemaText = File.ReadAllText(recipeIoRowSchemaPath);
|
||||
var defSchemaText = File.ReadAllText(recipeDefSchemaPath);
|
||||
var ioSchema = JsonSchema.FromText(ioSchemaText);
|
||||
SchemaRegistry.Global.Register(ioSchema);
|
||||
var defSchema = JsonSchema.FromText(defSchemaText);
|
||||
SchemaRegistry.Global.Register(defSchema);
|
||||
var ioSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(recipeIoRowSchemaPath);
|
||||
var defSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(recipeDefSchemaPath);
|
||||
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
|
|
|
|||
|
|
@ -44,10 +44,8 @@ public static class RewardTableDefinitionCatalogLoader
|
|||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var grantRowSchema = JsonSchema.FromText(File.ReadAllText(rewardGrantRowSchemaPath));
|
||||
SchemaRegistry.Global.Register(grantRowSchema);
|
||||
var tableSchema = JsonSchema.FromText(File.ReadAllText(rewardTableSchemaPath));
|
||||
SchemaRegistry.Global.Register(tableSchema);
|
||||
var grantRowSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
|
||||
var tableSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(rewardTableSchemaPath);
|
||||
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ namespace NeonSprawl.Server.Game.Quests;
|
|||
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113).</summary>
|
||||
public static class QuestDefinitionCatalogLoader
|
||||
{
|
||||
private static readonly Lock SchemaLoadLock = new();
|
||||
private static JsonSchema? _questObjectiveDefSchema;
|
||||
private static JsonSchema? _questStepDefSchema;
|
||||
private static JsonSchema? _questDefSchema;
|
||||
|
|
@ -352,23 +351,12 @@ public static class QuestDefinitionCatalogLoader
|
|||
string questStepDefSchemaPath,
|
||||
string questDefSchemaPath)
|
||||
{
|
||||
lock (SchemaLoadLock)
|
||||
{
|
||||
if (_questDefSchema is not null)
|
||||
return;
|
||||
if (_questDefSchema is not null)
|
||||
return;
|
||||
|
||||
var objectiveSchema = JsonSchema.FromText(File.ReadAllText(questObjectiveDefSchemaPath));
|
||||
SchemaRegistry.Global.Register(objectiveSchema);
|
||||
_questObjectiveDefSchema = objectiveSchema;
|
||||
|
||||
var stepSchema = JsonSchema.FromText(File.ReadAllText(questStepDefSchemaPath));
|
||||
SchemaRegistry.Global.Register(stepSchema);
|
||||
_questStepDefSchema = stepSchema;
|
||||
|
||||
var defSchema = JsonSchema.FromText(File.ReadAllText(questDefSchemaPath));
|
||||
SchemaRegistry.Global.Register(defSchema);
|
||||
_questDefSchema = defSchema;
|
||||
}
|
||||
_questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath);
|
||||
_questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath);
|
||||
_questDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questDefSchemaPath);
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> errors)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>JSON body for <c>GET /game/world/quest-definitions</c> (NEO-115).</summary>
|
||||
public sealed class QuestDefinitionsListResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
/// <summary>Loaded quests ordered by stable <c>id</c> (ordinal), matching <see cref="IQuestDefinitionRegistry.GetDefinitionsInIdOrder"/>.</summary>
|
||||
[JsonPropertyName("quests")]
|
||||
public required IReadOnlyList<QuestDefinitionJson> Quests { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One row in the read-only quest definition projection.</summary>
|
||||
public sealed class QuestDefinitionJson
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("prerequisiteQuestIds")]
|
||||
public required IReadOnlyList<string> PrerequisiteQuestIds { get; init; }
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public required IReadOnlyList<QuestStepDefinitionJson> Steps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One step in the read-only quest definition projection.</summary>
|
||||
public sealed class QuestStepDefinitionJson
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("objectives")]
|
||||
public required IReadOnlyList<QuestObjectiveDefinitionJson> Objectives { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One objective in the read-only quest definition projection (flat mirror of content).</summary>
|
||||
public sealed class QuestObjectiveDefinitionJson
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public required string Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("itemId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ItemId { get; init; }
|
||||
|
||||
[JsonPropertyName("quantity")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Quantity { get; init; }
|
||||
|
||||
[JsonPropertyName("recipeId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RecipeId { get; init; }
|
||||
|
||||
[JsonPropertyName("encounterId")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? EncounterId { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Maps <c>GET /game/world/quest-definitions</c> (NEO-115).</summary>
|
||||
public static class QuestDefinitionsWorldApi
|
||||
{
|
||||
public static WebApplication MapQuestDefinitionsWorldApi(this WebApplication app)
|
||||
{
|
||||
app.MapGet(
|
||||
"/game/world/quest-definitions",
|
||||
(IQuestDefinitionRegistry questRegistry) =>
|
||||
{
|
||||
var defs = questRegistry.GetDefinitionsInIdOrder();
|
||||
var quests = new List<QuestDefinitionJson>(defs.Count);
|
||||
foreach (var d in defs)
|
||||
{
|
||||
quests.Add(
|
||||
new QuestDefinitionJson
|
||||
{
|
||||
Id = d.Id,
|
||||
DisplayName = d.DisplayName,
|
||||
PrerequisiteQuestIds = d.PrerequisiteQuestIds,
|
||||
Steps = MapSteps(d.Steps),
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Json(
|
||||
new QuestDefinitionsListResponse
|
||||
{
|
||||
SchemaVersion = QuestDefinitionsListResponse.CurrentSchemaVersion,
|
||||
Quests = quests,
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static List<QuestStepDefinitionJson> MapSteps(IReadOnlyList<QuestStepDefRow> steps)
|
||||
{
|
||||
var list = new List<QuestStepDefinitionJson>(steps.Count);
|
||||
foreach (var step in steps)
|
||||
{
|
||||
list.Add(
|
||||
new QuestStepDefinitionJson
|
||||
{
|
||||
Id = step.Id,
|
||||
DisplayName = step.DisplayName,
|
||||
Objectives = MapObjectives(step.Objectives),
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<QuestObjectiveDefinitionJson> MapObjectives(IReadOnlyList<QuestObjectiveDefRow> objectives)
|
||||
{
|
||||
var list = new List<QuestObjectiveDefinitionJson>(objectives.Count);
|
||||
foreach (var objective in objectives)
|
||||
{
|
||||
list.Add(MapObjective(objective));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static QuestObjectiveDefinitionJson MapObjective(QuestObjectiveDefRow objective) =>
|
||||
objective.Kind switch
|
||||
{
|
||||
"gather_item" or "inventory_has_item" => new QuestObjectiveDefinitionJson
|
||||
{
|
||||
Id = objective.Id,
|
||||
Kind = objective.Kind,
|
||||
ItemId = objective.ItemId,
|
||||
Quantity = objective.Quantity,
|
||||
},
|
||||
"craft_recipe" => new QuestObjectiveDefinitionJson
|
||||
{
|
||||
Id = objective.Id,
|
||||
Kind = objective.Kind,
|
||||
RecipeId = objective.RecipeId,
|
||||
Quantity = objective.Quantity,
|
||||
},
|
||||
"encounter_complete" => new QuestObjectiveDefinitionJson
|
||||
{
|
||||
Id = objective.Id,
|
||||
Kind = objective.Kind,
|
||||
EncounterId = objective.EncounterId,
|
||||
},
|
||||
_ => new QuestObjectiveDefinitionJson
|
||||
{
|
||||
Id = objective.Id,
|
||||
Kind = objective.Kind,
|
||||
ItemId = objective.ItemId,
|
||||
Quantity = objective.Quantity,
|
||||
RecipeId = objective.RecipeId,
|
||||
EncounterId = objective.EncounterId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ app.MapNpcRuntimeSnapshotWorldApi();
|
|||
app.MapPlayerCombatHealthApi();
|
||||
app.MapResourceNodeDefinitionsWorldApi();
|
||||
app.MapEncounterDefinitionsWorldApi();
|
||||
app.MapQuestDefinitionsWorldApi();
|
||||
app.MapPlayerInventoryApi();
|
||||
app.MapPlayerCraftApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
|
|
|
|||
|
|
@ -148,6 +148,14 @@ On startup the host loads every **`*_quests.json`** under the quests directory *
|
|||
|
||||
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.
|
||||
|
||||
## Quest definitions (NEO-115)
|
||||
|
||||
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
|
||||
|
||||
```bash
|
||||
curl -sS -i "http://localhost:5253/game/world/quest-definitions"
|
||||
```
|
||||
|
||||
## Encounter definitions (NEO-103)
|
||||
|
||||
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue