Compare commits

..

13 Commits

Author SHA1 Message Date
VinPropane 58b898a733
Merge pull request #155 from ViPro-Technologies/NEO-116-player-quest-state-store
NEO-116: Player quest state store + QuestStepState persistence
2026-06-03 23:29:39 -04:00
VinPropane fabbdae4c2 NEO-116: Make Postgres TryActivate safe under concurrent insert.
Use INSERT ON CONFLICT DO NOTHING so duplicate activations return false
with the existing snapshot instead of raising a primary-key violation.
2026-06-03 23:25:13 -04:00
VinPropane de611ac294 NEO-116: Address code review findings for quest state store.
Fix persistence test AAA layout, empty-id denial theory, frozen id constants,
Postgres rollback/JSONB read, XML doc cref, README Bruno link, and review doc.
2026-06-03 23:19:52 -04:00
VinPropane af87b5d12c NEO-116: Add code review for player quest state store. 2026-06-03 23:16:48 -04:00
VinPropane 395b70c030 NEO-116: Add player quest state store with Postgres persistence.
Introduces IPlayerQuestStateStore (in-memory + V008 Postgres), QuestStepState
snapshots, DI wiring, AAA unit tests, and docs for E7M1-05 before NEO-117 ops.
2026-06-03 23:04:50 -04:00
VinPropane 065872405c NEO-116: Add kickoff implementation plan for player quest state store.
Documents store contract, Postgres V008, tests, and kickoff decisions
(in-memory + Postgres when configured) before E7M1-06 operations land.
2026-06-03 23:01:59 -04:00
VinPropane 91d6cba34c
Merge pull request #154 from ViPro-Technologies/NEO-115-get-world-quest-definitions
NEO-115: GET /game/world/quest-definitions
2026-06-03 22:58:43 -04:00
VinPropane 5f1244843c 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.
2026-06-03 22:37:55 -04:00
VinPropane ab315f326b 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.
2026-06-03 22:22:44 -04:00
VinPropane 31ffee1f7a NEO-115: add code review for quest definitions GET API. 2026-06-03 22:16:48 -04:00
VinPropane b3f90ded53 NEO-115: reconcile plan, backlog, and E7.M1 alignment docs.
Mark E7M1-04 acceptance landed and document the quest definitions HTTP slice.
2026-06-03 22:14:39 -04:00
VinPropane 31cd67725d 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.
2026-06-03 22:14:37 -04:00
VinPropane a5be355a22 NEO-115: add kickoff implementation plan for quest definitions GET. 2026-06-03 22:12:14 -04:00
33 changed files with 1923 additions and 34 deletions

View File

@ -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,
});
});
}

View File

@ -0,0 +1,3 @@
meta {
name: quest-definitions
}

View File

@ -0,0 +1,25 @@
meta {
name: GET health (quest state store NEO-116)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-116 registers IPlayerQuestStateStore (in-memory or Postgres when configured). No quest progress HTTP API in this story — use this request to confirm the host started after store DI wiring.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M1 | | **Module ID** | E7.M1 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Prototype | | **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**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; runtime from **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) → 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) | | **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
## Purpose ## Purpose
@ -80,6 +80,10 @@ 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). **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/`.
**Player quest state store (NEO-116):** **`IPlayerQuestStateStore`** — in-memory + Postgres (`V008__player_quest_progress.sql`) per-player rows keyed by `(playerId, questId)`; implicit `not_started` when missing ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). HTTP read/accept deferred to E7M1-08/09; **`QuestStateOperations`** in E7M1-06.
**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. **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 ## Risks and telemetry

File diff suppressed because one or more lines are too long

View File

@ -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 | 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)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); register row stays **Planned** until quest runtime (E7M1-06+) lands.
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned | | 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.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 | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |

View File

@ -166,8 +166,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
**Acceptance criteria** **Acceptance criteria**
- [ ] GET returns all four prototype quests with stable JSON v1 envelope. - [x] GET returns all four prototype quests with stable JSON v1 envelope.
- [ ] Bruno request documents example response shape. - [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). **Client counterpart:** consumed by [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) for display names (optional cache).
@ -190,8 +190,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
**Acceptance criteria** **Acceptance criteria**
- [ ] Store supports read/update per `(playerId, questId)`. - [x] Store supports read/update per `(playerId, questId)`.
- [ ] Completed quest cannot regress to active without explicit reset API (none in prototype). - [x] Completed quest cannot regress to active without explicit reset API (none in prototype).
**Client counterpart:** none (infrastructure-only). **Client counterpart:** none (infrastructure-only).

View File

@ -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** |

View File

@ -0,0 +1,160 @@
# NEO-116 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-116 |
| **Title** | E7M1-05: Player quest state store + QuestStepState persistence |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-116/e7m1-05-player-quest-state-store-queststepstate-persistence |
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-05** |
| **Branch** | `NEO-116-player-quest-state-store` |
| **Precursor** | [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) — quest definition registry (**Done**); [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — GET `/game/world/quest-definitions` (**landed on `main`**) |
| **Pattern** | [NEO-104](NEO-104-implementation-plan.md) — keyed in-memory store + atomic mutations; [NEO-44](NEO-44-implementation-plan.md) — Postgres when configured + persistence integration test |
| **Blocks** | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) — `QuestStateOperations` (accept, step advance, complete); E7M1-07+ objective wiring |
| **Client counterpart** | None — server-only store; player-visible progress starts at [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) / [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Postgres persistence** | Include `V008` migration + `PostgresPlayerQuestStateStore` in NEO-116 vs defer? | **Include when configured** — E7M1 backlog default is in-memory **+ optional Postgres**; mirror `AddGigProgressionStore` / `PostgresPlayerGigProgressionStore`. | **Adopted** — user chose in-memory + Postgres when configured. |
| **Store API shape** | Mutation methods on store vs minimal `TryGet` + `TryReplace` only? | **Mutation methods on store**`TryActivate`, `TryAdvanceStep`, `TryUpdateObjectiveCounter`, `TryMarkComplete`; E7M1-06 `QuestStateOperations` adds prerequisite/registry validation on top (NEO-104 encounter store precedent). | **Adopted** (agent default; no objection). |
| **`not_started` representation** | Missing row vs explicit row with `not_started` status? | **Implicit missing row** — encounter progress pattern; E7M1-08 HTTP merges registry + store. | **Adopted** (agent default). |
| **DI registration** | Extend `AddQuestDefinitionCatalog` vs separate extension? | **Separate `AddPlayerQuestStateStore`** — persistence separate from catalog load (NEO-113/114), like `AddGigProgressionStore`. | **Adopted** (agent default). |
| **Objective counters scope** | Counters for current step only vs full quest history? | **Current step only** — reset counters on `TryAdvanceStep`; sufficient for E7M1-07 quantity objectives. | **Adopted** (agent default). |
## Goal, scope, and out-of-scope
**Goal:** Durable per-player quest progress rows keyed by **`(playerId, questId)`**: status (`not_started` implicit / `active` / `completed`), current step index, per-objective counters for the active step, and completion timestamp. Provide **`IPlayerQuestStateStore`** with in-memory and Postgres implementations registered in DI.
**In scope (from Linear + [E7M1-05](E7M1-prototype-backlog.md#e7m1-05--player-quest-state-store--queststepstate-persistence)):**
- **`QuestStepState`** snapshot type and **`PlayerQuestProgressRow`** (or equivalent immutable read model).
- **`QuestProgressStatus`** enum: `Active`, `Completed` (missing row ⇒ `not_started` at call sites).
- **`IPlayerQuestStateStore`** + **`InMemoryPlayerQuestStateStore`** (dev player bucket seeded; thread-safe).
- **`PostgresPlayerQuestStateStore`** + **`PostgresPlayerQuestProgressBootstrap`** + **`V008__player_quest_progress.sql`** when `ConnectionStrings:NeonSprawl` is set.
- **`QuestProgressIds`** — player/quest id normalization + composite key helper (mirror `EncounterProgressIds`).
- **`QuestStateServiceCollectionExtensions.AddPlayerQuestStateStore`** wired from **`Program.cs`**.
- Unit tests (AAA): accept (`not_started` → `active`), step advance + counter updates, idempotent complete, completed row cannot regress to active.
- Postgres persistence integration test (`RequirePostgresFact`): write via store on first factory, read on second factory.
- `server/README.md` quest progress store section (brief).
- Update alignment register E7.M1 row when complete.
**Out of scope (from Linear + backlog):**
- **`QuestStateOperations`** prerequisite enforcement and reason codes ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) / E7M1-06).
- Gather/craft/encounter objective wiring (E7M1-07).
- HTTP **`GET /game/players/{id}/quest-progress`** (E7M1-08) and accept POST (E7M1-09).
- Bruno, Godot, dev fixture reset API for quest rows (may add in NEO-108-style story if needed).
- Reward grants / E7.M2 bundles.
## Acceptance criteria checklist
- [x] Store supports read/update per `(playerId, questId)`.
- [x] Completed quest cannot regress to active without explicit reset API (none in prototype — store denies `TryActivate` / `TryAdvanceStep` / counter updates when completed).
- [x] Idempotent complete: second `TryMarkComplete` returns `false` without changing `CompletedAt` (Linear AC).
## Implementation reconciliation (shipped)
- **Types:** `QuestProgressStatus`, `QuestStepState`, `QuestProgressIds`.
- **Store:** `IPlayerQuestStateStore`; `InMemoryPlayerQuestStateStore` (dev player seeded); `PostgresPlayerQuestStateStore` + `V008__player_quest_progress.sql` when Postgres configured.
- **DI:** `AddPlayerQuestStateStore` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`.
- **Tests:** `InMemoryPlayerQuestStateStoreTests` (AAA cases + host DI + empty-id denial); `PlayerQuestProgressPersistenceIntegrationTests` (`RequirePostgresFact`).
- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru` — health smoke only (pre-commit hook when `Program.cs` changes; no quest HTTP this story).
- **Docs:** `server/README.md` quest progress store section; alignment register E7.M1 row updated.
## Technical approach
1. **Types (`Game/Quests/`)**
- **`QuestProgressStatus`**: `Active`, `Completed`.
- **`QuestStepState`**: immutable snapshot for one quest row — `PlayerId`, `QuestId`, `Status`, `CurrentStepIndex` (0-based), `IReadOnlyDictionary<string, int> ObjectiveCounters` (objective id → accumulated count for **current step only**), optional `CompletedAt`.
- **`QuestProgressIds`**: `NormalizePlayerId`, `NormalizeQuestId`, `MakeProgressKey` (trim + lowercase; empty guard).
2. **`IPlayerQuestStateStore`**
- `bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)` — missing row ⇒ `false` (callers treat as `not_started`).
- `bool TryActivate(string playerId, string questId, out QuestStepState snapshot)` — creates row at step 0, empty counters; `true` on first activation; `false` if already active or completed or unknown player (in-memory: no dev bucket).
- `bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot)` — requires active row; sets index, **clears** objective counters; denies when completed or `newStepIndex` ≤ current.
- `bool TryUpdateObjectiveCounter(string playerId, string questId, string objectiveId, int newCount, out QuestStepState snapshot)` — requires active row; sets counter for objective id (non-negative); denies when completed.
- `bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot)` — first mark `true`; replay `false`; preserves first `CompletedAt`.
- XML remarks: game code should inject this interface; **`QuestStateOperations`** (NEO-117) and HTTP (E7M1-08) consume it — not the catalog.
3. **`InMemoryPlayerQuestStateStore`**
- `ConcurrentDictionary<string, ProgressRow>` keyed by `MakeProgressKey`; per-key locks.
- Seed configured dev player bucket on construction (`GamePositionOptions.DevPlayerId`) — empty inner map (all quests implicit `not_started`).
- `CanWritePlayer` semantics: returns false when player has no bucket (mirror gig/inventory stores).
4. **Postgres (`V008__player_quest_progress.sql`)**
- Table **`player_quest_progress`**: `player_id` FK → `player_position`, `quest_id`, `status` (`active` / `completed`), `current_step_index`, `objective_counters` JSONB (objective id → int map), `completed_at` TIMESTAMPTZ NULL, `updated_at`, PK `(player_id, quest_id)`.
- **`PostgresPlayerQuestProgressBootstrap.EnsureSchema`** — idempotent DDL from migration file (NEO-44 pattern).
- **`PostgresPlayerQuestStateStore`** — same interface; deny writes when player missing from `player_position`; use transactions + `FOR UPDATE` on mutations.
5. **DI**
- **`QuestStateServiceCollectionExtensions.AddPlayerQuestStateStore(configuration)`**:
- When connection string set → `PostgresPlayerQuestStateStore`.
- Else → `InMemoryPlayerQuestStateStore`.
- Call from **`Program.cs`** after quest catalog registration.
- Extend **`InMemoryWebApplicationFactory`** — strip Postgres quest store + register in-memory implementation (same list as gig progression).
6. **Tests**
- **`InMemoryPlayerQuestStateStoreTests`**: AAA coverage for activate, advance (counter reset), counter update, idempotent complete, completed-row denial on activate/advance/counter, unknown player false, host DI resolve.
- **`PlayerQuestProgressPersistenceIntegrationTests`**: direct store mutation on `PostgresWebApplicationFactory`, second factory reads same row (no HTTP — store-only story).
7. **Docs**
- `server/README.md` — quest progress store section (interface methods, implicit `not_started`, Postgres table, note HTTP deferred to E7M1-08).
- `documentation_and_implementation_alignment.md` E7.M1 row — note NEO-116 store landed; register stays **Planned** until E7M1-06+ runtime.
### Prototype store behavior (frozen for tests)
| Step | Expected |
|------|----------|
| Missing row | `TryGetProgress` false; `TryActivate` → active, step 0, empty counters |
| Re-activate | `TryActivate` false (already active) |
| Counter | `TryUpdateObjectiveCounter(..., "gather_scrap", 3)` → counter map updated |
| Advance | `TryAdvanceStep(..., 1)` → step 1, counters cleared |
| Complete | `TryMarkComplete` once → true, status completed; second → false |
| Regression guard | After complete, `TryActivate` / `TryAdvanceStep` / `TryUpdateObjectiveCounter` all false |
Use frozen quest id **`prototype_quest_gather_intro`** and objective id from catalog in tests (load from `PrototypeE7M1QuestCatalogRules` / first step objective where practical).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Quests/QuestProgressStatus.cs` | Status enum for active/completed rows. |
| `server/NeonSprawl.Server/Game/Quests/QuestStepState.cs` | Immutable progress snapshot (module contract). |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressIds.cs` | Player/quest id normalization and composite key. |
| `server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs` | Store contract. |
| `server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs` | Thread-safe in-memory implementation. |
| `server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestProgressBootstrap.cs` | Applies V008 DDL once per process. |
| `server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs` | PostgreSQL-backed implementation. |
| `server/NeonSprawl.Server/Game/Quests/QuestStateServiceCollectionExtensions.cs` | DI: Postgres when configured, else in-memory. |
| `server/db/migrations/V008__player_quest_progress.sql` | Quest progress table DDL. |
| `server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs` | AAA unit + host DI tests. |
| `server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs` | Postgres cross-factory persistence test. |
| `docs/plans/NEO-116-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register `AddPlayerQuestStateStore` after quest catalog. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Force in-memory quest store in unit/integration tests (strip Postgres registration). |
| `server/README.md` | Document quest progress store, V008 table, implicit `not_started`, deferred HTTP. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — note NEO-116 store when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `InMemoryPlayerQuestStateStoreTests.cs` | **Unit:** `TryActivate` creates active row at step 0; second activate false. **Unit:** `TryUpdateObjectiveCounter` updates map; denied when completed. **Unit:** `TryAdvanceStep` bumps index and clears counters; denied when completed or non-increasing index. **Unit:** `TryMarkComplete` idempotent (second false, `CompletedAt` unchanged). **Unit:** completed row denies activate/advance/counter. **Unit:** unknown player / empty ids false. **Host:** `InMemoryWebApplicationFactory` resolves `IPlayerQuestStateStore`; smoke activate + get on dev player + frozen quest id. |
| `PlayerQuestProgressPersistenceIntegrationTests.cs` | **Postgres:** `TryActivate` + `TryUpdateObjectiveCounter` + `TryMarkComplete` on first factory; second `PostgresWebApplicationFactory` `TryGetProgress` returns completed row with same counters-at-complete semantics and `CompletedAt`. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **JSONB vs normalized objective counter table** | Single JSONB column on `player_quest_progress` — flexible for variable objectives per step; prototype four quests only. | **adopted** |
| **Quest id normalization** | Lowercase + trim via `QuestProgressIds`; store keys use normalized quest id; align with `IQuestDefinitionRegistry.TryNormalizeKnown` at operation layer (NEO-117). | **adopted** |
| **Dev fixture quest reset** | Omit this story; Bruno quest flows may need clear hook in E7M1-08/09 or follow NEO-108 encounter reset pattern. | **deferred** |
| **Constants for tests** | Reuse `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` / gather intro id; avoid duplicating quest id strings. | **adopted** |

