14 KiB
14 KiB
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 · Epic 7 Slice 1 · backlog E7M1-05 |
| Branch | NEO-116-player-quest-state-store |
| Precursor | NEO-114 — quest definition registry (Done); NEO-115 — GET /game/world/quest-definitions (landed on main) |
| Pattern | NEO-104 — keyed in-memory store + atomic mutations; NEO-44 — Postgres when configured + persistence integration test |
| Blocks | NEO-117 — QuestStateOperations (accept, step advance, complete); E7M1-07+ objective wiring |
| Client counterpart | None — server-only store; player-visible progress starts at NEO-122 / 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):
QuestStepStatesnapshot type andPlayerQuestProgressRow(or equivalent immutable read model).QuestProgressStatusenum:Active,Completed(missing row ⇒not_startedat call sites).IPlayerQuestStateStore+InMemoryPlayerQuestStateStore(dev player bucket seeded; thread-safe).PostgresPlayerQuestStateStore+PostgresPlayerQuestProgressBootstrap+V008__player_quest_progress.sqlwhenConnectionStrings:NeonSprawlis set.QuestProgressIds— player/quest id normalization + composite key helper (mirrorEncounterProgressIds).QuestStateServiceCollectionExtensions.AddPlayerQuestStateStorewired fromProgram.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.mdquest progress store section (brief).- Update alignment register E7.M1 row when complete.
Out of scope (from Linear + backlog):
QuestStateOperationsprerequisite enforcement and reason codes (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
TryMarkCompletereturnsfalsewithout changingCompletedAt(Linear AC).
Implementation reconciliation (shipped)
- Types:
QuestProgressStatus,QuestStepState,QuestProgressIds. - Store:
IPlayerQuestStateStore;InMemoryPlayerQuestStateStore(dev player seeded);PostgresPlayerQuestStateStore+V008__player_quest_progress.sqlwhen Postgres configured. - DI:
AddPlayerQuestStateStoreinProgram.cs; in-memory override inInMemoryWebApplicationFactory. - Tests:
InMemoryPlayerQuestStateStoreTests(10 AAA cases + host DI);PlayerQuestProgressPersistenceIntegrationTests(RequirePostgresFact). - Docs:
server/README.mdquest progress store section; alignment register E7.M1 row updated.
Technical approach
-
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), optionalCompletedAt.QuestProgressIds:NormalizePlayerId,NormalizeQuestId,MakeProgressKey(trim + lowercase; empty guard).
-
IPlayerQuestStateStorebool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)— missing row ⇒false(callers treat asnot_started).bool TryActivate(string playerId, string questId, out QuestStepState snapshot)— creates row at step 0, empty counters;trueon first activation;falseif 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 ornewStepIndex≤ 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 marktrue; replayfalse; preserves firstCompletedAt.- XML remarks: game code should inject this interface;
QuestStateOperations(NEO-117) and HTTP (E7M1-08) consume it — not the catalog.
-
InMemoryPlayerQuestStateStoreConcurrentDictionary<string, ProgressRow>keyed byMakeProgressKey; per-key locks.- Seed configured dev player bucket on construction (
GamePositionOptions.DevPlayerId) — empty inner map (all quests implicitnot_started). CanWritePlayersemantics: returns false when player has no bucket (mirror gig/inventory stores).
-
Postgres (
V008__player_quest_progress.sql)- Table
player_quest_progress:player_idFK →player_position,quest_id,status(active/completed),current_step_index,objective_countersJSONB (objective id → int map),completed_atTIMESTAMPTZ 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 fromplayer_position; use transactions +FOR UPDATEon mutations.
- Table
-
DI
QuestStateServiceCollectionExtensions.AddPlayerQuestStateStore(configuration):- When connection string set →
PostgresPlayerQuestStateStore. - Else →
InMemoryPlayerQuestStateStore.
- When connection string set →
- Call from
Program.csafter quest catalog registration. - Extend
InMemoryWebApplicationFactory— strip Postgres quest store + register in-memory implementation (same list as gig progression).
-
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 onPostgresWebApplicationFactory, second factory reads same row (no HTTP — store-only story).
-
Docs
server/README.md— quest progress store section (interface methods, implicitnot_started, Postgres table, note HTTP deferred to E7M1-08).documentation_and_implementation_alignment.mdE7.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 |