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.
pull/155/head
VinPropane 2026-06-03 23:04:50 -04:00
parent 065872405c
commit 395b70c030
20 changed files with 1075 additions and 8 deletions

View File

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

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M1 | | **Module ID** | E7.M1 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Prototype | | **Stage target** | Prototype |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **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) | | **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
## Purpose ## 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/`. **HTTP read model (NEO-115):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs in `Game/Quests/` ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`.
**Player quest state store (NEO-116):** **`IPlayerQuestStateStore`** — in-memory + Postgres (`V008__player_quest_progress.sql`) per-player rows keyed by `(playerId, questId)`; implicit `not_started` when missing ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). HTTP read/accept deferred to E7M1-08/09; **`QuestStateOperations`** in E7M1-06.
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2. **Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.
## Risks and telemetry ## Risks and telemetry

File diff suppressed because one or more lines are too long

View File

@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|---|---|---|---|---|---| |---|---|---|---|---|---|
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned | | E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **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.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned | | E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |

View File

@ -190,8 +190,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
**Acceptance criteria** **Acceptance criteria**
- [ ] Store supports read/update per `(playerId, questId)`. - [x] Store supports read/update per `(playerId, questId)`.
- [ ] Completed quest cannot regress to active without explicit reset API (none in prototype). - [x] Completed quest cannot regress to active without explicit reset API (none in prototype).
**Client counterpart:** none (infrastructure-only). **Client counterpart:** none (infrastructure-only).

View File

@ -51,9 +51,17 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] Store supports read/update per `(playerId, questId)`. - [x] Store supports read/update per `(playerId, questId)`.
- [ ] Completed quest cannot regress to active without explicit reset API (none in prototype — store denies `TryActivate` / `TryAdvanceStep` / counter updates when completed). - [x] 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] 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 ## Technical approach

View File

@ -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<IPlayerQuestStateStore>();
// Act
var activated = store.TryActivate(PlayerId, GatherQuestId, out var snapshot);
var found = store.TryGetProgress(PlayerId, GatherQuestId, out var readBack);
// Assert
Assert.True(activated);
Assert.True(found);
Assert.Equal(snapshot.QuestId, readBack.QuestId);
Assert.Equal(QuestProgressStatus.Active, readBack.Status);
}
private static InMemoryPlayerQuestStateStore CreateStore()
{
var options = Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryPlayerQuestStateStore(options);
}
}

View File

@ -0,0 +1,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<IPlayerQuestStateStore>();
// 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<IPlayerQuestStateStore>();
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<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var questDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V008__player_quest_progress.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(questDdlPath))
{
throw new FileNotFoundException("Test DDL for quest progress persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var questDdl = await File.ReadAllTextAsync(questDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyQuest = new NpgsqlCommand(questDdl, conn))
{
await applyQuest.ExecuteNonQueryAsync();
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,359 @@
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 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<string, int>(StringComparer.Ordinal),
null);
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);
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.GetString(2);
var completedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3);
var counters = DeserializeCounters(countersJson);
return new QuestStepState(player, quest, status, stepIndex, counters, completedAt);
}
private static Dictionary<string, int> DeserializeCounters(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == "{}")
{
return new Dictionary<string, int>(StringComparer.Ordinal);
}
var parsed = JsonSerializer.Deserialize<Dictionary<string, int>>(json, JsonOptions);
return parsed is null
? new Dictionary<string, int>(StringComparer.Ordinal)
: new Dictionary<string, int>(parsed, StringComparer.Ordinal);
}
private static QuestProgressStatus ParseStatus(string raw) =>
raw switch
{
"active" => QuestProgressStatus.Active,
"completed" => QuestProgressStatus.Completed,
_ => throw new InvalidOperationException($"Unknown quest progress status '{raw}'."),
};
private static string ToPersistenceStatus(QuestProgressStatus status) =>
status switch
{
QuestProgressStatus.Active => "active",
QuestProgressStatus.Completed => "completed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized) =>
PlayerExists(conn, playerIdNormalized, null);
private static bool PlayerExists(
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
Npgsql.NpgsqlTransaction? tx)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private delegate bool TryMutateRowDelegate(QuestStepState current, out QuestStepState updated);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
builder.Services.AddThreatStateStore(); builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddPlayerCombatHealthStore(); builder.Services.AddPlayerCombatHealthStore();

View File

@ -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" 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) ## Encounter definitions (NEO-103)
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`. **`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.

View File

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