From 065872405ce96faa538421d529faaff5091bf83a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 23:01:59 -0400 Subject: [PATCH 1/5] 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. --- docs/plans/NEO-116-implementation-plan.md | 151 ++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/plans/NEO-116-implementation-plan.md diff --git a/docs/plans/NEO-116-implementation-plan.md b/docs/plans/NEO-116-implementation-plan.md new file mode 100644 index 0000000..fc114f3 --- /dev/null +++ b/docs/plans/NEO-116-implementation-plan.md @@ -0,0 +1,151 @@ +# 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 + +- [ ] Store supports read/update per `(playerId, questId)`. +- [ ] Completed quest cannot regress to active without explicit reset API (none in prototype — store denies `TryActivate` / `TryAdvanceStep` / counter updates when completed). +- [ ] Idempotent complete: second `TryMarkComplete` returns `false` without changing `CompletedAt` (Linear AC). + +## Technical approach + +1. **Types (`Game/Quests/`)** + - **`QuestProgressStatus`**: `Active`, `Completed`. + - **`QuestStepState`**: immutable snapshot for one quest row — `PlayerId`, `QuestId`, `Status`, `CurrentStepIndex` (0-based), `IReadOnlyDictionary 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` 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** | From 395b70c03026198cce00764472909c0bf6adacea Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 23:04:50 -0400 Subject: [PATCH 2/5] 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. --- .../Health after quest state store load.bru | 25 ++ .../modules/E7_M1_QuestStateMachine.md | 4 +- ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/plans/E7M1-prototype-backlog.md | 4 +- docs/plans/NEO-116-implementation-plan.md | 14 +- .../InMemoryPlayerQuestStateStoreTests.cs | 166 ++++++++ ...uestProgressPersistenceIntegrationTests.cs | 90 +++++ .../InMemoryWebApplicationFactory.cs | 2 + .../Game/Quests/IPlayerQuestStateStore.cs | 28 ++ .../Quests/InMemoryPlayerQuestStateStore.cs | 228 +++++++++++ .../PostgresPlayerQuestProgressBootstrap.cs | 39 ++ .../Quests/PostgresPlayerQuestStateStore.cs | 359 ++++++++++++++++++ .../Game/Quests/QuestProgressIds.cs | 36 ++ .../Game/Quests/QuestProgressStatus.cs | 8 + .../QuestStateServiceCollectionExtensions.cs | 22 ++ .../Game/Quests/QuestStepState.cs | 24 ++ server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 16 + .../V008__player_quest_progress.sql | 13 + 20 files changed, 1075 insertions(+), 8 deletions(-) create mode 100644 bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestProgressBootstrap.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestProgressIds.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestProgressStatus.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestStateServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestStepState.cs create mode 100644 server/db/migrations/V008__player_quest_progress.sql diff --git a/bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru b/bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru new file mode 100644 index 0000000..d039b00 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru @@ -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"); + }); +} diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md index d802743..be8a357 100644 --- a/docs/decomposition/modules/E7_M1_QuestStateMachine.md +++ b/docs/decomposition/modules/E7_M1_QuestStateMachine.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M1 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Prototype | -| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; runtime from **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) | +| **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) | ## Purpose @@ -82,6 +82,8 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del **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. ## Risks and telemetry diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index e2492ff..f1e5b2a 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M1 | Planned | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **Backlog decomposed (runtime from E7M1-05+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until quest runtime (E7M1-05+) lands. | [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | +| E7.M1 | Planned | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **Backlog decomposed (runtime from E7M1-06+):** Epic 7 Slice 1 — [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Four frozen quests (3 onboarding + 1 gather→craft→combat chain); solo only; state-only completion in Slice 1 (rewards deferred to E7.M2). Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. Register row stays **Planned** until quest runtime (E7M1-06+) lands. | [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 4692cac..e118a21 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl |---|---|---|---|---|---| | E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned | -**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); 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.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned | diff --git a/docs/plans/E7M1-prototype-backlog.md b/docs/plans/E7M1-prototype-backlog.md index 3305a43..bb0fbfd 100644 --- a/docs/plans/E7M1-prototype-backlog.md +++ b/docs/plans/E7M1-prototype-backlog.md @@ -190,8 +190,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco **Acceptance criteria** -- [ ] Store supports read/update per `(playerId, questId)`. -- [ ] Completed quest cannot regress to active without explicit reset API (none in prototype). +- [x] Store supports read/update per `(playerId, questId)`. +- [x] Completed quest cannot regress to active without explicit reset API (none in prototype). **Client counterpart:** none (infrastructure-only). diff --git a/docs/plans/NEO-116-implementation-plan.md b/docs/plans/NEO-116-implementation-plan.md index fc114f3..105a0bf 100644 --- a/docs/plans/NEO-116-implementation-plan.md +++ b/docs/plans/NEO-116-implementation-plan.md @@ -51,9 +51,17 @@ ## Acceptance criteria checklist -- [ ] Store supports read/update per `(playerId, questId)`. -- [ ] Completed quest cannot regress to active without explicit reset API (none in prototype — store denies `TryActivate` / `TryAdvanceStep` / counter updates when completed). -- [ ] Idempotent complete: second `TryMarkComplete` returns `false` without changing `CompletedAt` (Linear AC). +- [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` (10 AAA cases + host DI); `PlayerQuestProgressPersistenceIntegrationTests` (`RequirePostgresFact`). +- **Docs:** `server/README.md` quest progress store section; alignment register E7.M1 row updated. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs new file mode 100644 index 0000000..12d0645 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs @@ -0,0 +1,166 @@ +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 = "prototype_quest_gather_intro"; + private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; + private const string GatherObjectiveId = "gather_intro_obj_scrap"; + 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, "chain_obj_gather", 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); + } + + [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(); + // 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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs new file mode 100644 index 0000000..8b21b7d --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs @@ -0,0 +1,90 @@ +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 const string GatherQuestId = "prototype_quest_gather_intro"; + private const string GatherObjectiveId = "gather_intro_obj_scrap"; + private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 14, 30, 0, TimeSpan.Zero); + + [RequirePostgresFact] + public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory() + { + // Arrange + await ResetQuestProgressTableAsync(); + using (var scope = Factory.Services.CreateScope()) + { + var store = scope.ServiceProvider.GetRequiredService(); + // Act — write on first host + Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _)); + Assert.True(store.TryUpdateObjectiveCounter(PlayerId, GatherQuestId, GatherObjectiveId, 3, out _)); + Assert.True(store.TryMarkComplete(PlayerId, GatherQuestId, CompletedAt, out _)); + } + + QuestStepState readBack; + await using (var secondFactory = new PostgresWebApplicationFactory()) + { + using var scope = secondFactory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + readBack = store.TryGetProgress(PlayerId, GatherQuestId, 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[GatherObjectiveId]); + 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>().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(); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index d7f3cf7..5e74ce8 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -83,6 +83,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs new file mode 100644 index 0000000..b1276f6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs @@ -0,0 +1,28 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Persisted per-player quest progress keyed by (playerId, questId) (NEO-116). +/// Missing row ⇒ not_started. (NEO-117) and HTTP (E7M1-08) consume this interface. +/// +public interface IPlayerQuestStateStore +{ + /// Missing row ⇒ false (callers treat as not_started). + bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot); + + /// Creates an active row at step 0 with empty counters. Returns false when already active/completed or player cannot be written. + bool TryActivate(string playerId, string questId, out QuestStepState snapshot); + + /// Requires an active row; sets step index and clears objective counters. Denies when completed or is not greater than current. + bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot); + + /// Requires an active row; sets one objective counter (non-negative). Denies when completed. + bool TryUpdateObjectiveCounter( + string playerId, + string questId, + string objectiveId, + int newCount, + out QuestStepState snapshot); + + /// First completion returns true; replays return false without changing . + bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot); +} diff --git a/server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs new file mode 100644 index 0000000..c32e320 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs @@ -0,0 +1,228 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Quests; + +/// Thread-safe in-memory quest progress; seeds the configured dev player (NEO-116). +public sealed class InMemoryPlayerQuestStateStore(IOptions options) : IPlayerQuestStateStore +{ + private sealed class ProgressRow( + QuestProgressStatus status, + int currentStepIndex, + Dictionary objectiveCounters, + DateTimeOffset? completedAt) + { + public QuestProgressStatus Status { get; private set; } = status; + + public int CurrentStepIndex { get; private set; } = currentStepIndex; + + public Dictionary ObjectiveCounters { get; } = objectiveCounters; + + public DateTimeOffset? CompletedAt { get; private set; } = completedAt; + + public QuestStepState ToSnapshot(string playerId, string questId) => + new( + playerId, + questId, + Status, + CurrentStepIndex, + new Dictionary(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(StringComparer.Ordinal), null); + } + + private readonly HashSet knownPlayers = CreateKnownPlayers(options.Value); + + private readonly ConcurrentDictionary byKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); + + private static HashSet 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(StringComparer.OrdinalIgnoreCase) { id }; + } + + /// + 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; + } + } + + /// + 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; + } + } + + /// + 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; + } + } + + /// + 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; + } + } + + /// + 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; + } + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestProgressBootstrap.cs b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestProgressBootstrap.cs new file mode 100644 index 0000000..4d528db --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestProgressBootstrap.cs @@ -0,0 +1,39 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Applies NEO-116 quest progress table DDL once per process. +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); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs new file mode 100644 index 0000000..22e0ef7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs @@ -0,0 +1,359 @@ +using System.Text.Json; + +namespace NeonSprawl.Server.Game.Quests; + +/// PostgreSQL-backed quest progress keyed by normalized player id + quest id (NEO-116). +public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerQuestStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + 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; + } + + /// + 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 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()) + { + snapshot = ReadSnapshot(reader, player, quest); + 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()); + """, + conn, + tx); + insert.Parameters.AddWithValue("pid", player); + insert.Parameters.AddWithValue("qid", quest); + insert.ExecuteNonQuery(); + tx.Commit(); + snapshot = new QuestStepState( + player, + quest, + QuestProgressStatus.Active, + 0, + new Dictionary(StringComparer.Ordinal), + null); + return true; + } + + /// + 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(StringComparer.Ordinal), + row.CompletedAt); + return true; + }, out snapshot); + } + + /// + 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(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); + } + + /// + 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.GetString(2); + var completedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue(3); + var counters = DeserializeCounters(countersJson); + return new QuestStepState(player, quest, status, stepIndex, counters, completedAt); + } + + private static Dictionary DeserializeCounters(string json) + { + if (string.IsNullOrWhiteSpace(json) || json == "{}") + { + return new Dictionary(StringComparer.Ordinal); + } + + var parsed = JsonSerializer.Deserialize>(json, JsonOptions); + return parsed is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(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); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressIds.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressIds.cs new file mode 100644 index 0000000..113ff79 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressIds.cs @@ -0,0 +1,36 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Id normalization for player quest progress stores (NEO-116). +public static class QuestProgressIds +{ + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizePlayerId(string? playerId) + { + var trimmed = playerId?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return string.Empty; + } + + return trimmed.ToLowerInvariant(); + } + + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId); + + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizeObjectiveId(string? objectiveId) => NormalizePlayerId(objectiveId); + + /// Composite store key for + . + 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}"; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressStatus.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressStatus.cs new file mode 100644 index 0000000..f917fca --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressStatus.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Persisted quest row status (NEO-116). Missing store row implies not_started at call sites. +public enum QuestProgressStatus +{ + Active, + Completed, +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateServiceCollectionExtensions.cs new file mode 100644 index 0000000..b55bf23 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Quests; + +/// Registers quest progress persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-116). +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(); + } + else + { + services.AddSingleton(); + } + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStepState.cs b/server/NeonSprawl.Server/Game/Quests/QuestStepState.cs new file mode 100644 index 0000000..c8e57a9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestStepState.cs @@ -0,0 +1,24 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Immutable per-player quest progress snapshot (NEO-116; module contract QuestStepState). +public sealed class QuestStepState( + string playerId, + string questId, + QuestProgressStatus status, + int currentStepIndex, + IReadOnlyDictionary objectiveCounters, + DateTimeOffset? completedAt) +{ + public string PlayerId { get; } = playerId; + + public string QuestId { get; } = questId; + + public QuestProgressStatus Status { get; } = status; + + public int CurrentStepIndex { get; } = currentStepIndex; + + /// Objective id → accumulated count for the step only. + public IReadOnlyDictionary ObjectiveCounters { get; } = objectiveCounters; + + public DateTimeOffset? CompletedAt { get; } = completedAt; +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index c50f10f..70118b9 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -29,6 +29,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration); +builder.Services.AddPlayerQuestStateStore(builder.Configuration); builder.Services.AddThreatStateStore(); builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddPlayerCombatHealthStore(); diff --git a/server/README.md b/server/README.md index 7c5917d..222d8bb 100644 --- a/server/README.md +++ b/server/README.md @@ -156,6 +156,22 @@ On success, **Information** logs include the resolved quests directory path, dis 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). + ## 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/`. diff --git a/server/db/migrations/V008__player_quest_progress.sql b/server/db/migrations/V008__player_quest_progress.sql new file mode 100644 index 0000000..bdffebe --- /dev/null +++ b/server/db/migrations/V008__player_quest_progress.sql @@ -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.'; From af87b5d12c3677f8cfbde742c1cd977a0ed3190c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 23:16:48 -0400 Subject: [PATCH 3/5] NEO-116: Add code review for player quest state store. --- docs/reviews/2026-06-03-NEO-116.md | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/reviews/2026-06-03-NEO-116.md diff --git a/docs/reviews/2026-06-03-NEO-116.md b/docs/reviews/2026-06-03-NEO-116.md new file mode 100644 index 0000000..4bf5890 --- /dev/null +++ b/docs/reviews/2026-06-03-NEO-116.md @@ -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. + +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`. + +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. + +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. + +## Nits + +- Nit: **`IPlayerQuestStateStore`** XML `` references a type not yet in the repo (NEO-117). Harmless for compile; consider `QuestStateOperations` until NEO-117 lands if doc warnings appear. + +- 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. + +- 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`** or typed JSON read — same pattern worth watching in future JSONB stores. + +- 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. + +## 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. From de611ac2943d61a1b2c8457168e5acc959502f07 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 23:19:52 -0400 Subject: [PATCH 4/5] 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. --- docs/plans/NEO-116-implementation-plan.md | 3 +- docs/reviews/2026-06-03-NEO-116.md | 16 ++++---- .../InMemoryPlayerQuestStateStoreTests.cs | 25 ++++++++++-- ...uestProgressPersistenceIntegrationTests.cs | 38 +++++++++++++------ .../Game/Quests/IPlayerQuestStateStore.cs | 2 +- .../Quests/PostgresPlayerQuestStateStore.cs | 3 +- .../Quests/PrototypeE7M1QuestCatalogRules.cs | 9 +++++ server/README.md | 2 +- 8 files changed, 71 insertions(+), 27 deletions(-) diff --git a/docs/plans/NEO-116-implementation-plan.md b/docs/plans/NEO-116-implementation-plan.md index 105a0bf..b992196 100644 --- a/docs/plans/NEO-116-implementation-plan.md +++ b/docs/plans/NEO-116-implementation-plan.md @@ -60,7 +60,8 @@ - **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` (10 AAA cases + host DI); `PlayerQuestProgressPersistenceIntegrationTests` (`RequirePostgresFact`). +- **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 diff --git a/docs/reviews/2026-06-03-NEO-116.md b/docs/reviews/2026-06-03-NEO-116.md index 4bf5890..36e89a8 100644 --- a/docs/reviews/2026-06-03-NEO-116.md +++ b/docs/reviews/2026-06-03-NEO-116.md @@ -32,23 +32,23 @@ NEO-116 adds **`IPlayerQuestStateStore`** with thread-safe **`InMemoryPlayerQues ## 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. +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`. +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. +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. +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 `` references a type not yet in the repo (NEO-117). Harmless for compile; consider `QuestStateOperations` until NEO-117 lands if doc warnings appear. +- ~~Nit: **`IPlayerQuestStateStore`** XML `` references a type not yet in the repo (NEO-117). Harmless for compile; consider `QuestStateOperations` 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. +- ~~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`** or typed JSON read — same pattern worth watching in future JSONB stores. +- ~~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`** or typed JSON read — same pattern worth watching in future JSONB stores.~~ **Done.** Uses **`GetFieldValue(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. +- ~~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 diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs index 12d0645..a741451 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs @@ -10,9 +10,9 @@ public sealed class InMemoryPlayerQuestStateStoreTests { private const string PlayerId = "dev-local-1"; private const string UnknownPlayerId = "unknown-player-xyz"; - private const string GatherQuestId = "prototype_quest_gather_intro"; + private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; - private const string GatherObjectiveId = "gather_intro_obj_scrap"; + private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId; private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 12, 0, 0, TimeSpan.Zero); [Fact] @@ -73,7 +73,7 @@ public sealed class InMemoryPlayerQuestStateStoreTests // Arrange var store = CreateStore(); Assert.True(store.TryActivate(PlayerId, ChainQuestId, out _)); - Assert.True(store.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_gather", 5, out _)); + Assert.True(store.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId, 5, out _)); // Act var advanced = store.TryAdvanceStep(PlayerId, ChainQuestId, 1, out var snapshot); // Assert @@ -141,6 +141,25 @@ public sealed class InMemoryPlayerQuestStateStoreTests 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() { diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs index 8b21b7d..d405783 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs @@ -14,8 +14,6 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg private PostgresWebApplicationFactory Factory => harness.Factory; private const string PlayerId = "dev-local-1"; - private const string GatherQuestId = "prototype_quest_gather_intro"; - private const string GatherObjectiveId = "gather_intro_obj_scrap"; private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 14, 30, 0, TimeSpan.Zero); [RequirePostgresFact] @@ -23,28 +21,44 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg { // Arrange await ResetQuestProgressTableAsync(); - using (var scope = Factory.Services.CreateScope()) + + // Act — write through first host + using (var firstScope = Factory.Services.CreateScope()) { - var store = scope.ServiceProvider.GetRequiredService(); - // Act — write on first host - Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _)); - Assert.True(store.TryUpdateObjectiveCounter(PlayerId, GatherQuestId, GatherObjectiveId, 3, out _)); - Assert.True(store.TryMarkComplete(PlayerId, GatherQuestId, CompletedAt, out _)); + var store = firstScope.ServiceProvider.GetRequiredService(); + 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 scope = secondFactory.Services.CreateScope(); - var store = scope.ServiceProvider.GetRequiredService(); - readBack = store.TryGetProgress(PlayerId, GatherQuestId, out var snapshot) ? snapshot : null!; + using var secondScope = secondFactory.Services.CreateScope(); + var store = secondScope.ServiceProvider.GetRequiredService(); + 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[GatherObjectiveId]); + Assert.Equal(3, readBack.ObjectiveCounters[PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId]); Assert.Equal(CompletedAt, readBack.CompletedAt); } diff --git a/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs index b1276f6..53342d4 100644 --- a/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs +++ b/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs @@ -2,7 +2,7 @@ namespace NeonSprawl.Server.Game.Quests; /// /// Persisted per-player quest progress keyed by (playerId, questId) (NEO-116). -/// Missing row ⇒ not_started. (NEO-117) and HTTP (E7M1-08) consume this interface. +/// Missing row ⇒ not_started. QuestStateOperations (NEO-117) and HTTP (E7M1-08) consume this interface. /// public interface IPlayerQuestStateStore { diff --git a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs index 22e0ef7..135c828 100644 --- a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs +++ b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs @@ -85,6 +85,7 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo if (reader.Read()) { snapshot = ReadSnapshot(reader, player, quest); + tx.Rollback(); return false; } } @@ -304,7 +305,7 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo { var status = ParseStatus(reader.GetString(0)); var stepIndex = reader.GetInt32(1); - var countersJson = reader.GetString(2); + var countersJson = reader.GetFieldValue(2); var completedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue(3); var counters = DeserializeCounters(countersJson); return new QuestStepState(player, quest, status, stepIndex, counters, completedAt); diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs index d76ac3c..1de299f 100644 --- a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs @@ -23,6 +23,15 @@ public static class PrototypeE7M1QuestCatalogRules /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_QUEST_ID. public const string ChainQuestId = "prototype_quest_operator_chain"; + /// First frozen onboarding quest id (gather intro). + public const string GatherIntroQuestId = "prototype_quest_gather_intro"; + + /// First objective id on gather intro step (frozen catalog). + public const string GatherIntroFirstObjectiveId = "gather_intro_obj_scrap"; + + /// First objective id on operator chain step 0 (frozen catalog). + public const string ChainFirstObjectiveId = "chain_obj_gather"; + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID. public const string ChainTerminalItemId = "contract_handoff_token"; diff --git a/server/README.md b/server/README.md index 222d8bb..a4e4b07 100644 --- a/server/README.md +++ b/server/README.md @@ -170,7 +170,7 @@ Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by * Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids 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). +**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) From fabbdae4c2ba197a9e94e3bc6d9d3e8c33302a2d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 3 Jun 2026 23:25:13 -0400 Subject: [PATCH 5/5] 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. --- ...uestProgressPersistenceIntegrationTests.cs | 28 +++++++++++ .../Quests/PostgresPlayerQuestStateStore.cs | 49 +++++++++++-------- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs index d405783..8f4d352 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs @@ -16,6 +16,34 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg 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(); + 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() + .TryGetProgress(PlayerId, questId, out var snapshot)); + Assert.Equal(QuestProgressStatus.Active, snapshot.Status); + Assert.Equal(0, snapshot.CurrentStepIndex); + } + [RequirePostgresFact] public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory() { diff --git a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs index 135c828..2a60b37 100644 --- a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs +++ b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs @@ -68,6 +68,29 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo 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(StringComparer.Ordinal), + null); + return true; + } + using (var sel = new Npgsql.NpgsqlCommand( """ SELECT status, current_step_index, objective_counters, completed_at @@ -82,33 +105,17 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo sel.Parameters.AddWithValue("pid", player); sel.Parameters.AddWithValue("qid", quest); using var reader = sel.ExecuteReader(); - if (reader.Read()) + if (!reader.Read()) { - snapshot = ReadSnapshot(reader, player, quest); tx.Rollback(); return false; } + + snapshot = ReadSnapshot(reader, player, quest); } - 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()); - """, - conn, - tx); - insert.Parameters.AddWithValue("pid", player); - insert.Parameters.AddWithValue("qid", quest); - insert.ExecuteNonQuery(); - tx.Commit(); - snapshot = new QuestStepState( - player, - quest, - QuestProgressStatus.Active, - 0, - new Dictionary(StringComparer.Ordinal), - null); - return true; + tx.Rollback(); + return false; } ///