Merge pull request #155 from ViPro-Technologies/NEO-116-player-quest-state-store
NEO-116: Player quest state store + QuestStepState persistencepull/157/head
commit
58b898a733
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
|---|---|---|---|---|---|
|
||||
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
|
||||
|
||||
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **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 |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
# NEO-116 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-116 |
|
||||
| **Title** | E7M1-05: Player quest state store + QuestStepState persistence |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-116/e7m1-05-player-quest-state-store-queststepstate-persistence |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-05** |
|
||||
| **Branch** | `NEO-116-player-quest-state-store` |
|
||||
| **Precursor** | [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) — quest definition registry (**Done**); [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — GET `/game/world/quest-definitions` (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-104](NEO-104-implementation-plan.md) — keyed in-memory store + atomic mutations; [NEO-44](NEO-44-implementation-plan.md) — Postgres when configured + persistence integration test |
|
||||
| **Blocks** | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) — `QuestStateOperations` (accept, step advance, complete); E7M1-07+ objective wiring |
|
||||
| **Client counterpart** | None — server-only store; player-visible progress starts at [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) / [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Postgres persistence** | Include `V008` migration + `PostgresPlayerQuestStateStore` in NEO-116 vs defer? | **Include when configured** — E7M1 backlog default is in-memory **+ optional Postgres**; mirror `AddGigProgressionStore` / `PostgresPlayerGigProgressionStore`. | **Adopted** — user chose in-memory + Postgres when configured. |
|
||||
| **Store API shape** | Mutation methods on store vs minimal `TryGet` + `TryReplace` only? | **Mutation methods on store** — `TryActivate`, `TryAdvanceStep`, `TryUpdateObjectiveCounter`, `TryMarkComplete`; E7M1-06 `QuestStateOperations` adds prerequisite/registry validation on top (NEO-104 encounter store precedent). | **Adopted** (agent default; no objection). |
|
||||
| **`not_started` representation** | Missing row vs explicit row with `not_started` status? | **Implicit missing row** — encounter progress pattern; E7M1-08 HTTP merges registry + store. | **Adopted** (agent default). |
|
||||
| **DI registration** | Extend `AddQuestDefinitionCatalog` vs separate extension? | **Separate `AddPlayerQuestStateStore`** — persistence separate from catalog load (NEO-113/114), like `AddGigProgressionStore`. | **Adopted** (agent default). |
|
||||
| **Objective counters scope** | Counters for current step only vs full quest history? | **Current step only** — reset counters on `TryAdvanceStep`; sufficient for E7M1-07 quantity objectives. | **Adopted** (agent default). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Durable per-player quest progress rows keyed by **`(playerId, questId)`**: status (`not_started` implicit / `active` / `completed`), current step index, per-objective counters for the active step, and completion timestamp. Provide **`IPlayerQuestStateStore`** with in-memory and Postgres implementations registered in DI.
|
||||
|
||||
**In scope (from Linear + [E7M1-05](E7M1-prototype-backlog.md#e7m1-05--player-quest-state-store--queststepstate-persistence)):**
|
||||
|
||||
- **`QuestStepState`** snapshot type and **`PlayerQuestProgressRow`** (or equivalent immutable read model).
|
||||
- **`QuestProgressStatus`** enum: `Active`, `Completed` (missing row ⇒ `not_started` at call sites).
|
||||
- **`IPlayerQuestStateStore`** + **`InMemoryPlayerQuestStateStore`** (dev player bucket seeded; thread-safe).
|
||||
- **`PostgresPlayerQuestStateStore`** + **`PostgresPlayerQuestProgressBootstrap`** + **`V008__player_quest_progress.sql`** when `ConnectionStrings:NeonSprawl` is set.
|
||||
- **`QuestProgressIds`** — player/quest id normalization + composite key helper (mirror `EncounterProgressIds`).
|
||||
- **`QuestStateServiceCollectionExtensions.AddPlayerQuestStateStore`** wired from **`Program.cs`**.
|
||||
- Unit tests (AAA): accept (`not_started` → `active`), step advance + counter updates, idempotent complete, completed row cannot regress to active.
|
||||
- Postgres persistence integration test (`RequirePostgresFact`): write via store on first factory, read on second factory.
|
||||
- `server/README.md` quest progress store section (brief).
|
||||
- Update alignment register E7.M1 row when complete.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- **`QuestStateOperations`** prerequisite enforcement and reason codes ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) / E7M1-06).
|
||||
- Gather/craft/encounter objective wiring (E7M1-07).
|
||||
- HTTP **`GET /game/players/{id}/quest-progress`** (E7M1-08) and accept POST (E7M1-09).
|
||||
- Bruno, Godot, dev fixture reset API for quest rows (may add in NEO-108-style story if needed).
|
||||
- Reward grants / E7.M2 bundles.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Store supports read/update per `(playerId, questId)`.
|
||||
- [x] Completed quest cannot regress to active without explicit reset API (none in prototype — store denies `TryActivate` / `TryAdvanceStep` / counter updates when completed).
|
||||
- [x] Idempotent complete: second `TryMarkComplete` returns `false` without changing `CompletedAt` (Linear AC).
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Types:** `QuestProgressStatus`, `QuestStepState`, `QuestProgressIds`.
|
||||
- **Store:** `IPlayerQuestStateStore`; `InMemoryPlayerQuestStateStore` (dev player seeded); `PostgresPlayerQuestStateStore` + `V008__player_quest_progress.sql` when Postgres configured.
|
||||
- **DI:** `AddPlayerQuestStateStore` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`.
|
||||
- **Tests:** `InMemoryPlayerQuestStateStoreTests` (AAA cases + host DI + empty-id denial); `PlayerQuestProgressPersistenceIntegrationTests` (`RequirePostgresFact`).
|
||||
- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru` — health smoke only (pre-commit hook when `Program.cs` changes; no quest HTTP this story).
|
||||
- **Docs:** `server/README.md` quest progress store section; alignment register E7.M1 row updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Types (`Game/Quests/`)**
|
||||
- **`QuestProgressStatus`**: `Active`, `Completed`.
|
||||
- **`QuestStepState`**: immutable snapshot for one quest row — `PlayerId`, `QuestId`, `Status`, `CurrentStepIndex` (0-based), `IReadOnlyDictionary<string, int> ObjectiveCounters` (objective id → accumulated count for **current step only**), optional `CompletedAt`.
|
||||
- **`QuestProgressIds`**: `NormalizePlayerId`, `NormalizeQuestId`, `MakeProgressKey` (trim + lowercase; empty guard).
|
||||
|
||||
2. **`IPlayerQuestStateStore`**
|
||||
- `bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)` — missing row ⇒ `false` (callers treat as `not_started`).
|
||||
- `bool TryActivate(string playerId, string questId, out QuestStepState snapshot)` — creates row at step 0, empty counters; `true` on first activation; `false` if already active or completed or unknown player (in-memory: no dev bucket).
|
||||
- `bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot)` — requires active row; sets index, **clears** objective counters; denies when completed or `newStepIndex` ≤ current.
|
||||
- `bool TryUpdateObjectiveCounter(string playerId, string questId, string objectiveId, int newCount, out QuestStepState snapshot)` — requires active row; sets counter for objective id (non-negative); denies when completed.
|
||||
- `bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot)` — first mark `true`; replay `false`; preserves first `CompletedAt`.
|
||||
- XML remarks: game code should inject this interface; **`QuestStateOperations`** (NEO-117) and HTTP (E7M1-08) consume it — not the catalog.
|
||||
|
||||
3. **`InMemoryPlayerQuestStateStore`**
|
||||
- `ConcurrentDictionary<string, ProgressRow>` keyed by `MakeProgressKey`; per-key locks.
|
||||
- Seed configured dev player bucket on construction (`GamePositionOptions.DevPlayerId`) — empty inner map (all quests implicit `not_started`).
|
||||
- `CanWritePlayer` semantics: returns false when player has no bucket (mirror gig/inventory stores).
|
||||
|
||||
4. **Postgres (`V008__player_quest_progress.sql`)**
|
||||
- Table **`player_quest_progress`**: `player_id` FK → `player_position`, `quest_id`, `status` (`active` / `completed`), `current_step_index`, `objective_counters` JSONB (objective id → int map), `completed_at` TIMESTAMPTZ NULL, `updated_at`, PK `(player_id, quest_id)`.
|
||||
- **`PostgresPlayerQuestProgressBootstrap.EnsureSchema`** — idempotent DDL from migration file (NEO-44 pattern).
|
||||
- **`PostgresPlayerQuestStateStore`** — same interface; deny writes when player missing from `player_position`; use transactions + `FOR UPDATE` on mutations.
|
||||
|
||||
5. **DI**
|
||||
- **`QuestStateServiceCollectionExtensions.AddPlayerQuestStateStore(configuration)`**:
|
||||
- When connection string set → `PostgresPlayerQuestStateStore`.
|
||||
- Else → `InMemoryPlayerQuestStateStore`.
|
||||
- Call from **`Program.cs`** after quest catalog registration.
|
||||
- Extend **`InMemoryWebApplicationFactory`** — strip Postgres quest store + register in-memory implementation (same list as gig progression).
|
||||
|
||||
6. **Tests**
|
||||
- **`InMemoryPlayerQuestStateStoreTests`**: AAA coverage for activate, advance (counter reset), counter update, idempotent complete, completed-row denial on activate/advance/counter, unknown player false, host DI resolve.
|
||||
- **`PlayerQuestProgressPersistenceIntegrationTests`**: direct store mutation on `PostgresWebApplicationFactory`, second factory reads same row (no HTTP — store-only story).
|
||||
|
||||
7. **Docs**
|
||||
- `server/README.md` — quest progress store section (interface methods, implicit `not_started`, Postgres table, note HTTP deferred to E7M1-08).
|
||||
- `documentation_and_implementation_alignment.md` E7.M1 row — note NEO-116 store landed; register stays **Planned** until E7M1-06+ runtime.
|
||||
|
||||
### Prototype store behavior (frozen for tests)
|
||||
|
||||
| Step | Expected |
|
||||
|------|----------|
|
||||
| Missing row | `TryGetProgress` false; `TryActivate` → active, step 0, empty counters |
|
||||
| Re-activate | `TryActivate` false (already active) |
|
||||
| Counter | `TryUpdateObjectiveCounter(..., "gather_scrap", 3)` → counter map updated |
|
||||
| Advance | `TryAdvanceStep(..., 1)` → step 1, counters cleared |
|
||||
| Complete | `TryMarkComplete` once → true, status completed; second → false |
|
||||
| Regression guard | After complete, `TryActivate` / `TryAdvanceStep` / `TryUpdateObjectiveCounter` all false |
|
||||
|
||||
Use frozen quest id **`prototype_quest_gather_intro`** and objective id from catalog in tests (load from `PrototypeE7M1QuestCatalogRules` / first step objective where practical).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestProgressStatus.cs` | Status enum for active/completed rows. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestStepState.cs` | Immutable progress snapshot (module contract). |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestProgressIds.cs` | Player/quest id normalization and composite key. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs` | Store contract. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs` | Thread-safe in-memory implementation. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestProgressBootstrap.cs` | Applies V008 DDL once per process. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs` | PostgreSQL-backed implementation. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestStateServiceCollectionExtensions.cs` | DI: Postgres when configured, else in-memory. |
|
||||
| `server/db/migrations/V008__player_quest_progress.sql` | Quest progress table DDL. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/InMemoryPlayerQuestStateStoreTests.cs` | AAA unit + host DI tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs` | Postgres cross-factory persistence test. |
|
||||
| `docs/plans/NEO-116-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `AddPlayerQuestStateStore` after quest catalog. |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Force in-memory quest store in unit/integration tests (strip Postgres registration). |
|
||||
| `server/README.md` | Document quest progress store, V008 table, implicit `not_started`, deferred HTTP. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — note NEO-116 store when complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `InMemoryPlayerQuestStateStoreTests.cs` | **Unit:** `TryActivate` creates active row at step 0; second activate false. **Unit:** `TryUpdateObjectiveCounter` updates map; denied when completed. **Unit:** `TryAdvanceStep` bumps index and clears counters; denied when completed or non-increasing index. **Unit:** `TryMarkComplete` idempotent (second false, `CompletedAt` unchanged). **Unit:** completed row denies activate/advance/counter. **Unit:** unknown player / empty ids false. **Host:** `InMemoryWebApplicationFactory` resolves `IPlayerQuestStateStore`; smoke activate + get on dev player + frozen quest id. |
|
||||
| `PlayerQuestProgressPersistenceIntegrationTests.cs` | **Postgres:** `TryActivate` + `TryUpdateObjectiveCounter` + `TryMarkComplete` on first factory; second `PostgresWebApplicationFactory` `TryGetProgress` returns completed row with same counters-at-complete semantics and `CompletedAt`. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **JSONB vs normalized objective counter table** | Single JSONB column on `player_quest_progress` — flexible for variable objectives per step; prototype four quests only. | **adopted** |
|
||||
| **Quest id normalization** | Lowercase + trim via `QuestProgressIds`; store keys use normalized quest id; align with `IQuestDefinitionRegistry.TryNormalizeKnown` at operation layer (NEO-117). | **adopted** |
|
||||
| **Dev fixture quest reset** | Omit this story; Bruno quest flows may need clear hook in E7M1-08/09 or follow NEO-108 encounter reset pattern. | **deferred** |
|
||||
| **Constants for tests** | Reuse `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` / gather intro id; avoid duplicating quest id strings. | **adopted** |
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# Code review — NEO-116 (E7M1-05)
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Scope:** Branch `NEO-116-player-quest-state-store` vs `origin/main` — commits `0658724` … `395b70c`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-116 adds **`IPlayerQuestStateStore`** with thread-safe **`InMemoryPlayerQuestStateStore`** (dev-player bucket) and **`PostgresPlayerQuestStateStore`** (`V008__player_quest_progress.sql`, JSONB objective counters, `FOR UPDATE` mutations). **`QuestStepState`**, **`QuestProgressStatus`**, and **`QuestProgressIds`** mirror the NEO-104 encounter store and NEO-44 Postgres patterns. DI via **`AddPlayerQuestStateStore`** is wired in **`Program.cs`**; tests force in-memory registration in **`InMemoryWebApplicationFactory`**. Eleven AAA unit/host tests cover activate, counters, step advance with counter reset, idempotent complete, completed-row denial, and unknown player; a Postgres cross-factory integration test validates persistence. Documentation (implementation plan, backlog AC, E7.M1 module page, alignment register, dependency register, `server/README.md`) is updated. Server-only infrastructure — correct scope. Risk is low; main follow-ups are test-layout consistency and minor plan/doc reconciliation for the Bruno health smoke.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-116-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-05) | **Matches** — AC checked; in/out of scope aligned except Bruno note below. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — store bullet documents interface, V008, deferred HTTP/operations. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-116 store; register stays **Planned** until E7M1-06+ runtime. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note appended for E7M1-05 / NEO-116. |
|
||||
| `server/README.md` | **Matches** — quest progress store section with methods, implicit `not_started`, V008, NEO-117 deferral. |
|
||||
| Full-stack epic decomposition | **N/A** — server-only; client counterpart explicitly deferred (NEO-122 / NEO-123). |
|
||||
|
||||
**Register / tracking:** No register **Status** promotion required — E7.M1 correctly remains **Planned** until **`QuestStateOperations`** (NEO-117) lands.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Persistence integration test AAA layout** — `PlayerQuestProgressPersistenceIntegrationTests.ActivateCounterComplete_ShouldPersistAcrossNewFactory` labels store writes as `// Act` inside the `// Arrange` block. Match **`PlayerInventoryPersistenceIntegrationTests`**: reset in Arrange; first `using` scope = Act (write); second factory scope = Act (read); Assert last.~~ **Done.** Persistence test now uses separate Arrange / Act (write) / Act (read) / Assert phases.
|
||||
|
||||
2. ~~**Empty-id denial tests** — Implementation plan test table lists “unknown player / empty ids false”; only unknown player is covered. Add a `[Fact]` (or theory) asserting `TryActivate` / `TryGetProgress` return false for empty/whitespace `playerId` or `questId`.~~ **Done.** `[Theory]` with four empty/whitespace player+quest id cases added to **`InMemoryPlayerQuestStateStoreTests`**.
|
||||
|
||||
3. ~~**Frozen quest id constants in tests** — Plan adopted reusing **`PrototypeE7M1QuestCatalogRules`**; tests use **`ChainQuestId`** but hardcode **`prototype_quest_gather_intro`** and objective ids. A **`GatherIntroQuestId`** constant (parallel to **`ChainQuestId`**) would avoid drift; optional helper for first-step objective ids if reused in NEO-117.~~ **Done.** Added **`GatherIntroQuestId`**, **`GatherIntroFirstObjectiveId`**, and **`ChainFirstObjectiveId`** to **`PrototypeE7M1QuestCatalogRules`**; tests updated.
|
||||
|
||||
4. ~~**Plan reconciliation for Bruno** — Kickoff/plan **out of scope** lists Bruno; branch adds `bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru` (health smoke only). Harmless, but add a one-line note under **Implementation reconciliation** so the plan matches shipped artifacts.~~ **Done.** Bruno health smoke noted in plan reconciliation and **`server/README.md`**.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`IPlayerQuestStateStore`** XML `<see cref="QuestStateOperations"/>` references a type not yet in the repo (NEO-117). Harmless for compile; consider `<c>QuestStateOperations</c>` until NEO-117 lands if doc warnings appear.~~ **Done.**
|
||||
|
||||
- ~~Nit: **`PostgresPlayerQuestStateStore.TryActivate`** returns false on duplicate row without explicit **`tx.Rollback()`** (relies on dispose). **`PostgresPlayerGigProgressionStore`** rolls back explicitly on failure paths — aligning would improve consistency.~~ **Done.**
|
||||
|
||||
- ~~Nit: **`ReadSnapshot`** uses **`reader.GetString(2)`** for JSONB counters; works if Npgsql maps JSONB as string (integration test validates when Postgres CI runs). If driver behavior changes, prefer **`GetFieldValue<string>`** or typed JSON read — same pattern worth watching in future JSONB stores.~~ **Done.** Uses **`GetFieldValue<string>(2)`**.
|
||||
|
||||
- ~~Nit: Bruno health folder is useful startup smoke but not linked from `server/README.md` (unlike NEO-115 quest-definitions Bruno link). Optional README mention when HTTP lands in E7M1-08.~~ **Done.** README links **`bruno/neon-sprawl-server/quest-progress/`** (health smoke until E7M1-08).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Quest progress store (primary signal for this story)
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~InMemoryPlayerQuestStateStoreTests|FullyQualifiedName~PlayerQuestProgressPersistence"
|
||||
|
||||
# Quest registry / world API regression (precursors)
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~QuestDefinitionRegistryTests|FullyQualifiedName~QuestDefinitionsWorldApiTests"
|
||||
|
||||
# Full server suite (CI)
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
**Reviewer note:** `InMemoryPlayerQuestStateStoreTests` + host DI test (11 tests) passed locally during review. Postgres integration test requires **`ConnectionStrings__NeonSprawl`** (`RequirePostgresFact`).
|
||||
|
||||
**Manual:** Optional Bruno `bruno/neon-sprawl-server/quest-progress/Health after quest state store load.bru` against dev server on port 5253 to confirm DI wiring after startup.
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
public sealed class InMemoryPlayerQuestStateStoreTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string UnknownPlayerId = "unknown-player-xyz";
|
||||
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||
private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId;
|
||||
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void TryGetProgress_ShouldReturnFalse_WhenRowMissing()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var found = store.TryGetProgress(PlayerId, GatherQuestId, out _);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryActivate_ShouldCreateActiveRowAtStepZero()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var activated = store.TryActivate(PlayerId, GatherQuestId, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(activated);
|
||||
Assert.Equal(QuestProgressStatus.Active, snapshot.Status);
|
||||
Assert.Equal(0, snapshot.CurrentStepIndex);
|
||||
Assert.Empty(snapshot.ObjectiveCounters);
|
||||
Assert.Null(snapshot.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryActivate_ShouldReturnFalse_WhenAlreadyActive()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
|
||||
// Act
|
||||
var second = store.TryActivate(PlayerId, GatherQuestId, out var snapshot);
|
||||
// Assert
|
||||
Assert.False(second);
|
||||
Assert.Equal(QuestProgressStatus.Active, snapshot.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryUpdateObjectiveCounter_ShouldUpdateMap_WhenActive()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
|
||||
// Act
|
||||
var updated = store.TryUpdateObjectiveCounter(PlayerId, GatherQuestId, GatherObjectiveId, 3, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(updated);
|
||||
Assert.Equal(3, snapshot.ObjectiveCounters[GatherObjectiveId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdvanceStep_ShouldClearCountersAndBumpIndex()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryActivate(PlayerId, ChainQuestId, out _));
|
||||
Assert.True(store.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId, 5, out _));
|
||||
// Act
|
||||
var advanced = store.TryAdvanceStep(PlayerId, ChainQuestId, 1, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(advanced);
|
||||
Assert.Equal(1, snapshot.CurrentStepIndex);
|
||||
Assert.Empty(snapshot.ObjectiveCounters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdvanceStep_ShouldReturnFalse_WhenIndexNotIncreasing()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
|
||||
// Act
|
||||
var sameIndex = store.TryAdvanceStep(PlayerId, GatherQuestId, 0, out _);
|
||||
// Assert
|
||||
Assert.False(sameIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMarkComplete_ShouldBeIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
|
||||
// Act
|
||||
var first = store.TryMarkComplete(PlayerId, GatherQuestId, CompletedAt, out var firstSnapshot);
|
||||
var second = store.TryMarkComplete(PlayerId, GatherQuestId, CompletedAt.AddHours(1), out var secondSnapshot);
|
||||
// Assert
|
||||
Assert.True(first);
|
||||
Assert.False(second);
|
||||
Assert.Equal(QuestProgressStatus.Completed, secondSnapshot.Status);
|
||||
Assert.Equal(CompletedAt, secondSnapshot.CompletedAt);
|
||||
Assert.Equal(firstSnapshot.CurrentStepIndex, secondSnapshot.CurrentStepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryMarkComplete_ShouldDenyRegression_OnActivateAdvanceAndCounter()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
Assert.True(store.TryActivate(PlayerId, GatherQuestId, out _));
|
||||
Assert.True(store.TryMarkComplete(PlayerId, GatherQuestId, CompletedAt, out _));
|
||||
// Act
|
||||
var reactivate = store.TryActivate(PlayerId, GatherQuestId, out _);
|
||||
var advance = store.TryAdvanceStep(PlayerId, GatherQuestId, 1, out _);
|
||||
var counter = store.TryUpdateObjectiveCounter(PlayerId, GatherQuestId, GatherObjectiveId, 1, out _);
|
||||
// Assert
|
||||
Assert.False(reactivate);
|
||||
Assert.False(advance);
|
||||
Assert.False(counter);
|
||||
Assert.True(store.TryGetProgress(PlayerId, GatherQuestId, out var snapshot));
|
||||
Assert.Equal(QuestProgressStatus.Completed, snapshot.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryActivate_ShouldReturnFalse_ForUnknownPlayer()
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var activated = store.TryActivate(UnknownPlayerId, GatherQuestId, out _);
|
||||
// Assert
|
||||
Assert.False(activated);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", GatherQuestId)]
|
||||
[InlineData(" ", GatherQuestId)]
|
||||
[InlineData(PlayerId, "")]
|
||||
[InlineData(PlayerId, " ")]
|
||||
public void TryGetProgressAndActivate_ShouldReturnFalse_WhenPlayerOrQuestIdEmpty(
|
||||
string playerId,
|
||||
string questId)
|
||||
{
|
||||
// Arrange
|
||||
var store = CreateStore();
|
||||
// Act
|
||||
var getProgress = store.TryGetProgress(playerId, questId, out _);
|
||||
var activate = store.TryActivate(playerId, questId, out _);
|
||||
// Assert
|
||||
Assert.False(getProgress);
|
||||
Assert.False(activate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveStore_AndActivateGatherIntro()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||
// Act
|
||||
var activated = store.TryActivate(PlayerId, GatherQuestId, out var snapshot);
|
||||
var found = store.TryGetProgress(PlayerId, GatherQuestId, out var readBack);
|
||||
// Assert
|
||||
Assert.True(activated);
|
||||
Assert.True(found);
|
||||
Assert.Equal(snapshot.QuestId, readBack.QuestId);
|
||||
Assert.Equal(QuestProgressStatus.Active, readBack.Status);
|
||||
}
|
||||
|
||||
private static InMemoryPlayerQuestStateStore CreateStore()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||
return new InMemoryPlayerQuestStateStore(options);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
[Collection("Postgres integration")]
|
||||
public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||
{
|
||||
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 14, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task TryActivate_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently()
|
||||
{
|
||||
// Arrange
|
||||
await ResetQuestProgressTableAsync();
|
||||
const int concurrentCalls = 8;
|
||||
var questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
|
||||
// Act
|
||||
var results = await Task.WhenAll(
|
||||
Enumerable.Range(0, concurrentCalls).Select(_ => Task.Run(() =>
|
||||
{
|
||||
using var scope = Factory.Services.CreateScope();
|
||||
var store = scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||
return store.TryActivate(PlayerId, questId, out QuestStepState _);
|
||||
})));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, results.Count(static r => r));
|
||||
Assert.Equal(concurrentCalls - 1, results.Count(static r => !r));
|
||||
Assert.True(
|
||||
Factory.Services.CreateScope().ServiceProvider
|
||||
.GetRequiredService<IPlayerQuestStateStore>()
|
||||
.TryGetProgress(PlayerId, questId, out var snapshot));
|
||||
Assert.Equal(QuestProgressStatus.Active, snapshot.Status);
|
||||
Assert.Equal(0, snapshot.CurrentStepIndex);
|
||||
}
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory()
|
||||
{
|
||||
// Arrange
|
||||
await ResetQuestProgressTableAsync();
|
||||
|
||||
// Act — write through first host
|
||||
using (var firstScope = Factory.Services.CreateScope())
|
||||
{
|
||||
var store = firstScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||
Assert.True(store.TryActivate(PlayerId, PrototypeE7M1QuestCatalogRules.GatherIntroQuestId, out _));
|
||||
Assert.True(store.TryUpdateObjectiveCounter(
|
||||
PlayerId,
|
||||
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
|
||||
PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId,
|
||||
3,
|
||||
out _));
|
||||
Assert.True(store.TryMarkComplete(
|
||||
PlayerId,
|
||||
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
|
||||
CompletedAt,
|
||||
out _));
|
||||
}
|
||||
|
||||
// Act — read back through a fresh host
|
||||
QuestStepState readBack;
|
||||
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||
{
|
||||
using var secondScope = secondFactory.Services.CreateScope();
|
||||
var store = secondScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||
readBack = store.TryGetProgress(
|
||||
PlayerId,
|
||||
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
|
||||
out var snapshot)
|
||||
? snapshot
|
||||
: null!;
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(readBack);
|
||||
Assert.Equal(QuestProgressStatus.Completed, readBack.Status);
|
||||
Assert.Equal(0, readBack.CurrentStepIndex);
|
||||
Assert.Equal(3, readBack.ObjectiveCounters[PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId]);
|
||||
Assert.Equal(CompletedAt, readBack.CompletedAt);
|
||||
}
|
||||
|
||||
private async Task ResetQuestProgressTableAsync()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||
}
|
||||
|
||||
_ = Factory.Services;
|
||||
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||
|
||||
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||
var questDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V008__player_quest_progress.sql");
|
||||
if (!File.Exists(positionDdlPath) || !File.Exists(questDdlPath))
|
||||
{
|
||||
throw new FileNotFoundException("Test DDL for quest progress persistence not found.");
|
||||
}
|
||||
|
||||
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
||||
var questDdl = await File.ReadAllTextAsync(questDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyQuest = new NpgsqlCommand(questDdl, conn))
|
||||
{
|
||||
await applyQuest.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
|
||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||
|
|
@ -104,6 +105,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
|
||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted per-player quest progress keyed by <c>(playerId, questId)</c> (NEO-116).
|
||||
/// Missing row ⇒ <c>not_started</c>. <c>QuestStateOperations</c> (NEO-117) and HTTP (E7M1-08) consume this interface.
|
||||
/// </summary>
|
||||
public interface IPlayerQuestStateStore
|
||||
{
|
||||
/// <summary>Missing row ⇒ <c>false</c> (callers treat as <c>not_started</c>).</summary>
|
||||
bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot);
|
||||
|
||||
/// <summary>Creates an active row at step 0 with empty counters. Returns <c>false</c> when already active/completed or player cannot be written.</summary>
|
||||
bool TryActivate(string playerId, string questId, out QuestStepState snapshot);
|
||||
|
||||
/// <summary>Requires an active row; sets step index and clears objective counters. Denies when completed or <paramref name="newStepIndex"/> is not greater than current.</summary>
|
||||
bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot);
|
||||
|
||||
/// <summary>Requires an active row; sets one objective counter (non-negative). Denies when completed.</summary>
|
||||
bool TryUpdateObjectiveCounter(
|
||||
string playerId,
|
||||
string questId,
|
||||
string objectiveId,
|
||||
int newCount,
|
||||
out QuestStepState snapshot);
|
||||
|
||||
/// <summary>First completion returns <c>true</c>; replays return <c>false</c> without changing <see cref="QuestStepState.CompletedAt"/>.</summary>
|
||||
bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot);
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Thread-safe in-memory quest progress; seeds the configured dev player (NEO-116).</summary>
|
||||
public sealed class InMemoryPlayerQuestStateStore(IOptions<GamePositionOptions> options) : IPlayerQuestStateStore
|
||||
{
|
||||
private sealed class ProgressRow(
|
||||
QuestProgressStatus status,
|
||||
int currentStepIndex,
|
||||
Dictionary<string, int> objectiveCounters,
|
||||
DateTimeOffset? completedAt)
|
||||
{
|
||||
public QuestProgressStatus Status { get; private set; } = status;
|
||||
|
||||
public int CurrentStepIndex { get; private set; } = currentStepIndex;
|
||||
|
||||
public Dictionary<string, int> ObjectiveCounters { get; } = objectiveCounters;
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; private set; } = completedAt;
|
||||
|
||||
public QuestStepState ToSnapshot(string playerId, string questId) =>
|
||||
new(
|
||||
playerId,
|
||||
questId,
|
||||
Status,
|
||||
CurrentStepIndex,
|
||||
new Dictionary<string, int>(ObjectiveCounters, StringComparer.Ordinal),
|
||||
CompletedAt);
|
||||
|
||||
public void SetStepIndex(int newStepIndex)
|
||||
{
|
||||
CurrentStepIndex = newStepIndex;
|
||||
ObjectiveCounters.Clear();
|
||||
}
|
||||
|
||||
public void SetObjectiveCounter(string objectiveId, int newCount) =>
|
||||
ObjectiveCounters[objectiveId] = newCount;
|
||||
|
||||
public void MarkCompleted(DateTimeOffset completedAt)
|
||||
{
|
||||
Status = QuestProgressStatus.Completed;
|
||||
CompletedAt = completedAt;
|
||||
}
|
||||
|
||||
public static ProgressRow CreateActive() =>
|
||||
new(QuestProgressStatus.Active, 0, new Dictionary<string, int>(StringComparer.Ordinal), null);
|
||||
}
|
||||
|
||||
private readonly HashSet<string> knownPlayers = CreateKnownPlayers(options.Value);
|
||||
|
||||
private readonly ConcurrentDictionary<string, ProgressRow> byKey = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
|
||||
|
||||
private static HashSet<string> CreateKnownPlayers(GamePositionOptions o)
|
||||
{
|
||||
var id = QuestProgressIds.NormalizePlayerId(o.DevPlayerId);
|
||||
if (id.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||
}
|
||||
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
var key = QuestProgressIds.MakeProgressKey(player, quest);
|
||||
if (key.Length == 0 || !knownPlayers.Contains(player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byKey.TryGetValue(key, out var row))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = row.ToSnapshot(player, quest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryActivate(string playerId, string questId, out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
var key = QuestProgressIds.MakeProgressKey(player, quest);
|
||||
if (key.Length == 0 || !knownPlayers.Contains(player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (byKey.TryGetValue(key, out var existing))
|
||||
{
|
||||
snapshot = existing.ToSnapshot(player, quest);
|
||||
return false;
|
||||
}
|
||||
|
||||
var row = ProgressRow.CreateActive();
|
||||
byKey[key] = row;
|
||||
snapshot = row.ToSnapshot(player, quest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
if (newStepIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
var key = QuestProgressIds.MakeProgressKey(player, quest);
|
||||
if (key.Length == 0 || !knownPlayers.Contains(player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byKey.TryGetValue(key, out var row) ||
|
||||
row.Status != QuestProgressStatus.Active ||
|
||||
newStepIndex <= row.CurrentStepIndex)
|
||||
{
|
||||
if (byKey.TryGetValue(key, out var existing))
|
||||
{
|
||||
snapshot = existing.ToSnapshot(player, quest);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
row.SetStepIndex(newStepIndex);
|
||||
snapshot = row.ToSnapshot(player, quest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryUpdateObjectiveCounter(
|
||||
string playerId,
|
||||
string questId,
|
||||
string objectiveId,
|
||||
int newCount,
|
||||
out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
if (newCount < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
var objective = QuestProgressIds.NormalizeObjectiveId(objectiveId);
|
||||
var key = QuestProgressIds.MakeProgressKey(player, quest);
|
||||
if (key.Length == 0 || objective.Length == 0 || !knownPlayers.Contains(player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byKey.TryGetValue(key, out var row) || row.Status != QuestProgressStatus.Active)
|
||||
{
|
||||
if (byKey.TryGetValue(key, out var existing))
|
||||
{
|
||||
snapshot = existing.ToSnapshot(player, quest);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
row.SetObjectiveCounter(objective, newCount);
|
||||
snapshot = row.ToSnapshot(player, quest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
var key = QuestProgressIds.MakeProgressKey(player, quest);
|
||||
if (key.Length == 0 || !knownPlayers.Contains(player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byKey.TryGetValue(key, out var row))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (row.Status == QuestProgressStatus.Completed)
|
||||
{
|
||||
snapshot = row.ToSnapshot(player, quest);
|
||||
return false;
|
||||
}
|
||||
|
||||
row.MarkCompleted(completedAt);
|
||||
snapshot = row.ToSnapshot(player, quest);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Applies NEO-116 quest progress table DDL once per process.</summary>
|
||||
public static class PostgresPlayerQuestProgressBootstrap
|
||||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V008__player_quest_progress.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
{
|
||||
if (Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SchemaGate)
|
||||
{
|
||||
if (Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"NEO-116 DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = File.ReadAllText(ddlPath);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||
cmd.ExecuteNonQuery();
|
||||
Volatile.Write(ref _schemaReady, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>PostgreSQL-backed quest progress keyed by normalized player id + quest id (NEO-116).</summary>
|
||||
public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerQuestStateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
if (player.Length == 0 || quest.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
if (!PlayerExists(conn, player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT status, current_step_index, objective_counters, completed_at
|
||||
FROM player_quest_progress
|
||||
WHERE player_id = @pid AND quest_id = @qid
|
||||
LIMIT 1;
|
||||
""",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", player);
|
||||
cmd.Parameters.AddWithValue("qid", quest);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = ReadSnapshot(reader, player, quest);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryActivate(string playerId, string questId, out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
if (player.Length == 0 || quest.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
if (!PlayerExists(conn, player, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
using var insert = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_quest_progress (player_id, quest_id, status, current_step_index, objective_counters, updated_at)
|
||||
VALUES (@pid, @qid, 'active', 0, '{}'::jsonb, now())
|
||||
ON CONFLICT (player_id, quest_id) DO NOTHING;
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
insert.Parameters.AddWithValue("pid", player);
|
||||
insert.Parameters.AddWithValue("qid", quest);
|
||||
if (insert.ExecuteNonQuery() > 0)
|
||||
{
|
||||
tx.Commit();
|
||||
snapshot = new QuestStepState(
|
||||
player,
|
||||
quest,
|
||||
QuestProgressStatus.Active,
|
||||
0,
|
||||
new Dictionary<string, int>(StringComparer.Ordinal),
|
||||
null);
|
||||
return true;
|
||||
}
|
||||
|
||||
using (var sel = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT status, current_step_index, objective_counters, completed_at
|
||||
FROM player_quest_progress
|
||||
WHERE player_id = @pid AND quest_id = @qid
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
""",
|
||||
conn,
|
||||
tx))
|
||||
{
|
||||
sel.Parameters.AddWithValue("pid", player);
|
||||
sel.Parameters.AddWithValue("qid", quest);
|
||||
using var reader = sel.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = ReadSnapshot(reader, player, quest);
|
||||
}
|
||||
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
if (newStepIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
if (player.Length == 0 || quest.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
|
||||
{
|
||||
if (row.Status != QuestProgressStatus.Active || newStepIndex <= row.CurrentStepIndex)
|
||||
{
|
||||
result = row;
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new QuestStepState(
|
||||
row.PlayerId,
|
||||
row.QuestId,
|
||||
row.Status,
|
||||
newStepIndex,
|
||||
new Dictionary<string, int>(StringComparer.Ordinal),
|
||||
row.CompletedAt);
|
||||
return true;
|
||||
}, out snapshot);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryUpdateObjectiveCounter(
|
||||
string playerId,
|
||||
string questId,
|
||||
string objectiveId,
|
||||
int newCount,
|
||||
out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
if (newCount < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
var objective = QuestProgressIds.NormalizeObjectiveId(objectiveId);
|
||||
if (player.Length == 0 || quest.Length == 0 || objective.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
|
||||
{
|
||||
if (row.Status != QuestProgressStatus.Active)
|
||||
{
|
||||
result = row;
|
||||
return false;
|
||||
}
|
||||
|
||||
var counters = new Dictionary<string, int>(row.ObjectiveCounters, StringComparer.Ordinal);
|
||||
counters[objective] = newCount;
|
||||
result = new QuestStepState(
|
||||
row.PlayerId,
|
||||
row.QuestId,
|
||||
row.Status,
|
||||
row.CurrentStepIndex,
|
||||
counters,
|
||||
row.CompletedAt);
|
||||
return true;
|
||||
}, out snapshot);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||
if (player.Length == 0 || quest.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
|
||||
{
|
||||
if (row.Status == QuestProgressStatus.Completed)
|
||||
{
|
||||
result = row;
|
||||
return false;
|
||||
}
|
||||
|
||||
result = new QuestStepState(
|
||||
row.PlayerId,
|
||||
row.QuestId,
|
||||
QuestProgressStatus.Completed,
|
||||
row.CurrentStepIndex,
|
||||
row.ObjectiveCounters,
|
||||
completedAt);
|
||||
return true;
|
||||
}, out snapshot);
|
||||
}
|
||||
|
||||
private bool TryMutateRow(
|
||||
string player,
|
||||
string quest,
|
||||
TryMutateRowDelegate mutator,
|
||||
out QuestStepState snapshot)
|
||||
{
|
||||
snapshot = null!;
|
||||
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
if (!PlayerExists(conn, player, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
QuestStepState current;
|
||||
using (var sel = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT status, current_step_index, objective_counters, completed_at
|
||||
FROM player_quest_progress
|
||||
WHERE player_id = @pid AND quest_id = @qid
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
""",
|
||||
conn,
|
||||
tx))
|
||||
{
|
||||
sel.Parameters.AddWithValue("pid", player);
|
||||
sel.Parameters.AddWithValue("qid", quest);
|
||||
using var reader = sel.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
current = ReadSnapshot(reader, player, quest);
|
||||
}
|
||||
|
||||
if (!mutator(current, out var updated))
|
||||
{
|
||||
snapshot = current;
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteRow(conn, player, quest, updated, tx);
|
||||
tx.Commit();
|
||||
snapshot = updated;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WriteRow(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
string player,
|
||||
string quest,
|
||||
QuestStepState row,
|
||||
Npgsql.NpgsqlTransaction tx)
|
||||
{
|
||||
var countersJson = JsonSerializer.Serialize(row.ObjectiveCounters, JsonOptions);
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
UPDATE player_quest_progress
|
||||
SET status = @status,
|
||||
current_step_index = @step,
|
||||
objective_counters = @counters::jsonb,
|
||||
completed_at = @completed_at,
|
||||
updated_at = now()
|
||||
WHERE player_id = @pid AND quest_id = @qid;
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
cmd.Parameters.AddWithValue("pid", player);
|
||||
cmd.Parameters.AddWithValue("qid", quest);
|
||||
cmd.Parameters.AddWithValue("status", ToPersistenceStatus(row.Status));
|
||||
cmd.Parameters.AddWithValue("step", row.CurrentStepIndex);
|
||||
cmd.Parameters.AddWithValue("counters", countersJson);
|
||||
cmd.Parameters.AddWithValue("completed_at", (object?)row.CompletedAt ?? DBNull.Value);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static QuestStepState ReadSnapshot(Npgsql.NpgsqlDataReader reader, string player, string quest)
|
||||
{
|
||||
var status = ParseStatus(reader.GetString(0));
|
||||
var stepIndex = reader.GetInt32(1);
|
||||
var countersJson = reader.GetFieldValue<string>(2);
|
||||
var completedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3);
|
||||
var counters = DeserializeCounters(countersJson);
|
||||
return new QuestStepState(player, quest, status, stepIndex, counters, completedAt);
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> DeserializeCounters(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "{}")
|
||||
{
|
||||
return new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<Dictionary<string, int>>(json, JsonOptions);
|
||||
return parsed is null
|
||||
? new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
: new Dictionary<string, int>(parsed, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static QuestProgressStatus ParseStatus(string raw) =>
|
||||
raw switch
|
||||
{
|
||||
"active" => QuestProgressStatus.Active,
|
||||
"completed" => QuestProgressStatus.Completed,
|
||||
_ => throw new InvalidOperationException($"Unknown quest progress status '{raw}'."),
|
||||
};
|
||||
|
||||
private static string ToPersistenceStatus(QuestProgressStatus status) =>
|
||||
status switch
|
||||
{
|
||||
QuestProgressStatus.Active => "active",
|
||||
QuestProgressStatus.Completed => "completed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized) =>
|
||||
PlayerExists(conn, playerIdNormalized, null);
|
||||
|
||||
private static bool PlayerExists(
|
||||
Npgsql.NpgsqlConnection conn,
|
||||
string playerIdNormalized,
|
||||
Npgsql.NpgsqlTransaction? tx)
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
|
||||
conn,
|
||||
tx);
|
||||
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private delegate bool TryMutateRowDelegate(QuestStepState current, out QuestStepState updated);
|
||||
}
|
||||
|
|
@ -23,6 +23,15 @@ public static class PrototypeE7M1QuestCatalogRules
|
|||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary>
|
||||
public const string ChainQuestId = "prototype_quest_operator_chain";
|
||||
|
||||
/// <summary>First frozen onboarding quest id (gather intro).</summary>
|
||||
public const string GatherIntroQuestId = "prototype_quest_gather_intro";
|
||||
|
||||
/// <summary>First objective id on gather intro step (frozen catalog).</summary>
|
||||
public const string GatherIntroFirstObjectiveId = "gather_intro_obj_scrap";
|
||||
|
||||
/// <summary>First objective id on operator chain step 0 (frozen catalog).</summary>
|
||||
public const string ChainFirstObjectiveId = "chain_obj_gather";
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary>
|
||||
public const string ChainTerminalItemId = "contract_handoff_token";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Id normalization for player quest progress stores (NEO-116).</summary>
|
||||
public static class QuestProgressIds
|
||||
{
|
||||
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
|
||||
public static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var trimmed = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
|
||||
public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId);
|
||||
|
||||
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
|
||||
public static string NormalizeObjectiveId(string? objectiveId) => NormalizePlayerId(objectiveId);
|
||||
|
||||
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="questId"/>.</summary>
|
||||
public static string MakeProgressKey(string? playerId, string? questId)
|
||||
{
|
||||
var p = NormalizePlayerId(playerId);
|
||||
var q = NormalizeQuestId(questId);
|
||||
if (p.Length == 0 || q.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{p}\0{q}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Persisted quest row status (NEO-116). Missing store row implies <c>not_started</c> at call sites.</summary>
|
||||
public enum QuestProgressStatus
|
||||
{
|
||||
Active,
|
||||
Completed,
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Registers quest progress persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-116).</summary>
|
||||
public static class QuestStateServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddPlayerQuestStateStore(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
services.AddSingleton<IPlayerQuestStateStore, PostgresPlayerQuestStateStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Immutable per-player quest progress snapshot (NEO-116; module contract <c>QuestStepState</c>).</summary>
|
||||
public sealed class QuestStepState(
|
||||
string playerId,
|
||||
string questId,
|
||||
QuestProgressStatus status,
|
||||
int currentStepIndex,
|
||||
IReadOnlyDictionary<string, int> objectiveCounters,
|
||||
DateTimeOffset? completedAt)
|
||||
{
|
||||
public string PlayerId { get; } = playerId;
|
||||
|
||||
public string QuestId { get; } = questId;
|
||||
|
||||
public QuestProgressStatus Status { get; } = status;
|
||||
|
||||
public int CurrentStepIndex { get; } = currentStepIndex;
|
||||
|
||||
/// <summary>Objective id → accumulated count for the <see cref="CurrentStepIndex"/> step only.</summary>
|
||||
public IReadOnlyDictionary<string, int> ObjectiveCounters { get; } = objectiveCounters;
|
||||
|
||||
public DateTimeOffset? CompletedAt { get; } = completedAt;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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). Bruno startup smoke: `bruno/neon-sprawl-server/quest-progress/` (health only until E7M1-08 HTTP).
|
||||
|
||||
## 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/`.
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
Loading…
Reference in New Issue