Merge pull request #165 from ViPro-Technologies/NEO-126-e7m2-reward-delivery-store

NEO-126: E7M2-03 reward delivery store (IRewardDeliveryStore)
pull/167/head
VinPropane 2026-06-07 17:36:59 -04:00 committed by GitHub
commit ce142b9271
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 541 additions and 3 deletions

View File

@ -0,0 +1,25 @@
meta {
name: GET health (reward delivery store NEO-126)
type: http
seq: 2
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-126 registers IRewardDeliveryStore (in-memory prototype). No quest reward delivery 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

@ -67,3 +67,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**NEO-43:** When applying scripted **skill XP** from a mission/quest reward bundle, call **`MissionRewardSkillXpGrant.GrantFromMissionReward`** (`server/NeonSprawl.Server/Game/Skills/`) — fixed **`sourceKind: mission_reward`**, **`skillId`** and **`amount`** from reward data — [NEO-43 implementation plan](../../plans/NEO-43-implementation-plan.md).
**NEO-107 (E5.M3 producer hook):** On encounter completion grant commit, **`EncounterCompletionOperations.TryCompleteAndGrant`** records **`EncounterCompleteEvent`** in **`IEncounterCompleteEventStore`** (idempotency key **`{playerId}:{encounterId}`**). Future E7.M2 **`QuestRewardBundle`** router consumes this record for **`reward_delivery`** quest credit beyond prototype **`contract_handoff_token`** item loot — comment-only hook stub; no runtime router yet. See [NEO-107 implementation plan](../../plans/NEO-107-implementation-plan.md); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107).
**NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Router apply and quest-state wiring land in NEO-127/NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126).

File diff suppressed because one or more lines are too long

View File

@ -146,8 +146,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria**
- [ ] `TryRecord` idempotent on same key.
- [ ] Store readable summary for HTTP projection.
- [x] `TryRecord` idempotent on same key.
- [x] Store readable summary for HTTP projection.
**Client counterpart:** none (infrastructure-only).

View File

