Merge pull request #165 from ViPro-Technologies/NEO-126-e7m2-reward-delivery-store
NEO-126: E7M2-03 reward delivery store (IRewardDeliveryStore)pull/167/head
commit
ce142b9271
|
|
@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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-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-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
|
|
@ -146,8 +146,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
|
||||||
|
|
||||||
**Acceptance criteria**
|
**Acceptance criteria**
|
||||||
|
|
||||||
- [ ] `TryRecord` idempotent on same key.
|
- [x] `TryRecord` idempotent on same key.
|
||||||
- [ ] Store readable summary for HTTP projection.
|
- [x] Store readable summary for HTTP projection.
|
||||||
|
|
||||||
**Client counterpart:** none (infrastructure-only).
|
**Client counterpart:** none (infrastructure-only).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`).
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -10,6 +10,7 @@ using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Quests;
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Game.Rewards;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using NeonSprawl.Server.Game.Targeting;
|
using NeonSprawl.Server.Game.Targeting;
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ 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.AddPlayerQuestStateStore(builder.Configuration);
|
||||||
|
builder.Services.AddRewardDeliveryStore();
|
||||||
builder.Services.AddThreatStateStore();
|
builder.Services.AddThreatStateStore();
|
||||||
builder.Services.AddNpcRuntimeStateStore();
|
builder.Services.AddNpcRuntimeStateStore();
|
||||||
builder.Services.AddPlayerCombatHealthStore();
|
builder.Services.AddPlayerCombatHealthStore();
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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)
|
## 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/`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue