# 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` + per-key locks — same threading model as `InMemoryEncounterCompleteEventStore`. - `TryClear(playerId, questId)` on concrete type only (dev fixture). ### 5. DI **`RewardDeliveryServiceCollectionExtensions.AddRewardDeliveryStore`:** - `services.AddSingleton();` - 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.