@ -0,0 +1,168 @@
# NEO-126 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-126 |
| **Title** | E7M2-03: Reward delivery store (`IRewardDeliveryStore`) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-126/e7m2-03-reward-delivery-store-irewarddeliverystore |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-03** |
| **Branch** | `NEO-126-e7m2-reward-delivery-store` |
| **Precursor** | [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) **`Done`** — bundle schemas + catalog rows · [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) **`Done`** — server catalog load + E7M2 gates |
| **Pattern** | [NEO-107](NEO-107-implementation-plan.md) — `IEncounterCompleteEventStore` / `InMemoryEncounterCompleteEventStore` idempotent `TryRecord` + `TryGet` |
| **Blocks** | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) (`RewardRouterOperations` apply path) |
| **Client counterpart** | none — infrastructure-only ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-03--reward-delivery-store-irewarddeliverystore)) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Postgres persistence** | Implement NEO-29-style Postgres split now or in-memory only? | **In-memory only** — mirror `IEncounterCompleteEventStore`; NEO-126 AC is store idempotency + readable summary, not cross-restart durability; Postgres deferred until a story needs persisted delivery rows. | **Adopted** — in-memory only |
**Additional defaults (no kickoff question — settled by backlog / precedent):**
- **Idempotency key:** `{normalizedPlayerId}:quest_complete:{normalizedQuestId}` per [E7M2 kickoff decisions](E7M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults) and [E7.M2 module](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01).
- **Namespace:** `Game/Rewards/` — delivery is cross-cutting (quest completion v1; encounter consumer later); keeps `Game/Quests/` for state machine only.
- **Grant summary rows:** dedicated `RewardItemGrantApplied` + `RewardSkillXpGrantApplied` on the event (do not reuse `EncounterGrantApplied` — quest bundles include skill XP lines).
- **Store key:** composite `playerId\0questId` via `RewardDeliveryIds.MakeStoreKey` for `TryGet(playerId, questId)`; event carries explicit **`IdempotencyKey`** string for router audit.
- **Dev `TryClear`:** optional on `InMemoryRewardDeliveryStore` only (Bruno / test reset parity with `InMemoryEncounterCompleteEventStore`).
## Goal, scope, and out-of-scope
**Goal:** Durable idempotency record for **`RewardDeliveryEvent`** per player + quest completion.
**In scope (from Linear + [E7M2-03](E7M2-prototype-backlog.md#e7m2-03--reward-delivery-store-irewarddeliverystore)):**
- `IRewardDeliveryStore`, in-memory implementation.
- Idempotency key **`{playerId}:quest_complete:{questId}`**; store granted item + skill XP summary for audit/read projection.
- Unit tests (AAA): first delivery records; replay returns existing event without double-apply (`TryRecord` → `false`).
- `server/README.md` store section.
- DI registration in `Program.cs`.
**Out of scope (from Linear):**
- Router apply logic ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) E7M2-04).
- Quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) E7M2-05).
- HTTP `completionRewardSummary` projection ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06).
- Postgres migration / `PostgresRewardDeliveryStore` (deferred per kickoff answer).
- Godot client.
## Acceptance criteria checklist
- [x] `TryRecord` idempotent on same key.
- [x] Store readable summary for HTTP projection.
## Implementation reconciliation (shipped)
- **Types:** `RewardDeliveryEvent`, `RewardItemGrantApplied`, `RewardSkillXpGrantApplied`, `RewardDeliveryIds` in `Game/Rewards/`.
- **Store:** `IRewardDeliveryStore` + `InMemoryRewardDeliveryStore` (`TryRecord`, `TryGet`, dev `TryClear`).
- **DI:** `AddRewardDeliveryStore()` in `Program.cs` — in-memory singleton only (Postgres deferred per kickoff).
- **Tests:** 5 AAA tests in `InMemoryRewardDeliveryStoreTests` (idempotency incl. mixed-case replay, fail-closed, grant summary, host DI resolve).
- **Docs:** `server/README.md` reward delivery store section; E7M2-03 backlog AC checked; alignment register updated.
- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru` (host boot smoke; pre-commit gate for `Program.cs`).
## Technical approach
### 1. Event + grant summary types (`Game/Rewards/`)
**`RewardDeliveryEvent`** (readonly record struct):
| Field | Role |
|-------|------|
| **`PlayerId`** | Normalized player id. |
| **`QuestId`** | Normalized quest id. |
| **`DeliveredAt`** | UTC timestamp at record commit (from router/`TimeProvider` in NEO-127). |
| **`GrantedItems`** | Snapshot of applied item rows (`RewardItemGrantApplied`: `ItemId`, `Quantity`). |
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`RewardSkillXpGrantApplied`: `SkillId`, `Amount`). |
| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`**. |
Defensive copy on `TryRecord` (`.ToArray()` on lists) — same as `InMemoryEncounterCompleteEventStore`.
### 2. `RewardDeliveryIds`
Static helper (mirror `QuestProgressIds` / `EncounterProgressIds`):
- `NormalizePlayerId` / `NormalizeQuestId` — trim + lowercase; empty on whitespace.
- `MakeIdempotencyKey(playerId, questId)``{p}:quest_complete:{q}` or empty when invalid.
- `MakeStoreKey(playerId, questId)``{p}\0{q}` for dictionary lookup.
### 3. `IRewardDeliveryStore`
```csharp
bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
```
First `TryRecord` returns `true`; replays for the same player+quest return `false`. Invalid ids fail closed (`false`).
### 4. `InMemoryRewardDeliveryStore`
- `ConcurrentDictionary<string, RewardDeliveryEvent>` + per-key locks — same threading model as `InMemoryEncounterCompleteEventStore`.
- `TryClear(playerId, questId)` on concrete type only (dev fixture).
### 5. DI
**`RewardDeliveryServiceCollectionExtensions.AddRewardDeliveryStore`:**
- `services.AddSingleton<IRewardDeliveryStore, InMemoryRewardDeliveryStore>();`
- Call from **`Program.cs`** after quest catalog / quest state registration.
No connection-string branch until Postgres story.
### 6. Tests (`InMemoryRewardDeliveryStoreTests`)
Mirror `InMemoryEncounterCompleteEventStoreTests`:
- **`TryRecord_ShouldBeIdempotent_AtStoreLayer`** — first `true`, second `false`; `TryGet` returns original `DeliveredAt` + grant snapshots.
- **`TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty`**
- **`TryRecord_ShouldFailClosed_WhenQuestIdIsEmpty`**
- **`TryGet_ShouldExposeReadableGrantSummary`** — item + skill XP rows preserved in order for future HTTP mapper (NEO-129).
Optional host smoke: assert `WebApplicationFactory` resolves `IRewardDeliveryStore` as `InMemoryRewardDeliveryStore` (same pattern as `EncounterDefinitionRegistryTests`).
### 7. Docs
- **`server/README.md`** — new **Reward delivery store (NEO-126)** section: interface, event fields, idempotency key, in-memory prototype policy, pointer to NEO-127 router consumer.
- **`documentation_and_implementation_alignment.md`** — E7.M2 row note: delivery store landed (NEO-126).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Rewards/RewardItemGrantApplied.cs` | Item grant snapshot on a delivery event. |
| `server/NeonSprawl.Server/Game/Rewards/RewardSkillXpGrantApplied.cs` | Skill XP grant snapshot on a delivery event. |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` | Idempotent delivery payload (quest completion v1). |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs` | Id normalization, store key, idempotency key helper. |
| `server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs` | Store contract (`TryRecord`, `TryGet`). |
| `server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs` | Thread-safe in-memory implementation + dev `TryClear`. |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryServiceCollectionExtensions.cs` | DI registration. |
| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | AAA idempotency + fail-closed + summary tests. |
| `docs/plans/NEO-126-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register `AddRewardDeliveryStore()`. |
| `server/README.md` | Document `IRewardDeliveryStore`, event shape, idempotency key. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — delivery store (NEO-126). |
## Tests
| Target | Coverage |
|--------|----------|
| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | Idempotent `TryRecord`; fail-closed empty ids; `TryGet` returns item + skill XP summary suitable for HTTP projection. |
| **Optional** — extend an existing host/DI test file | `IRewardDeliveryStore` resolves as `InMemoryRewardDeliveryStore` from `WebApplicationFactory`. |
| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter InMemoryRewardDeliveryStoreTests`; boot dev server — no behavior change until NEO-127 wires router. |
## Open questions / risks
| Question or risk | Agent recommendation | Status |
|------------------|----------------------|--------|
| Postgres deferred vs backlog “optional migration” wording | Kickoff adopted in-memory only; add Postgres when restart durability is required (likely before production or a dedicated persistence story). | **adopted** |
| Encounter deliveries reuse same store | Out of scope — quest completion keys only in NEO-126; encounter path stays on `IEncounterCompleteEventStore` until a follow-on unifies consumers. | **adopted** |
| Empty grant arrays on event | Allow empty lists when bundle has only one grant kind; router (NEO-127) populates from apply outcome. | **adopted** |
| HTTP DTO mapping | NEO-129 maps `GrantedItems` / `GrantedSkillXp``completionRewardSummary`; no API changes in NEO-126. | **deferred** |
None beyond the above.