View File

@ -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.

View File

@ -0,0 +1,71 @@
# Code review — NEO-116 (E7M1-05)
**Date:** 2026-06-03
**Scope:** Branch `NEO-116-player-quest-state-store` vs `origin/main` — commits `0658724``395b70c`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-116 adds **`IPlayerQuestStateStore`** with thread-safe **`InMemoryPlayerQuestStateStore`** (dev-player bucket) and **`PostgresPlayerQuestStateStore`** (`V008__player_quest_progress.sql`, JSONB objective counters, `FOR UPDATE` mutations). **`QuestStepState`**, **`QuestProgressStatus`**, and **`QuestProgressIds`** mirror the NEO-104 encounter store and NEO-44 Postgres patterns. DI via **`AddPlayerQuestStateStore`** is wired in **`Program.cs`**; tests force in-memory registration in **`InMemoryWebApplicationFactory`**. Eleven AAA unit/host tests cover activate, counters, step advance with counter reset, idempotent complete, completed-row denial, and unknown player; a Postgres cross-factory integration test validates persistence. Documentation (implementation plan, backlog AC, E7.M1 module page, alignment register, dependency register, `server/README.md`) is updated. Server-only infrastructure — correct scope. Risk is low; main follow-ups are test-layout consistency and minor plan/doc reconciliation for the Bruno health smoke.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-116-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate. |
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-05) | **Matches** — AC checked; in/out of scope aligned except Bruno note below. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — store bullet documents interface, V008, deferred HTTP/operations. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-116 store; register stays **Planned** until E7M1-06+ runtime. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note appended for E7M1-05 / NEO-116. |
| `server/README.md` | **Matches** — quest progress store section with methods, implicit `not_started`, V008, NEO-117 deferral. |
| Full-stack epic decomposition | **N/A** — server-only; client counterpart explicitly deferred (NEO-122 / NEO-123). |
**Register / tracking:** No register **Status** promotion required — E7.M1 correctly remains **Planned** until **`QuestStateOperations`** (NEO-117) lands.
## Blocking issues
(none)
## Suggestions
1. ~~**Persistence integration test AAA layout** — `PlayerQuestProgressPersistenceIntegrationTests.ActivateCounterComplete_ShouldPersistAcrossNewFactory` labels store writes as `// Act` inside the `// Arrange` block. Match **`PlayerInventoryPersistenceIntegrationTests`**: reset in Arrange; first `using` scope = Act (write); second factory scope = Act (read); Assert last.~~ **Done.** Persistence test now uses separate Arrange / Act (write) / Act (read) / Assert phases.
2. ~~**Empty-id denial tests** — Implementation plan test table lists “unknown player / empty ids false”; only unknown player is covered. Add a `[Fact]` (or theory) asserting `TryActivate` / `TryGetProgress` return false for empty/whitespace `playerId` or `questId`.~~ **Done.** `[Theory]` with four empty/whitespace player+quest id cases added to **`InMemoryPlayerQuestStateStoreTests`**.
3. ~~**Frozen quest id constants in tests** — Plan adopted reusing **`PrototypeE7M1QuestCatalogRules`**; tests use **`ChainQuestId`** but hardcode **`prototype_quest_gather_intro`** and objective ids. A **`GatherIntroQuestId`** constant (parallel to **`ChainQuestId`**) would avoid drift; optional helper for first-step objective ids if reused in NEO-117.~~ **Done.** Added **`GatherIntroQuestId`**, **`GatherIntroFirstObjectiveId`**, and **`ChainFirstObjectiveId`** to **`PrototypeE7M1QuestCatalogRules`**; tests updated.
4. ~~**Plan reconciliation for Bruno** — Kickoff/plan **out of scope** lists Bruno; branch adds `bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru` (health smoke only). Harmless, but add a one-line note under **Implementation reconciliation** so the plan matches shipped artifacts.~~ **Done.** Bruno health smoke noted in plan reconciliation and **`server/README.md`**.
## Nits
- ~~Nit: **`IPlayerQuestStateStore`** XML `<see cref="QuestStateOperations"/>` references a type not yet in the repo (NEO-117). Harmless for compile; consider `<c>QuestStateOperations</c>` until NEO-117 lands if doc warnings appear.~~ **Done.**
- ~~Nit: **`PostgresPlayerQuestStateStore.TryActivate`** returns false on duplicate row without explicit **`tx.Rollback()`** (relies on dispose). **`PostgresPlayerGigProgressionStore`** rolls back explicitly on failure paths — aligning would improve consistency.~~ **Done.**
- ~~Nit: **`ReadSnapshot`** uses **`reader.GetString(2)`** for JSONB counters; works if Npgsql maps JSONB as string (integration test validates when Postgres CI runs). If driver behavior changes, prefer **`GetFieldValue<string>`** or typed JSON read — same pattern worth watching in future JSONB stores.~~ **Done.** Uses **`GetFieldValue<string>(2)`**.
- ~~Nit: Bruno health folder is useful startup smoke but not linked from `server/README.md` (unlike NEO-115 quest-definitions Bruno link). Optional README mention when HTTP lands in E7M1-08.~~ **Done.** README links **`bruno/neon-sprawl-server/quest-progress/`** (health smoke until E7M1-08).
## Verification
```bash
# Quest progress store (primary signal for this story)
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~InMemoryPlayerQuestStateStoreTests|FullyQualifiedName~PlayerQuestProgressPersistence"
# Quest registry / world API regression (precursors)
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~QuestDefinitionRegistryTests|FullyQualifiedName~QuestDefinitionsWorldApiTests"
# Full server suite (CI)
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
**Reviewer note:** `InMemoryPlayerQuestStateStoreTests` + host DI test (11 tests) passed locally during review. Postgres integration test requires **`ConnectionStrings__NeonSprawl`** (`RequirePostgresFact`).
**Manual:** Optional Bruno `bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru` against dev server on port 5253 to confirm DI wiring after startup.

View File

@ -0,0 +1,185 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public sealed class InMemoryPlayerQuestStateStoreTests
{
private const string PlayerId = "dev-local-1";
private const string UnknownPlayerId = "unknown-player-xyz";
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId;
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void TryGetProgress_ShouldReturnFalse_WhenRowMissing()
{
// Arrange
var store = CreateStore();
// Act
var found = store.TryGetProgress(PlayerId, GatherQuestId, out _);
// Assert
Assert.False(found);
}
[Fact]
public void TryActivate_ShouldCreateActiveRowAtStepZero()
{
// Arrange
var store = CreateStore();
// Act
var activated = store.TryActivate(PlayerId, GatherQuestId, out var snapshot);
// Assert
Assert.True(activated);
Assert.Equal(QuestProgressStatus.Active, snapshot.Status);
Assert.Equal(0, snapshot.CurrentStepIndex);
Assert.Empty(snapshot.ObjectiveCounters);
Assert.Null(snapshot.CompletedAt);
}
[Fact]
public void TryActivate_ShouldReturnFalse_WhenAlreadyActive()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
// Act
var second = store.TryActivate(PlayerId, GatherQuestId, out var snapshot);
// Assert
Assert.False(second);
Assert.Equal(QuestProgressStatus.Active, snapshot.Status);
}
[Fact]
public void TryUpdateObjectiveCounter_ShouldUpdateMap_WhenActive()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
// Act
var updated = store.TryUpdateObjectiveCounter(PlayerId, GatherQuestId, GatherObjectiveId, 3, out var snapshot);
// Assert
Assert.True(updated);
Assert.Equal(3, snapshot.ObjectiveCounters[GatherObjectiveId]);
}
[Fact]
public void TryAdvanceStep_ShouldClearCountersAndBumpIndex()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryActivate(PlayerId, ChainQuestId, out _));
Assert.True(store.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId, 5, out _));
// Act
var advanced = store.TryAdvanceStep(PlayerId, ChainQuestId, 1, out var snapshot);
// Assert
Assert.True(advanced);
Assert.Equal(1, snapshot.CurrentStepIndex);
Assert.Empty(snapshot.ObjectiveCounters);
}
[Fact]
public void TryAdvanceStep_ShouldReturnFalse_WhenIndexNotIncreasing()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
// Act
var sameIndex = store.TryAdvanceStep(PlayerId, GatherQuestId, 0, out _);
// Assert
Assert.False(sameIndex);
}
[Fact]
public void TryMarkComplete_ShouldBeIdempotent()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
// Act
var first = store.TryMarkComplete(PlayerId, GatherQuestId, CompletedAt, out var firstSnapshot);
var second = store.TryMarkComplete(PlayerId, GatherQuestId, CompletedAt.AddHours(1), out var secondSnapshot);
// Assert
Assert.True(first);
Assert.False(second);
Assert.Equal(QuestProgressStatus.Completed, secondSnapshot.Status);
Assert.Equal(CompletedAt, secondSnapshot.CompletedAt);
Assert.Equal(firstSnapshot.CurrentStepIndex, secondSnapshot.CurrentStepIndex);
}
[Fact]
public void TryMarkComplete_ShouldDenyRegression_OnActivateAdvanceAndCounter()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
Assert.True(store.TryMarkComplete(PlayerId, GatherQuestId, CompletedAt, out _));
// Act
var reactivate = store.TryActivate(PlayerId, GatherQuestId, out _);
var advance = store.TryAdvanceStep(PlayerId, GatherQuestId, 1, out _);
var counter = store.TryUpdateObjectiveCounter(PlayerId, GatherQuestId, GatherObjectiveId, 1, out _);
// Assert
Assert.False(reactivate);
Assert.False(advance);
Assert.False(counter);
Assert.True(store.TryGetProgress(PlayerId, GatherQuestId, out var snapshot));
Assert.Equal(QuestProgressStatus.Completed, snapshot.Status);
}
[Fact]
public void TryActivate_ShouldReturnFalse_ForUnknownPlayer()
{
// Arrange
var store = CreateStore();
// Act
var activated = store.TryActivate(UnknownPlayerId, GatherQuestId, out _);
// Assert
Assert.False(activated);
}
[Theory]
[InlineData("", GatherQuestId)]
[InlineData(" ", GatherQuestId)]
[InlineData(PlayerId, "")]
[InlineData(PlayerId, " ")]
public void TryGetProgressAndActivate_ShouldReturnFalse_WhenPlayerOrQuestIdEmpty(
string playerId,
string questId)
{
// Arrange
var store = CreateStore();
// Act
var getProgress = store.TryGetProgress(playerId, questId, out _);
var activate = store.TryActivate(playerId, questId, out _);
// Assert
Assert.False(getProgress);
Assert.False(activate);
}
[Fact]
public async Task Host_ShouldResolveStore_AndActivateGatherIntro()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var scope = factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
// Act
var activated = store.TryActivate(PlayerId, GatherQuestId, out var snapshot);
var found = store.TryGetProgress(PlayerId, GatherQuestId, out var readBack);
// Assert
Assert.True(activated);
Assert.True(found);
Assert.Equal(snapshot.QuestId, readBack.QuestId);
Assert.Equal(QuestProgressStatus.Active, readBack.Status);
}
private static InMemoryPlayerQuestStateStore CreateStore()
{
var options = Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryPlayerQuestStateStore(options);
}
}

View File

@ -0,0 +1,132 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
[Collection("Postgres integration")]
public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 14, 30, 0, TimeSpan.Zero);
[RequirePostgresFact]
public async Task TryActivate_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently()
{
// Arrange
await ResetQuestProgressTableAsync();
const int concurrentCalls = 8;
var questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
// Act
var results = await Task.WhenAll(
Enumerable.Range(0, concurrentCalls).Select(_ => Task.Run(() =>
{
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
return store.TryActivate(PlayerId, questId, out QuestStepState _);
})));
// Assert
Assert.Equal(1, results.Count(static r => r));
Assert.Equal(concurrentCalls - 1, results.Count(static r => !r));
Assert.True(
Factory.Services.CreateScope().ServiceProvider
.GetRequiredService<IPlayerQuestStateStore>()
.TryGetProgress(PlayerId, questId, out var snapshot));
Assert.Equal(QuestProgressStatus.Active, snapshot.Status);
Assert.Equal(0, snapshot.CurrentStepIndex);
}
[RequirePostgresFact]
public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory()
{
// Arrange
await ResetQuestProgressTableAsync();
// Act — write through first host
using (var firstScope = Factory.Services.CreateScope())
{
var store = firstScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
Assert.True(store.TryActivate(PlayerId, PrototypeE7M1QuestCatalogRules.GatherIntroQuestId, out _));
Assert.True(store.TryUpdateObjectiveCounter(
PlayerId,
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId,
3,
out _));
Assert.True(store.TryMarkComplete(
PlayerId,
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
CompletedAt,
out _));
}
// Act — read back through a fresh host
QuestStepState readBack;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var secondScope = secondFactory.Services.CreateScope();
var store = secondScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
readBack = store.TryGetProgress(
PlayerId,
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
out var snapshot)
? snapshot
: null!;
}
// Assert
Assert.NotNull(readBack);
Assert.Equal(QuestProgressStatus.Completed, readBack.Status);
Assert.Equal(0, readBack.CurrentStepIndex);
Assert.Equal(3, readBack.ObjectiveCounters[PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId]);
Assert.Equal(CompletedAt, readBack.CompletedAt);
}
private async Task ResetQuestProgressTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var questDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V008__player_quest_progress.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(questDdlPath))
{
throw new FileNotFoundException("Test DDL for quest progress persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var questDdl = await File.ReadAllTextAsync(questDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyQuest = new NpgsqlCommand(questDdl, conn))
{
await applyQuest.ExecuteNonQueryAsync();
}
}
}

View File

@ -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);
}
}

View File

@ -83,6 +83,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) || d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerGigProgressionStore) || d.ServiceType == typeof(IPlayerGigProgressionStore) ||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
d.ServiceType == typeof(IPlayerInventoryStore) || d.ServiceType == typeof(IPlayerInventoryStore) ||
d.ServiceType == typeof(IResourceNodeInstanceStore) || d.ServiceType == typeof(IResourceNodeInstanceStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(IPlayerPerkStateStore) ||
@ -104,6 +105,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>(); services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>(); services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>(); services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>(); services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>(); services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();

View File

@ -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;
}
}
}

View File

@ -45,12 +45,8 @@ public static class RecipeDefinitionCatalogLoader
if (errors.Count > 0) if (errors.Count > 0)
ThrowIfAny(errors); ThrowIfAny(errors);
var ioSchemaText = File.ReadAllText(recipeIoRowSchemaPath); var ioSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(recipeIoRowSchemaPath);
var defSchemaText = File.ReadAllText(recipeDefSchemaPath); var defSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(recipeDefSchemaPath);
var ioSchema = JsonSchema.FromText(ioSchemaText);
SchemaRegistry.Global.Register(ioSchema);
var defSchema = JsonSchema.FromText(defSchemaText);
SchemaRegistry.Global.Register(defSchema);
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };

View File

@ -44,10 +44,8 @@ public static class RewardTableDefinitionCatalogLoader
if (errors.Count > 0) if (errors.Count > 0)
ThrowIfAny(errors); ThrowIfAny(errors);
var grantRowSchema = JsonSchema.FromText(File.ReadAllText(rewardGrantRowSchemaPath)); var grantRowSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
SchemaRegistry.Global.Register(grantRowSchema); var tableSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(rewardTableSchemaPath);
var tableSchema = JsonSchema.FromText(File.ReadAllText(rewardTableSchemaPath));
SchemaRegistry.Global.Register(tableSchema);
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };

View File

@ -0,0 +1,28 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Persisted per-player quest progress keyed by <c>(playerId, questId)</c> (NEO-116).
/// Missing row ⇒ <c>not_started</c>. <c>QuestStateOperations</c> (NEO-117) and HTTP (E7M1-08) consume this interface.
/// </summary>
public interface IPlayerQuestStateStore
{
/// <summary>Missing row ⇒ <c>false</c> (callers treat as <c>not_started</c>).</summary>
bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot);
/// <summary>Creates an active row at step 0 with empty counters. Returns <c>false</c> when already active/completed or player cannot be written.</summary>
bool TryActivate(string playerId, string questId, out QuestStepState snapshot);
/// <summary>Requires an active row; sets step index and clears objective counters. Denies when completed or <paramref name="newStepIndex"/> is not greater than current.</summary>
bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot);
/// <summary>Requires an active row; sets one objective counter (non-negative). Denies when completed.</summary>
bool TryUpdateObjectiveCounter(
string playerId,
string questId,
string objectiveId,
int newCount,
out QuestStepState snapshot);
/// <summary>First completion returns <c>true</c>; replays return <c>false</c> without changing <see cref="QuestStepState.CompletedAt"/>.</summary>
bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot);
}

View File

@ -0,0 +1,228 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Thread-safe in-memory quest progress; seeds the configured dev player (NEO-116).</summary>
public sealed class InMemoryPlayerQuestStateStore(IOptions<GamePositionOptions> options) : IPlayerQuestStateStore
{
private sealed class ProgressRow(
QuestProgressStatus status,
int currentStepIndex,
Dictionary<string, int> objectiveCounters,
DateTimeOffset? completedAt)
{
public QuestProgressStatus Status { get; private set; } = status;
public int CurrentStepIndex { get; private set; } = currentStepIndex;
public Dictionary<string, int> ObjectiveCounters { get; } = objectiveCounters;
public DateTimeOffset? CompletedAt { get; private set; } = completedAt;
public QuestStepState ToSnapshot(string playerId, string questId) =>
new(
playerId,
questId,
Status,
CurrentStepIndex,
new Dictionary<string, int>(ObjectiveCounters, StringComparer.Ordinal),
CompletedAt);
public void SetStepIndex(int newStepIndex)
{
CurrentStepIndex = newStepIndex;
ObjectiveCounters.Clear();
}
public void SetObjectiveCounter(string objectiveId, int newCount) =>
ObjectiveCounters[objectiveId] = newCount;
public void MarkCompleted(DateTimeOffset completedAt)
{
Status = QuestProgressStatus.Completed;
CompletedAt = completedAt;
}
public static ProgressRow CreateActive() =>
new(QuestProgressStatus.Active, 0, new Dictionary<string, int>(StringComparer.Ordinal), null);
}
private readonly HashSet<string> knownPlayers = CreateKnownPlayers(options.Value);
private readonly ConcurrentDictionary<string, ProgressRow> byKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
private static HashSet<string> CreateKnownPlayers(GamePositionOptions o)
{
var id = QuestProgressIds.NormalizePlayerId(o.DevPlayerId);
if (id.Length == 0)
{
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
}
return new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
}
/// <inheritdoc />
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row))
{
return false;
}
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
/// <inheritdoc />
public bool TryActivate(string playerId, string questId, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (byKey.TryGetValue(key, out var existing))
{
snapshot = existing.ToSnapshot(player, quest);
return false;
}
var row = ProgressRow.CreateActive();
byKey[key] = row;
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
/// <inheritdoc />
public bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot)
{
snapshot = null!;
if (newStepIndex < 0)
{
return false;
}
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row) ||
row.Status != QuestProgressStatus.Active ||
newStepIndex <= row.CurrentStepIndex)
{
if (byKey.TryGetValue(key, out var existing))
{
snapshot = existing.ToSnapshot(player, quest);
}
return false;
}
row.SetStepIndex(newStepIndex);
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
/// <inheritdoc />
public bool TryUpdateObjectiveCounter(
string playerId,
string questId,
string objectiveId,
int newCount,
out QuestStepState snapshot)
{
snapshot = null!;
if (newCount < 0)
{
return false;
}
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var objective = QuestProgressIds.NormalizeObjectiveId(objectiveId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || objective.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row) || row.Status != QuestProgressStatus.Active)
{
if (byKey.TryGetValue(key, out var existing))
{
snapshot = existing.ToSnapshot(player, quest);
}
return false;
}
row.SetObjectiveCounter(objective, newCount);
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
/// <inheritdoc />
public bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row))
{
return false;
}
if (row.Status == QuestProgressStatus.Completed)
{
snapshot = row.ToSnapshot(player, quest);
return false;
}
row.MarkCompleted(completedAt);
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Applies NEO-116 quest progress table DDL once per process.</summary>
public static class PostgresPlayerQuestProgressBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V008__player_quest_progress.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-116 DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = File.ReadAllText(ddlPath);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
cmd.ExecuteNonQuery();
Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -0,0 +1,367 @@
using System.Text.Json;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>PostgreSQL-backed quest progress keyed by normalized player id + quest id (NEO-116).</summary>
public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerQuestStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <inheritdoc />
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0)
{
return false;
}
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, player))
{
return false;
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT status, current_step_index, objective_counters, completed_at
FROM player_quest_progress
WHERE player_id = @pid AND quest_id = @qid
LIMIT 1;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("qid", quest);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
return false;
}
snapshot = ReadSnapshot(reader, player, quest);
return true;
}
/// <inheritdoc />
public bool TryActivate(string playerId, string questId, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0)
{
return false;
}
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, player, tx))
{
tx.Rollback();
return false;
}
using var insert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_quest_progress (player_id, quest_id, status, current_step_index, objective_counters, updated_at)
VALUES (@pid, @qid, 'active', 0, '{}'::jsonb, now())
ON CONFLICT (player_id, quest_id) DO NOTHING;
""",
conn,
tx);
insert.Parameters.AddWithValue("pid", player);
insert.Parameters.AddWithValue("qid", quest);
if (insert.ExecuteNonQuery() > 0)
{
tx.Commit();
snapshot = new QuestStepState(
player,
quest,
QuestProgressStatus.Active,
0,
new Dictionary<string, int>(StringComparer.Ordinal),
null);
return true;
}
using (var sel = new Npgsql.NpgsqlCommand(
"""
SELECT status, current_step_index, objective_counters, completed_at
FROM player_quest_progress
WHERE player_id = @pid AND quest_id = @qid
LIMIT 1
FOR UPDATE;
""",
conn,
tx))
{
sel.Parameters.AddWithValue("pid", player);
sel.Parameters.AddWithValue("qid", quest);
using var reader = sel.ExecuteReader();
if (!reader.Read())
{
tx.Rollback();
return false;
}
snapshot = ReadSnapshot(reader, player, quest);
}
tx.Rollback();
return false;
}
/// <inheritdoc />
public bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot)
{
snapshot = null!;
if (newStepIndex < 0)
{
return false;
}
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0)
{
return false;
}
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
{
if (row.Status != QuestProgressStatus.Active || newStepIndex <= row.CurrentStepIndex)
{
result = row;
return false;
}
result = new QuestStepState(
row.PlayerId,
row.QuestId,
row.Status,
newStepIndex,
new Dictionary<string, int>(StringComparer.Ordinal),
row.CompletedAt);
return true;
}, out snapshot);
}
/// <inheritdoc />
public bool TryUpdateObjectiveCounter(
string playerId,
string questId,
string objectiveId,
int newCount,
out QuestStepState snapshot)
{
snapshot = null!;
if (newCount < 0)
{
return false;
}
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var objective = QuestProgressIds.NormalizeObjectiveId(objectiveId);
if (player.Length == 0 || quest.Length == 0 || objective.Length == 0)
{
return false;
}
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
{
if (row.Status != QuestProgressStatus.Active)
{
result = row;
return false;
}
var counters = new Dictionary<string, int>(row.ObjectiveCounters, StringComparer.Ordinal);
counters[objective] = newCount;
result = new QuestStepState(
row.PlayerId,
row.QuestId,
row.Status,
row.CurrentStepIndex,
counters,
row.CompletedAt);
return true;
}, out snapshot);
}
/// <inheritdoc />
public bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0)
{
return false;
}
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
{
if (row.Status == QuestProgressStatus.Completed)
{
result = row;
return false;
}
result = new QuestStepState(
row.PlayerId,
row.QuestId,
QuestProgressStatus.Completed,
row.CurrentStepIndex,
row.ObjectiveCounters,
completedAt);
return true;
}, out snapshot);
}
private bool TryMutateRow(
string player,
string quest,
TryMutateRowDelegate mutator,
out QuestStepState snapshot)
{
snapshot = null!;
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, player, tx))
{
tx.Rollback();
return false;
}
QuestStepState current;
using (var sel = new Npgsql.NpgsqlCommand(
"""
SELECT status, current_step_index, objective_counters, completed_at
FROM player_quest_progress
WHERE player_id = @pid AND quest_id = @qid
LIMIT 1
FOR UPDATE;
""",
conn,
tx))
{
sel.Parameters.AddWithValue("pid", player);
sel.Parameters.AddWithValue("qid", quest);
using var reader = sel.ExecuteReader();
if (!reader.Read())
{
tx.Rollback();
return false;
}
current = ReadSnapshot(reader, player, quest);
}
if (!mutator(current, out var updated))
{
snapshot = current;
tx.Rollback();
return false;
}
WriteRow(conn, player, quest, updated, tx);
tx.Commit();
snapshot = updated;
return true;
}
private static void WriteRow(
Npgsql.NpgsqlConnection conn,
string player,
string quest,
QuestStepState row,
Npgsql.NpgsqlTransaction tx)
{
var countersJson = JsonSerializer.Serialize(row.ObjectiveCounters, JsonOptions);
using var cmd = new Npgsql.NpgsqlCommand(
"""
UPDATE player_quest_progress
SET status = @status,
current_step_index = @step,
objective_counters = @counters::jsonb,
completed_at = @completed_at,
updated_at = now()
WHERE player_id = @pid AND quest_id = @qid;
""",
conn,
tx);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("qid", quest);
cmd.Parameters.AddWithValue("status", ToPersistenceStatus(row.Status));
cmd.Parameters.AddWithValue("step", row.CurrentStepIndex);
cmd.Parameters.AddWithValue("counters", countersJson);
cmd.Parameters.AddWithValue("completed_at", (object?)row.CompletedAt ?? DBNull.Value);
cmd.ExecuteNonQuery();
}
private static QuestStepState ReadSnapshot(Npgsql.NpgsqlDataReader reader, string player, string quest)
{
var status = ParseStatus(reader.GetString(0));
var stepIndex = reader.GetInt32(1);
var countersJson = reader.GetFieldValue<string>(2);
var completedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3);
var counters = DeserializeCounters(countersJson);
return new QuestStepState(player, quest, status, stepIndex, counters, completedAt);
}
private static Dictionary<string, int> DeserializeCounters(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == "{}")
{
return new Dictionary<string, int>(StringComparer.Ordinal);
}
var parsed = JsonSerializer.Deserialize<Dictionary<string, int>>(json, JsonOptions);
return parsed is null
? new Dictionary<string, int>(StringComparer.Ordinal)
: new Dictionary<string, int>(parsed, StringComparer.Ordinal);
}
private static QuestProgressStatus ParseStatus(string raw) =>
raw switch
{
"active" => QuestProgressStatus.Active,
"completed" => QuestProgressStatus.Completed,
_ => throw new InvalidOperationException($"Unknown quest progress status '{raw}'."),
};
private static string ToPersistenceStatus(QuestProgressStatus status) =>
status switch
{
QuestProgressStatus.Active => "active",
QuestProgressStatus.Completed => "completed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized) =>
PlayerExists(conn, playerIdNormalized, null);
private static bool PlayerExists(
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
Npgsql.NpgsqlTransaction? tx)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private delegate bool TryMutateRowDelegate(QuestStepState current, out QuestStepState updated);
}

View File

@ -23,6 +23,15 @@ public static class PrototypeE7M1QuestCatalogRules
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary> /// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary>
public const string ChainQuestId = "prototype_quest_operator_chain"; public const string ChainQuestId = "prototype_quest_operator_chain";
/// <summary>First frozen onboarding quest id (gather intro).</summary>
public const string GatherIntroQuestId = "prototype_quest_gather_intro";
/// <summary>First objective id on gather intro step (frozen catalog).</summary>
public const string GatherIntroFirstObjectiveId = "gather_intro_obj_scrap";
/// <summary>First objective id on operator chain step 0 (frozen catalog).</summary>
public const string ChainFirstObjectiveId = "chain_obj_gather";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary> /// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary>
public const string ChainTerminalItemId = "contract_handoff_token"; public const string ChainTerminalItemId = "contract_handoff_token";

View File

@ -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> /// <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 public static class QuestDefinitionCatalogLoader
{ {
private static readonly Lock SchemaLoadLock = new();
private static JsonSchema? _questObjectiveDefSchema; private static JsonSchema? _questObjectiveDefSchema;
private static JsonSchema? _questStepDefSchema; private static JsonSchema? _questStepDefSchema;
private static JsonSchema? _questDefSchema; private static JsonSchema? _questDefSchema;
@ -352,23 +351,12 @@ public static class QuestDefinitionCatalogLoader
string questStepDefSchemaPath, string questStepDefSchemaPath,
string questDefSchemaPath) string questDefSchemaPath)
{ {
lock (SchemaLoadLock) if (_questDefSchema is not null)
{ return;
if (_questDefSchema is not null)
return;
var objectiveSchema = JsonSchema.FromText(File.ReadAllText(questObjectiveDefSchemaPath)); _questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath);
SchemaRegistry.Global.Register(objectiveSchema); _questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath);
_questObjectiveDefSchema = objectiveSchema; _questDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questDefSchemaPath);
var stepSchema = JsonSchema.FromText(File.ReadAllText(questStepDefSchemaPath));
SchemaRegistry.Global.Register(stepSchema);
_questStepDefSchema = stepSchema;
var defSchema = JsonSchema.FromText(File.ReadAllText(questDefSchemaPath));
SchemaRegistry.Global.Register(defSchema);
_questDefSchema = defSchema;
}
} }
private static void ThrowIfAny(List<string> errors) private static void ThrowIfAny(List<string> errors)

View File

@ -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; }
}

View File

@ -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,
},
};
}

View File

@ -0,0 +1,36 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Id normalization for player quest progress stores (NEO-116).</summary>
public static class QuestProgressIds
{
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizePlayerId(string? playerId)
{
var trimmed = playerId?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
return string.Empty;
}
return trimmed.ToLowerInvariant();
}
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId);
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeObjectiveId(string? objectiveId) => NormalizePlayerId(objectiveId);
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="questId"/>.</summary>
public static string MakeProgressKey(string? playerId, string? questId)
{
var p = NormalizePlayerId(playerId);
var q = NormalizeQuestId(questId);
if (p.Length == 0 || q.Length == 0)
{
return string.Empty;
}
return $"{p}\0{q}";
}
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Persisted quest row status (NEO-116). Missing store row implies <c>not_started</c> at call sites.</summary>
public enum QuestProgressStatus
{
Active,
Completed,
}

View File

@ -0,0 +1,22 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Registers quest progress persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-116).</summary>
public static class QuestStateServiceCollectionExtensions
{
public static IServiceCollection AddPlayerQuestStateStore(this IServiceCollection services, IConfiguration configuration)
{
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IPlayerQuestStateStore, PostgresPlayerQuestStateStore>();
}
else
{
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
}
return services;
}
}

View File

@ -0,0 +1,24 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Immutable per-player quest progress snapshot (NEO-116; module contract <c>QuestStepState</c>).</summary>
public sealed class QuestStepState(
string playerId,
string questId,
QuestProgressStatus status,
int currentStepIndex,
IReadOnlyDictionary<string, int> objectiveCounters,
DateTimeOffset? completedAt)
{
public string PlayerId { get; } = playerId;
public string QuestId { get; } = questId;
public QuestProgressStatus Status { get; } = status;
public int CurrentStepIndex { get; } = currentStepIndex;
/// <summary>Objective id → accumulated count for the <see cref="CurrentStepIndex"/> step only.</summary>
public IReadOnlyDictionary<string, int> ObjectiveCounters { get; } = objectiveCounters;
public DateTimeOffset? CompletedAt { get; } = completedAt;
}

View File

@ -29,6 +29,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
builder.Services.AddThreatStateStore(); builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddPlayerCombatHealthStore(); builder.Services.AddPlayerCombatHealthStore();
@ -70,6 +71,7 @@ app.MapNpcRuntimeSnapshotWorldApi();
app.MapPlayerCombatHealthApi(); app.MapPlayerCombatHealthApi();
app.MapResourceNodeDefinitionsWorldApi(); app.MapResourceNodeDefinitionsWorldApi();
app.MapEncounterDefinitionsWorldApi(); app.MapEncounterDefinitionsWorldApi();
app.MapQuestDefinitionsWorldApi();
app.MapPlayerInventoryApi(); app.MapPlayerInventoryApi();
app.MapPlayerCraftApi(); app.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi(); app.MapSkillProgressionSnapshotApi();

View File

@ -148,6 +148,30 @@ 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. 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"
```
## Quest progress store (NEO-116)
Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Mutations are server-authoritative — HTTP read/accept routes land in E7M1-08/09 ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)+ for **`QuestStateOperations`**).
**Interface methods:**
- **`TryGetProgress`** — read one row; false when not started.
- **`TryActivate`** — first accept (`not_started` → `active`).
- **`TryAdvanceStep`** — bump step index and clear counters for the new step (denies when completed or index does not increase).
- **`TryUpdateObjectiveCounter`** — set one objective counter on the current step (non-negative).
- **`TryMarkComplete`** — idempotent completion flag + **`completedAt`** timestamp.
Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids should be validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** at operation/HTTP layers (NEO-117+).
**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresPlayerQuestStateStore`** persists to **`player_quest_progress`** ([`V008__player_quest_progress.sql`](../db/migrations/V008__player_quest_progress.sql)) with **`objective_counters`** JSONB for the current step. Plan: [NEO-116 implementation plan](../../docs/plans/NEO-116-implementation-plan.md). Bruno startup smoke: `bruno/neon-sprawl-server/quest-progress/` (health only until E7M1-08 HTTP).
## Encounter definitions (NEO-103) ## 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/`. **`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/`.

View File

@ -0,0 +1,13 @@
-- NEO-116: per-player quest progress rows (prototype E7.M1 Slice 1).
CREATE TABLE IF NOT EXISTS player_quest_progress (
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
quest_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'completed')),
current_step_index INTEGER NOT NULL CHECK (current_step_index >= 0),
objective_counters JSONB NOT NULL DEFAULT '{}'::jsonb,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (player_id, quest_id)
);
COMMENT ON TABLE player_quest_progress IS 'Persisted quest step state per player (NEO-116); missing row means not_started.';