10 KiB
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 · Epic 7 Slice 2 · backlog E7M2-03 |
| Branch | NEO-126-e7m2-reward-delivery-store |
| Precursor | NEO-124 Done — bundle schemas + catalog rows · NEO-125 Done — server catalog load + E7M2 gates |
| Pattern | NEO-107 — IEncounterCompleteEventStore / InMemoryEncounterCompleteEventStore idempotent TryRecord + TryGet |
| Blocks | NEO-127 (RewardRouterOperations apply path) |
| Client counterpart | none — infrastructure-only (E7M2-prototype-backlog) |
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 and E7.M2 module. - Namespace:
Game/Rewards/— delivery is cross-cutting (quest completion v1; encounter consumer later); keepsGame/Quests/for state machine only. - Grant summary rows: dedicated
RewardItemGrantApplied+RewardSkillXpGrantAppliedon the event (do not reuseEncounterGrantApplied— quest bundles include skill XP lines). - Store key: composite
playerId\0questIdviaRewardDeliveryIds.MakeStoreKeyforTryGet(playerId, questId); event carries explicitIdempotencyKeystring for router audit. - Dev
TryClear: optional onInMemoryRewardDeliveryStoreonly (Bruno / test reset parity withInMemoryEncounterCompleteEventStore).
Goal, scope, and out-of-scope
Goal: Durable idempotency record for RewardDeliveryEvent per player + quest completion.
In scope (from Linear + E7M2-03):
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.mdstore section.- DI registration in
Program.cs.
Out of scope (from Linear):
- Router apply logic (NEO-127 E7M2-04).
- Quest-state wiring (NEO-128 E7M2-05).
- HTTP
completionRewardSummaryprojection (NEO-129 E7M2-06). - Postgres migration /
PostgresRewardDeliveryStore(deferred per kickoff answer). - Godot client.
Acceptance criteria checklist
TryRecordidempotent on same key.- Store readable summary for HTTP projection.
Implementation reconciliation (shipped)
- Types:
RewardDeliveryEvent,RewardItemGrantApplied,RewardSkillXpGrantApplied,RewardDeliveryIdsinGame/Rewards/. - Store:
IRewardDeliveryStore+InMemoryRewardDeliveryStore(TryRecord,TryGet, devTryClear). - DI:
AddRewardDeliveryStore()inProgram.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.mdreward 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 forProgram.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
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 asInMemoryEncounterCompleteEventStore.TryClear(playerId, questId)on concrete type only (dev fixture).
5. DI
RewardDeliveryServiceCollectionExtensions.AddRewardDeliveryStore:
services.AddSingleton<IRewardDeliveryStore, InMemoryRewardDeliveryStore>();- Call from
Program.csafter quest catalog / quest state registration.
No connection-string branch until Postgres story.
6. Tests (InMemoryRewardDeliveryStoreTests)
Mirror InMemoryEncounterCompleteEventStoreTests:
TryRecord_ShouldBeIdempotent_AtStoreLayer— firsttrue, secondfalse;TryGetreturns originalDeliveredAt+ grant snapshots.TryRecord_ShouldFailClosed_WhenPlayerIdIsEmptyTryRecord_ShouldFailClosed_WhenQuestIdIsEmptyTryGet_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.