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
parent
065872405c
commit
395b70c030
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -51,9 +51,17 @@
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Store supports read/update per `(playerId, questId)`.
|
||||
- [ ] Completed quest cannot regress to active without explicit reset API (none in prototype — store denies `TryActivate` / `TryAdvanceStep` / counter updates when completed).
|
||||
- [ ] Idempotent complete: second `TryMarkComplete` returns `false` without changing `CompletedAt` (Linear AC).
|
||||
- [x] Store supports read/update per `(playerId, questId)`.
|
||||
- [x] Completed quest cannot regress to active without explicit reset API (none in prototype — store denies `TryActivate` / `TryAdvanceStep` / counter updates when completed).
|
||||
- [x] Idempotent complete: second `TryMarkComplete` returns `false` without changing `CompletedAt` (Linear AC).
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Types:** `QuestProgressStatus`, `QuestStepState`, `QuestProgressIds`.
|
||||
- **Store:** `IPlayerQuestStateStore`; `InMemoryPlayerQuestStateStore` (dev player seeded); `PostgresPlayerQuestStateStore` + `V008__player_quest_progress.sql` when Postgres configured.
|
||||
- **DI:** `AddPlayerQuestStateStore` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`.
|
||||
- **Tests:** `InMemoryPlayerQuestStateStoreTests` (10 AAA cases + host DI); `PlayerQuestProgressPersistenceIntegrationTests` (`RequirePostgresFact`).
|
||||
- **Docs:** `server/README.md` quest progress store section; alignment register E7.M1 row updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>. <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);
|
||||
}
|
||||
|
|
@ -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,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);
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
||||
## 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