View File

@ -0,0 +1,54 @@
# Code review — NEO-126 (E7M2-03)
**Date:** 2026-06-07
**Scope:** Branch `NEO-126-e7m2-reward-delivery-store` vs `origin/main` — commits `b20d9c1``2a8fe31`
**Base:** `origin/main`
**Follow-up:** Suggestions below are **done** (strikethrough + **Done.**).
## Verdict
**Approve with nits** — store contract, threading model, and tests mirror the NEO-107 encounter-event precedent; plan and backlog AC are satisfied; risk is low (infrastructure-only, no router or HTTP surface yet).
## Summary
NEO-126 adds **`IRewardDeliveryStore`** and **`InMemoryRewardDeliveryStore`** under `Game/Rewards/` for idempotent quest-completion reward delivery records. **`RewardDeliveryEvent`** carries item and skill XP grant snapshots plus a stable **`{playerId}:quest_complete:{questId}`** idempotency key; **`RewardDeliveryIds`** handles normalization and composite store keys. DI registers an in-memory singleton via **`AddRewardDeliveryStore()`** in **`Program.cs`**. Five AAA unit tests cover idempotency, fail-closed empty ids, grant summary readability, and host DI resolution; Bruno adds a health smoke for **`Program.cs`** pre-commit. Docs (implementation plan, E7M2-03 backlog AC, alignment register, **`server/README.md`**) are updated. No player-visible behavior until NEO-127 wires the router.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-126-implementation-plan.md` | **Matches** — kickoff (in-memory only), acceptance criteria checked, reconciliation accurate. |
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-03) | **Matches** — AC checkboxes complete; infrastructure-only client counterpart noted. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-126 implementation anchor added (review follow-up). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-03 delivery store; row correctly stays **Planned** until NEO-132 capstone. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required for this slice. |
| `server/README.md` | **Matches** — reward delivery store section documents interface, event fields, idempotency key, NEO-127/NEO-129 pointers. |
| Full-stack epic decomposition | **N/A** — E7M2-03 is infrastructure-only; no client counterpart required. |
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate.
## Blocking issues
None.
## Suggestions
1. ~~**E7.M2 module anchor** — Add a short **Implementation anchor (server)** bullet for NEO-126 in `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` (parity with the NEO-125 server-load and NEO-107 encounter-store paragraphs) so decomposition readers do not rely only on the alignment register and README.~~ **Done.** NEO-126 paragraph under **Implementation anchor (server)** in `E7_M2_RewardAndUnlockRouter.md`.
2. ~~**Case-normalization idempotency test** — `MakeStoreKey` lowercases ids, but no test proves a second `TryRecord` with different casing of the same player/quest is rejected. A one-liner extension of `TryRecord_ShouldBeIdempotent_AtStoreLayer` (e.g. replay with `PlayerId = "DEV-LOCAL-1"`) would lock that behavior for NEO-127 replays.~~ **Done.** Third replay with mixed-case ids in `InMemoryRewardDeliveryStoreTests.TryRecord_ShouldBeIdempotent_AtStoreLayer`.
## Nits
- Nit: `TryRecord` does not validate that `deliveryEvent.IdempotencyKey` matches `RewardDeliveryIds.MakeIdempotencyKey(...)` — same as `InMemoryEncounterCompleteEventStore`; NEO-127 router should populate both consistently.
- Nit: `TryClear` is untested; acceptable for dev-only API (encounter store also omits a `TryClear` test).
- Nit: Stored `PlayerId` / `QuestId` on the event are not rewritten to normalized form at record time; lookup normalizes via `MakeStoreKey`. Router (NEO-127) should pass normalized ids per plan table.
## Verification
```bash
dotnet test server/NeonSprawl.Server.Tests --filter InMemoryRewardDeliveryStoreTests
```
Optional before merge:
- Boot dev server; confirm `/health` 200 after `AddRewardDeliveryStore()` wiring (Bruno: `bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru`).

View File

@ -0,0 +1,110 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Rewards;
public sealed class InMemoryRewardDeliveryStoreTests
{
private const string PlayerId = "dev-local-1";
private const string QuestId = "prototype_quest_operator_chain";
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void TryRecord_ShouldBeIdempotent_AtStoreLayer()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var deliveryEvent = CreatePrototypeEvent();
// Act
var first = store.TryRecord(deliveryEvent);
var second = store.TryRecord(deliveryEvent with { DeliveredAt = DeliveredAt.AddHours(1) });
var third = store.TryRecord(
deliveryEvent with
{
PlayerId = "DEV-LOCAL-1",
QuestId = "PROTOTYPE_QUEST_OPERATOR_CHAIN",
});
// Assert
Assert.True(first);
Assert.False(second);
Assert.False(third);
Assert.True(store.TryGet(PlayerId, QuestId, out var stored));
Assert.Equal(deliveryEvent.IdempotencyKey, stored.IdempotencyKey);
Assert.Equal(DeliveredAt, stored.DeliveredAt);
Assert.Single(stored.GrantedItems);
Assert.Single(stored.GrantedSkillXp);
}
[Fact]
public void TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var invalidEvent = CreatePrototypeEvent() with { PlayerId = " " };
// Act
var recorded = store.TryRecord(invalidEvent);
// Assert
Assert.False(recorded);
Assert.False(store.TryGet(" ", QuestId, out _));
}
[Fact]
public void TryRecord_ShouldFailClosed_WhenQuestIdIsEmpty()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var invalidEvent = CreatePrototypeEvent() with { QuestId = " " };
// Act
var recorded = store.TryRecord(invalidEvent);
// Assert
Assert.False(recorded);
Assert.False(store.TryGet(PlayerId, " ", out _));
}
[Fact]
public void TryGet_ShouldExposeReadableGrantSummary()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var deliveryEvent = CreatePrototypeEvent();
// Act
var recorded = store.TryRecord(deliveryEvent);
var found = store.TryGet(PlayerId, QuestId, out var stored);
// Assert
Assert.True(recorded);
Assert.True(found);
Assert.Equal("survey_drone_kit", stored.GrantedItems[0].ItemId);
Assert.Equal(1, stored.GrantedItems[0].Quantity);
Assert.Equal("salvage", stored.GrantedSkillXp[0].SkillId);
Assert.Equal(50, stored.GrantedSkillXp[0].Amount);
}
[Fact]
public async Task Host_ShouldResolveRewardDeliveryStoreFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var deliveryStore = factory.Services.GetRequiredService<IRewardDeliveryStore>();
// Act
var recorded = deliveryStore.TryRecord(CreatePrototypeEvent());
var found = deliveryStore.TryGet(PlayerId, QuestId, out var stored);
// Assert
Assert.IsType<InMemoryRewardDeliveryStore>(deliveryStore);
Assert.True(recorded);
Assert.True(found);
Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey);
}
private static RewardDeliveryEvent CreatePrototypeEvent() =>
new(
PlayerId,
QuestId,
DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)],
[new RewardSkillXpGrantApplied("salvage", 50)],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId));
}

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Idempotent per-player quest completion reward delivery record (NEO-126).</summary>
public interface IRewardDeliveryStore
{
/// <summary>First record returns <c>true</c>; replays for the same player+quest return <c>false</c>.</summary>
bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
}

View File

@ -0,0 +1,69 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Thread-safe in-memory quest completion reward delivery records (NEO-126).</summary>
public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
{
private readonly ConcurrentDictionary<string, RewardDeliveryEvent> eventsByKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryRecord(RewardDeliveryEvent deliveryEvent)
{
var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.QuestId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (eventsByKey.ContainsKey(key))
{
return false;
}
eventsByKey[key] = deliveryEvent with
{
GrantedItems = deliveryEvent.GrantedItems.ToArray(),
GrantedSkillXp = deliveryEvent.GrantedSkillXp.ToArray(),
};
return true;
}
}
/// <inheritdoc />
public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent)
{
deliveryEvent = default;
var key = RewardDeliveryIds.MakeStoreKey(playerId, questId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
return eventsByKey.TryGetValue(key, out deliveryEvent);
}
}
/// <summary>Dev fixture only — removes one player+quest delivery row (Bruno / test reset).</summary>
public bool TryClear(string playerId, string questId)
{
var key = RewardDeliveryIds.MakeStoreKey(playerId, questId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
var removed = eventsByKey.TryRemove(key, out _);
keyLocks.TryRemove(key, out _);
return removed;
}
}
}

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>
/// Idempotent quest completion reward delivery record (NEO-126).
/// Persisted via <see cref="IRewardDeliveryStore"/> after router apply succeeds (NEO-127).
/// </summary>
public readonly record struct RewardDeliveryEvent(
string PlayerId,
string QuestId,
DateTimeOffset DeliveredAt,
IReadOnlyList<RewardItemGrantApplied> GrantedItems,
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
string IdempotencyKey);

View File

@ -0,0 +1,46 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Id normalization and composite keys for reward delivery stores (NEO-126).</summary>
public static class RewardDeliveryIds
{
/// <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>Stable idempotency key for quest completion delivery.</summary>
public static string MakeIdempotencyKey(string? playerId, string? questId)
{
var p = NormalizePlayerId(playerId);
var q = NormalizeQuestId(questId);
if (p.Length == 0 || q.Length == 0)
{
return string.Empty;
}
return $"{p}:quest_complete:{q}";
}
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="questId"/>.</summary>
public static string MakeStoreKey(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,12 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Registers quest completion reward delivery persistence (NEO-126).</summary>
public static class RewardDeliveryServiceCollectionExtensions
{
/// <summary>Prototype uses in-memory singleton; Postgres deferred until a persistence story lands.</summary>
public static IServiceCollection AddRewardDeliveryStore(this IServiceCollection services)
{
services.AddSingleton<IRewardDeliveryStore, InMemoryRewardDeliveryStore>();
return services;
}
}

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>One item grant applied on a successful quest completion reward delivery (NEO-126).</summary>
public readonly record struct RewardItemGrantApplied(string ItemId, int Quantity);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>One skill XP grant applied on a successful quest completion reward delivery (NEO-126).</summary>
public readonly record struct RewardSkillXpGrantApplied(string SkillId, int Amount);

View File

@ -10,6 +10,7 @@ using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Targeting;
@ -30,6 +31,7 @@ builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
builder.Services.AddRewardDeliveryStore();
builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddPlayerCombatHealthStore();

View File

@ -265,6 +265,25 @@ Comment-only hook sites in **`QuestStateOperations`** for future E9.M1 catalog e
Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: [NEO-121 implementation plan](../../docs/plans/NEO-121-implementation-plan.md).
## Reward delivery store (NEO-126)
**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully (router apply lands in NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**).
| Field | Role |
|-------|------|
| **`PlayerId`**, **`QuestId`** | Normalized keys (trim + lowercase). |
| **`DeliveredAt`** | UTC timestamp at delivery record commit. |
| **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. |
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`skillId`, `amount`) at commit time. |
| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. |
**Store interface:**
- **`TryRecord`** — first delivery returns `true`; replays for the same player+quest return `false` without overwriting the snapshot.
- **`TryGet`** — read one delivery event for HTTP **`completionRewardSummary`** projection (NEO-129).
Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest completion rewards use this store only. Postgres persistence deferred. Plan: [NEO-126 implementation plan](../../docs/plans/NEO-126-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/`.