From a5be355a22d4fc9ee1f469e6499785c9d9722b52 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 22:12:14 -0400 Subject: [PATCH 1/6] NEO-115: add kickoff implementation plan for quest definitions GET. --- docs/plans/NEO-115-implementation-plan.md | 115 ++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/plans/NEO-115-implementation-plan.md diff --git a/docs/plans/NEO-115-implementation-plan.md b/docs/plans/NEO-115-implementation-plan.md new file mode 100644 index 0000000..3171d7a --- /dev/null +++ b/docs/plans/NEO-115-implementation-plan.md @@ -0,0 +1,115 @@ +# 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 + +- [ ] GET returns all four prototype quests with stable JSON v1 envelope (`schemaVersion` 1, `quests[]`). +- [ ] Bruno request documents example response shape and passes in CI Bruno step. + +## 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** | From 31cd67725d28444f85962feb07495a622d8051d5 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 22:14:37 -0400 Subject: [PATCH 2/6] NEO-115: add GET /game/world/quest-definitions API and tests. Expose read-only v1 quest projection from IQuestDefinitionRegistry with flat objective fields, Bruno collection, and integration coverage. --- .../Get quest definitions.bru | 57 +++++++++++ .../quest-definitions/folder.bru | 3 + .../Quests/QuestDefinitionsWorldApiTests.cs | 78 +++++++++++++++ .../Game/Quests/QuestDefinitionsListDtos.cs | 71 ++++++++++++++ .../Game/Quests/QuestDefinitionsWorldApi.cs | 98 +++++++++++++++++++ server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 8 ++ 7 files changed, 316 insertions(+) create mode 100644 bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru create mode 100644 bruno/neon-sprawl-server/quest-definitions/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs diff --git a/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru new file mode 100644 index 0000000..abfc4c8 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru @@ -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, + }); + }); +} diff --git a/bruno/neon-sprawl-server/quest-definitions/folder.bru b/bruno/neon-sprawl-server/quest-definitions/folder.bru new file mode 100644 index 0000000..31b1b53 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-definitions/folder.bru @@ -0,0 +1,3 @@ +meta { + name: quest-definitions +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs new file mode 100644 index 0000000..c5157df --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs @@ -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 +{ + /// Frozen prototype quest ids in ascending ordinal order. Keep in sync with Bruno. + public static readonly string[] FrozenQuestIdsInOrdinalOrder = + [ + "prototype_quest_combat_intro", + "prototype_quest_gather_intro", + "prototype_quest_operator_chain", + "prototype_quest_refine_intro", + ]; + + [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(); + Assert.NotNull(body); + Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.NotNull(body.Quests); + Assert.Equal(4, body.Quests.Count); + Assert.Equal(FrozenQuestIdsInOrdinalOrder, 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 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); + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs new file mode 100644 index 0000000..a2bcf86 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsListDtos.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Quests; + +/// JSON body for GET /game/world/quest-definitions (NEO-115). +public sealed class QuestDefinitionsListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// Loaded quests ordered by stable id (ordinal), matching . + [JsonPropertyName("quests")] + public required IReadOnlyList Quests { get; init; } +} + +/// One row in the read-only quest definition projection. +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 PrerequisiteQuestIds { get; init; } + + [JsonPropertyName("steps")] + public required IReadOnlyList Steps { get; init; } +} + +/// One step in the read-only quest definition projection. +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 Objectives { get; init; } +} + +/// One objective in the read-only quest definition projection (flat mirror of content). +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; } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs new file mode 100644 index 0000000..e56416a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionsWorldApi.cs @@ -0,0 +1,98 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Maps GET /game/world/quest-definitions (NEO-115). +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(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 MapSteps(IReadOnlyList steps) + { + var list = new List(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 MapObjectives(IReadOnlyList objectives) + { + var list = new List(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, + }, + }; +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index f664013..c50f10f 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -70,6 +70,7 @@ app.MapNpcRuntimeSnapshotWorldApi(); app.MapPlayerCombatHealthApi(); app.MapResourceNodeDefinitionsWorldApi(); app.MapEncounterDefinitionsWorldApi(); +app.MapQuestDefinitionsWorldApi(); app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); app.MapSkillProgressionSnapshotApi(); diff --git a/server/README.md b/server/README.md index a9b85e7..7c5917d 100644 --- a/server/README.md +++ b/server/README.md @@ -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/`. From b3f90ded53343b5f03eb699448bac4989a7ca2b9 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 22:14:39 -0400 Subject: [PATCH 3/6] NEO-115: reconcile plan, backlog, and E7.M1 alignment docs. Mark E7M1-04 acceptance landed and document the quest definitions HTTP slice. --- .../modules/E7_M1_QuestStateMachine.md | 4 +++- .../documentation_and_implementation_alignment.md | 2 +- docs/plans/E7M1-prototype-backlog.md | 4 ++-- docs/plans/NEO-115-implementation-plan.md | 13 +++++++++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md index e42b875..d802743 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**; 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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 79e2681..e2492ff 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)). **Backlog decomposed (runtime from E7M1-04+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until quest runtime (E7M1-05+) lands. | [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | +| 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/`. **Backlog decomposed (runtime from E7M1-05+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until quest runtime (E7M1-05+) lands. | [NEO-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/plans/E7M1-prototype-backlog.md b/docs/plans/E7M1-prototype-backlog.md index 04aec38..3305a43 100644 --- a/docs/plans/E7M1-prototype-backlog.md +++ b/docs/plans/E7M1-prototype-backlog.md @@ -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). diff --git a/docs/plans/NEO-115-implementation-plan.md b/docs/plans/NEO-115-implementation-plan.md index 3171d7a..46197b0 100644 --- a/docs/plans/NEO-115-implementation-plan.md +++ b/docs/plans/NEO-115-implementation-plan.md @@ -43,8 +43,17 @@ ## Acceptance criteria checklist -- [ ] GET returns all four prototype quests with stable JSON v1 envelope (`schemaVersion` 1, `quests[]`). -- [ ] Bruno request documents example response shape and passes in CI Bruno step. +- [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 From 31ffee1f7a00717003421a64b2d223e52ca29b4b Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 22:16:48 -0400 Subject: [PATCH 4/6] NEO-115: add code review for quest definitions GET API. --- docs/reviews/2026-06-03-NEO-115.md | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/reviews/2026-06-03-NEO-115.md diff --git a/docs/reviews/2026-06-03-NEO-115.md b/docs/reviews/2026-06-03-NEO-115.md new file mode 100644 index 0000000..599d5d8 --- /dev/null +++ b/docs/reviews/2026-06-03-NEO-115.md @@ -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` | **Partially matches** — E7.M1 note still ends at NEO-114 registry; alignment doc covers NEO-115 (see Suggestion 2). | +| `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. + +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. + +## 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#. + +- 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. From ab315f326b7af3751211f7a43ccdb71f543f533c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 22:22:44 -0400 Subject: [PATCH 5/6] NEO-115: address code review findings for quest definitions GET. Derive expected quest id order from PrototypeE7M1QuestCatalogRules, assert refine craft_recipe objective in integration test, and update module dependency register plus review doc strikethroughs. --- .../modules/module_dependency_register.md | 2 +- docs/reviews/2026-06-03-NEO-115.md | 8 ++++---- .../Quests/QuestDefinitionsWorldApiTests.cs | 20 +++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 86d7cf2..4692cac 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)); 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 | diff --git a/docs/reviews/2026-06-03-NEO-115.md b/docs/reviews/2026-06-03-NEO-115.md index 599d5d8..558c35d 100644 --- a/docs/reviews/2026-06-03-NEO-115.md +++ b/docs/reviews/2026-06-03-NEO-115.md @@ -20,7 +20,7 @@ NEO-115 adds **`GET /game/world/quest-definitions`**, a versioned read-only proj | `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` | **Partially matches** — E7.M1 note still ends at NEO-114 registry; alignment doc covers NEO-115 (see Suggestion 2). | +| `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. | @@ -30,13 +30,13 @@ NEO-115 adds **`GET /game/world/quest-definitions`**, a versioned read-only proj ## 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. +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. +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#. +- ~~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. diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs index c5157df..bb1c333 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs @@ -7,15 +7,6 @@ namespace NeonSprawl.Server.Tests.Game.Quests; public class QuestDefinitionsWorldApiTests { - /// Frozen prototype quest ids in ascending ordinal order. Keep in sync with Bruno. - public static readonly string[] FrozenQuestIdsInOrdinalOrder = - [ - "prototype_quest_combat_intro", - "prototype_quest_gather_intro", - "prototype_quest_operator_chain", - "prototype_quest_refine_intro", - ]; - [Fact] public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFourFrozenQuestsInIdOrder() { @@ -31,7 +22,9 @@ public class QuestDefinitionsWorldApiTests Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.NotNull(body.Quests); Assert.Equal(4, body.Quests.Count); - Assert.Equal(FrozenQuestIdsInOrdinalOrder, body.Quests.Select(q => q.Id).ToArray()); + 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); @@ -51,6 +44,13 @@ public class QuestDefinitionsWorldApiTests 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); From 5f1244843cdc98d0396dc2fb248deaed959bc9a3 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 22:37:55 -0400 Subject: [PATCH 6/6] NEO-115: serialize JsonSchema registry registration for parallel tests. Recipe and reward-table loaders registered schemas without locking, corrupting SchemaRegistry.Global when xUnit spun up multiple WebApplicationFactory hosts concurrently in CI. --- .../CatalogSchemaRegistry.cs | 34 +++++++++++++++++++ .../Crafting/RecipeDefinitionCatalogLoader.cs | 8 ++--- .../RewardTableDefinitionCatalogLoader.cs | 6 ++-- .../Quests/QuestDefinitionCatalogLoader.cs | 22 +++--------- 4 files changed, 43 insertions(+), 27 deletions(-) create mode 100644 server/NeonSprawl.Server/CatalogSchemaRegistry.cs diff --git a/server/NeonSprawl.Server/CatalogSchemaRegistry.cs b/server/NeonSprawl.Server/CatalogSchemaRegistry.cs new file mode 100644 index 0000000..aa1d287 --- /dev/null +++ b/server/NeonSprawl.Server/CatalogSchemaRegistry.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using Json.Schema; + +namespace NeonSprawl.Server; + +/// +/// Thread-safe registration for (JsonSchema.Net registry is not concurrent). +/// Used by content catalog loaders when parallel integration tests spin up multiple hosts. +/// +internal static class CatalogSchemaRegistry +{ + private static readonly Lock RegisterLock = new(); + private static readonly ConcurrentDictionary SchemasByPath = + new(StringComparer.Ordinal); + + /// Parses once per process and registers it under a global lock. + 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; + } + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs index b3a5c78..f43a8f4 100644 --- a/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs @@ -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 }; diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs index 7a3b464..b510ede 100644 --- a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs @@ -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 }; diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs index 7d099f0..f570220 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -9,7 +9,6 @@ namespace NeonSprawl.Server.Game.Quests; /// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113). public static class QuestDefinitionCatalogLoader { - private static readonly Lock SchemaLoadLock = new(); private static JsonSchema? _questObjectiveDefSchema; private static JsonSchema? _questStepDefSchema; private static JsonSchema? _questDefSchema; @@ -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 errors)