From b20d7d001ea599ebd3eab2b5d375403297b8f058 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 27 Jun 2026 19:54:16 -0400 Subject: [PATCH 1/4] NEO-148: add E7M4-05 implementation plan for contract reward router --- docs/plans/NEO-148-implementation-plan.md | 162 ++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/plans/NEO-148-implementation-plan.md diff --git a/docs/plans/NEO-148-implementation-plan.md b/docs/plans/NEO-148-implementation-plan.md new file mode 100644 index 0000000..b49704d --- /dev/null +++ b/docs/plans/NEO-148-implementation-plan.md @@ -0,0 +1,162 @@ +# NEO-148 — E7M4-05: Extend RewardRouterOperations for contract completion bundles + +**Linear:** [NEO-148](https://linear.app/neon-sprawl/issue/NEO-148) +**Branch:** `NEO-148-e7m4-05-reward-router-contract-completion` +**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-05** +**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) +**Pattern:** [NEO-127-implementation-plan.md](NEO-127-implementation-plan.md) — grant order + compensating rollback + `TryRecord` race mirror; [NEO-138-implementation-plan.md](NEO-138-implementation-plan.md) — reputation grants + store snapshot extension +**Precursors:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) **`Done`** — contract template catalog load; [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) **`Done`** — `ContractOutcomeIds.MakeIdempotencyKey`, grant snapshot types +**Blocks:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) (E7M4-06 `ContractCompletionOperations` + encounter wiring) +**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (blocked by E7M4-08; no Godot work in this story) + +## Goal + +Contract completion payouts reuse E7.M2 delivery idempotency with contract-specific keys (`{playerId}:contract_complete:{contractInstanceId}`). + +## Kickoff clarifications + +**No blocking decisions needed.** Linear AC, [E7M4-05 backlog scope](E7M4-pre-production-backlog.md#e7m4-05--extend-rewardrouteroperations-for-contract-completion-bundles), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), and landed NEO-127/NEO-138/NEO-146 code settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| Idempotency key | **`ContractOutcomeIds.MakeIdempotencyKey`** → `{playerId}:contract_complete:{contractInstanceId}` | E7M4 kickoff table; NEO-146 shipped helper | +| Bundle DTO | Reuse **`QuestRewardBundleRow`** on **`ContractTemplateRow.CompletionRewardBundle`** | NEO-145 loader; prototype freeze table | +| Grant kinds + order | Items → skill XP → reputation; compensating rollback on partial deny and **`TryRecord`** race loss | E7M4-05 backlog “mirrors E7.M2 quest path”; NEO-127/NEO-138 | +| Delivery store | Extend **`IRewardDeliveryStore`** / **`RewardDeliveryEvent`** with **`contract_completion`** source kind | E7M4-05 backlog in-scope list | +| Outcome audit store | **Out of scope** — **`IContractOutcomeStore.TryAppend`** is E7M4-06 orchestrator responsibility | NEO-146 open-questions table (“orchestrator fills from router result”) | +| Reputation source | **`ReputationDeltaSourceKinds.ContractCompletion`** + **`contractInstanceId`** as **`sourceId`** | NEO-138 **`QuestCompletion`** precedent | +| Skill XP source kind | **`mission_reward`** (same as quest completion) | Prototype contract freeze; NEO-127 | +| Router shape | **`TryDeliverContractCompletion`** public entry + private shared grant-apply core extracted from quest path | Avoid ~200-line duplication while keeping **`TryDeliverQuestCompletion`** signature stable | +| Store API | Source-kind-aware **`TryGet`/`TryRecord`/`TryClear`**; existing quest methods remain thin wrappers | Backlog “extend store for contract source kind”; backward compat for NEO-126 callers | +| **`IContractOutcomeStore` replay** | Router idempotency uses **`IRewardDeliveryStore`** only; outcome store is separate audit trail | E7M4-05 vs E7M4-06 scope split | +| Postgres delivery store | **Deferred** — in-memory only (same as quest delivery today) | NEO-126/NEO-138 precedent | +| Encounter / HTTP wiring | **Out of scope** | E7M4-06, E7M4-08 | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- **`RewardRouterOperations.TryDeliverContractCompletion`** — same grant kinds as quest completion with contract idempotency key. +- Extend **`IRewardDeliveryStore`**, **`RewardDeliveryEvent`**, **`RewardDeliveryIds`**, **`InMemoryRewardDeliveryStore`** for **`contract_completion`** source kind. +- Add **`ReputationDeltaSourceKinds.ContractCompletion`**. +- Compensating rollback policy mirrors E7.M2 quest path (items, skill XP, reputation, perk revert, race loser). +- Integration tests: first delivery, idempotent replay, unknown item/faction deny. +- `server/README.md` reward router + delivery store sections. + +**Out of scope (from Linear + backlog):** + +- **`ContractCompletionOperations`** + encounter wiring (NEO-149 / E7M4-06). +- **`IContractOutcomeStore.TryAppend`** from router (orchestrator in E7M4-06). +- HTTP contract projections (NEO-151 / E7M4-08). +- E9.M1 telemetry ingest (comment-only hook sites optional; not required by AC). +- Godot client (NEO-153+). + +**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract HUD after E7M4-08. + +## Acceptance criteria checklist + +- [ ] First-time contract complete applies bundle exactly once per instance id. +- [ ] Replay returns **`already_delivered`** without double grant. + +## Technical approach + +### 1. Types (`Game/Rewards/`) + +| Type | Change | +|------|--------| +| **`RewardDeliverySourceKinds`** (new) | Stable strings: **`quest_completion`**, **`contract_completion`** | +| **`RewardDeliveryEvent`** | Add **`SourceKind`**; add **`ContractInstanceId`** (empty for quest rows); quest rows keep **`QuestId`** populated | +| **`RewardDeliveryIds`** | Add **`NormalizeContractInstanceId`** (delegate to **`ContractInstanceIds`**); **`MakeContractStoreKey(playerId, contractInstanceId)`**; extend **`MakeStoreKey`** to include source kind `{p}\0{kind}\0{id}` | +| **`RewardDeliveryReasonCodes`** | Add **`invalid_contract_instance_id`** | +| **`IRewardDeliveryStore`** | Add **`TryGet(playerId, sourceKind, sourceId, …)`**, **`TryRecord`**, **`TryClear`** overloads; keep existing quest methods as wrappers calling **`quest_completion`** | +| **`InMemoryRewardDeliveryStore`** | Index by source-kind-aware store key; quest + contract rows isolated | + +Add **`ReputationDeltaSourceKinds.ContractCompletion = "contract_completion"`** in `Game/Factions/`. + +### 2. `RewardRouterOperations` + +**New public method:** + +```csharp +public static RewardDeliveryResult TryDeliverContractCompletion( + string playerId, + string contractInstanceId, + QuestRewardBundleRow? bundle, + /* same dependency injection as TryDeliverQuestCompletion */) +``` + +**Flow** (mirror quest): + +1. Normalize **`playerId`** + **`contractInstanceId`**; deny on empty ids. +2. **`deliveryStore.TryGet(playerId, contract_completion, contractInstanceId)`** — idempotent replay → **`already_delivered`** success without re-grant. +3. Resolve bundle grant lists (null → empty). +4. Apply items → skill XP → reputation with **`ContractOutcomeIds.MakeIdempotencyKey`** and **`ReputationDeltaSourceKinds.ContractCompletion`** + **`contractInstanceId`** as rep **`sourceId`**. +5. Build **`RewardDeliveryEvent`** with **`SourceKind = contract_completion`**, **`ContractInstanceId`**, empty **`QuestId`**, grant snapshots, idempotency key. +6. **`TryRecord`**; on race loss, compensating rollback all grant types and return winner event. + +**Refactor:** Extract private **`TryDeliverBundleCore`** (or equivalent) parameterized by delivery context (source kind, source id, idempotency key factory, rep source kind/id) so quest and contract paths share rollback/apply logic. **`TryDeliverQuestCompletion`** remains a thin wrapper — no signature change for callers. + +### 3. Prototype test fixtures + +Use frozen template bundle from **`prototype_contract_clear_combat_pocket`**: + +- **`scrap_metal_bulk` ×5**, **`salvage` 15 XP** (no rep in prototype contract). + +Contract instance id: synthetic test value (e.g. **`prototype_contract_clear_combat_pocket:test-seed-001`**) — no generator dependency required for router unit tests. + +### 4. Documentation + +- **`server/README.md`**: extend reward delivery store table with **`SourceKind`** + contract key; add **`TryDeliverContractCompletion`** section under reward router with reason codes (reuse quest passthrough set + **`invalid_contract_instance_id`**). +- Update **`E7_M4_ContractMissionGenerator.md`** Slice 4 anchor when shipped (router landed). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs` | Stable **`quest_completion`** / **`contract_completion`** source kind constants | + +*(No other new source files expected — router + store extensions land in existing files.)* + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | Add **`TryDeliverContractCompletion`**; extract shared grant-apply core | +| `server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs` | Source-kind-aware **`TryGet`/`TryRecord`/`TryClear`** overloads | +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` | Add **`SourceKind`**, **`ContractInstanceId`** | +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs` | Contract instance normalization + source-kind store keys | +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs` | **`invalid_contract_instance_id`** | +| `server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs` | Source-kind-aware indexing | +| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs` | **`ContractCompletion`** constant | +| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | Contract delivery happy path, replay, deny tests | +| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | Contract source kind idempotency at store layer | +| `server/README.md` | Delivery store + router contract completion docs | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | Slice 4 router anchor when shipped | + +## Tests + +| File | Coverage | +|------|----------| +| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | **`TryDeliverContractCompletion_ShouldApplyPrototypeBundle_WhenFirstDelivery`** — items + skill XP applied once; store record with **`contract_completion`** key | +| Same | **`TryDeliverContractCompletion_ShouldReturnAlreadyDelivered_WhenReplayed`** — second call success + **`already_delivered`**; inventory/xp unchanged | +| Same | **`TryDeliverContractCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord`** — rep grant with unknown faction; no delivery row | +| Same | **`TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord`** — unknown item / full bag deny (backlog “unknown item/faction deny”) | +| Same | **`TryDeliverContractCompletion_ShouldDenyInvalidContractInstanceId_WhenEmpty`** — fail-closed id validation | +| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | Contract **`TryRecord`** idempotency + source-kind isolation from quest rows | + +All new tests follow AAA with **`// Arrange` / `// Act` / `// Assert`** labels per [csharp-style](../.cursor/rules/csharp-style.md). + +Manual Godot QA: **none** (server engine; capstone NEO-154). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| Shared-core refactor touches quest path | Keep **`TryDeliverQuestCompletion`** as thin wrapper; run full existing **`RewardRouterOperationsTests`** suite unchanged | `adopted` | +| Quest vs contract store key collision | Prefix store key with **`SourceKind`** — ids already namespaced (`prototype_quest_*` vs generated instance ids) | `adopted` | +| Rep grants on future contract templates | Apply path ready; prototype template has no rep rows — test unknown-faction deny with synthetic bundle | `adopted` | +| **`IContractOutcomeStore`** vs delivery store duplication | Delivery store = grant idempotency (E7.M2 pattern); outcome store = audit append from E7M4-06 orchestrator only | `adopted` | + +## Client counterpart + +[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after E7M4-08 HTTP. This story is server-only; Bruno/HTTP contract loop verification lands in E7M4-06+. From 76ce1943c742305b1b23786fa6c84e120d357bb3 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 27 Jun 2026 19:58:07 -0400 Subject: [PATCH 2/4] NEO-148: add contract completion reward router and delivery store extension TryDeliverContractCompletion reuses the quest grant-apply path with contract-specific idempotency keys and source-kind-aware delivery store rows. --- .../modules/E7_M4_ContractMissionGenerator.md | 2 + docs/plans/NEO-148-implementation-plan.md | 12 +- .../InMemoryRewardDeliveryStoreTests.cs | 44 +++++ .../Rewards/RewardRouterOperationsTests.cs | 184 +++++++++++++++++- .../Factions/ReputationDeltaSourceKinds.cs | 2 + .../Game/Rewards/IRewardDeliveryStore.cs | 8 +- .../Rewards/InMemoryRewardDeliveryStore.cs | 22 ++- .../Game/Rewards/RewardDeliveryEvent.cs | 8 +- .../Game/Rewards/RewardDeliveryIds.cs | 46 ++++- .../Game/Rewards/RewardDeliveryReasonCodes.cs | 2 + .../Game/Rewards/RewardDeliverySourceKinds.cs | 9 + .../Game/Rewards/RewardRouterOperations.cs | 157 ++++++++++++--- server/README.md | 24 ++- 13 files changed, 464 insertions(+), 56 deletions(-) create mode 100644 server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index bfc7d6d..3bdf964 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -59,6 +59,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies. **Generator operations (NEO-147):** **`ContractGeneratorOperations.TryIssue`** — template selection, deterministic instance ids, structured deny codes; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147). +**Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. diff --git a/docs/plans/NEO-148-implementation-plan.md b/docs/plans/NEO-148-implementation-plan.md index b49704d..0dc442c 100644 --- a/docs/plans/NEO-148-implementation-plan.md +++ b/docs/plans/NEO-148-implementation-plan.md @@ -55,8 +55,16 @@ Contract completion payouts reuse E7.M2 delivery idempotency with contract-speci ## Acceptance criteria checklist -- [ ] First-time contract complete applies bundle exactly once per instance id. -- [ ] Replay returns **`already_delivered`** without double grant. +- [x] First-time contract complete applies bundle exactly once per instance id. +- [x] Replay returns **`already_delivered`** without double grant. + +## Implementation reconciliation (shipped) + +- **Types:** `RewardDeliverySourceKinds`; `RewardDeliveryEvent.SourceKind` + `ContractInstanceId`; `RewardDeliveryReasonCodes.InvalidContractInstanceId`; `ReputationDeltaSourceKinds.ContractCompletion`. +- **Store:** `IRewardDeliveryStore` source-kind-aware `TryGet`/`TryClear`; `InMemoryRewardDeliveryStore` keys `{playerId}\0{sourceKind}\0{sourceId}`. +- **Router:** `TryDeliverContractCompletion` + private shared `TryDeliverBundle` core; quest path unchanged at public API. +- **Tests:** 5 contract router tests + 1 store isolation test; full suite **897** tests green. +- **Docs:** `server/README.md` delivery store + router sections; E7.M4 module anchor. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs index e39699b..8530dbe 100644 --- a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Tests; using Xunit; @@ -124,6 +125,49 @@ public sealed class InMemoryRewardDeliveryStoreTests Assert.True(cleared); } + [Fact] + public void TryRecord_ShouldIsolateContractRows_FromQuestRowsWithSameSourceId() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + const string sharedSourceId = "prototype_shared_source_id"; + var questEvent = new RewardDeliveryEvent( + PlayerId, + sharedSourceId, + DeliveredAt, + [new RewardItemGrantApplied("survey_drone_kit", 1)], + [], + [], + RewardDeliveryIds.MakeIdempotencyKey(PlayerId, sharedSourceId)); + var contractEvent = new RewardDeliveryEvent( + PlayerId, + QuestId: string.Empty, + DeliveredAt, + [new RewardItemGrantApplied("scrap_metal_bulk", 5)], + [], + [], + ContractOutcomeIds.MakeIdempotencyKey(PlayerId, sharedSourceId), + RewardDeliverySourceKinds.ContractCompletion, + sharedSourceId); + // Act + var questRecorded = store.TryRecord(questEvent); + var contractRecorded = store.TryRecord(contractEvent); + // Assert + Assert.True(questRecorded); + Assert.True(contractRecorded); + Assert.True(store.TryGet(PlayerId, sharedSourceId, out var storedQuest)); + Assert.True( + store.TryGet( + PlayerId, + RewardDeliverySourceKinds.ContractCompletion, + sharedSourceId, + out var storedContract)); + Assert.Equal(RewardDeliverySourceKinds.QuestCompletion, storedQuest.SourceKind); + Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, storedContract.SourceKind); + Assert.Equal("survey_drone_kit", storedQuest.GrantedItems[0].ItemId); + Assert.Equal("scrap_metal_bulk", storedContract.GrantedItems[0].ItemId); + } + private static RewardDeliveryEvent CreatePrototypeEvent() => new( PlayerId, diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs index 673f377..7333d49 100644 --- a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; @@ -18,6 +19,7 @@ public sealed class RewardRouterOperationsTests private const string OperatorChainQuestId = "prototype_quest_operator_chain"; private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; private const string RustCollectiveFactionId = "prototype_faction_rust_collective"; + private const string ContractInstanceId = "ci_test_contract_completion_001"; private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1"; private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero); @@ -653,6 +655,181 @@ public sealed class RewardRouterOperationsTests Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _)); } + [Fact] + public async Task TryDeliverContractCompletion_ShouldApplyPrototypeBundle_WhenFirstDelivery() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = PrototypeContractCompletionBundle(); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore); + var scrapBefore = CountBagItem(inventoryBefore!, "scrap_metal_bulk"); + + // Act + var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Single(result.GrantedItems); + Assert.Equal("scrap_metal_bulk", result.GrantedItems[0].ItemId); + Assert.Equal(5, result.GrantedItems[0].Quantity); + Assert.Single(result.GrantedSkillXp); + Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId); + Assert.Equal(15, result.GrantedSkillXp[0].Amount); + Assert.Empty(result.GrantedReputation); + Assert.True( + deps.DeliveryStore.TryGet( + PlayerId, + RewardDeliverySourceKinds.ContractCompletion, + ContractInstanceId, + out var stored)); + Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, stored.SourceKind); + Assert.Equal(ContractInstanceId, stored.ContractInstanceId); + Assert.Equal( + ContractOutcomeIds.MakeIdempotencyKey(PlayerId, ContractInstanceId), + stored.IdempotencyKey); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter); + Assert.Equal(scrapBefore + 5, CountBagItem(inventoryAfter!, "scrap_metal_bulk")); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; + Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp)); + Assert.Equal(salvageBefore + 15, salvageXp); + } + + [Fact] + public async Task TryDeliverContractCompletion_ShouldReturnAlreadyDelivered_WhenReplayed() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = PrototypeContractCompletionBundle(); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var scrapBefore = CountBagItem(inventoryBefore!, "scrap_metal_bulk"); + var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; + + // Act + var first = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System); + var second = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System); + + // Assert + Assert.True(first.Success); + Assert.Null(first.ReasonCode); + Assert.True(second.Success); + Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter); + Assert.Equal(scrapBefore + 5, CountBagItem(inventoryAfter!, "scrap_metal_bulk")); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter)); + Assert.Equal(salvageBefore + 15, salvageAfter); + } + + [Fact] + public async Task TryDeliverContractCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = new QuestRewardBundleRow( + [], + [], + [new ReputationGrantRow("prototype_faction_unknown", 10)]); + + // Act + var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode); + Assert.False( + deps.DeliveryStore.TryGet( + PlayerId, + RewardDeliverySourceKinds.ContractCompletion, + ContractInstanceId, + out _)); + } + + [Fact] + public async Task TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + FillBag(deps); + var bundle = PrototypeContractCompletionBundle(); + + // Act + var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode); + Assert.False( + deps.DeliveryStore.TryGet( + PlayerId, + RewardDeliverySourceKinds.ContractCompletion, + ContractInstanceId, + out _)); + } + + [Fact] + public async Task TryDeliverContractCompletion_ShouldDenyInvalidContractInstanceId_WhenEmpty() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = PrototypeContractCompletionBundle(); + + // Act + var result = RewardRouterOperations.TryDeliverContractCompletion( + PlayerId, + " ", + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, + TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.InvalidContractInstanceId, result.ReasonCode); + } + + private static QuestRewardBundleRow PrototypeContractCompletionBundle() => + new( + [new RewardGrantRow("scrap_metal_bulk", 5)], + [new QuestSkillXpGrantRow("salvage", 15)]); + + private static RewardDeliveryResult DeliverContract( + RewardRouterTestDependencies deps, + string contractInstanceId, + QuestRewardBundleRow? bundle, + TimeProvider timeProvider) => + RewardRouterOperations.TryDeliverContractCompletion( + PlayerId, + contractInstanceId, + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, + timeProvider); + private static RewardDeliveryResult Deliver( RewardRouterTestDependencies deps, string questId, @@ -755,7 +932,10 @@ public sealed class RewardRouterOperationsTests return false; } - public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) + public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) => + TryGet(playerId, RewardDeliverySourceKinds.QuestCompletion, questId, out deliveryEvent); + + public bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent) { if (!recordAttempted) { @@ -768,6 +948,8 @@ public sealed class RewardRouterOperationsTests } public bool TryClear(string playerId, string questId) => true; + + public bool TryClear(string playerId, string sourceKind, string sourceId) => true; } /// Second forward rep audit append fails; rollback appends still succeed. diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs index 873a50d..64b080e 100644 --- a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs +++ b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs @@ -4,4 +4,6 @@ namespace NeonSprawl.Server.Game.Factions; public static class ReputationDeltaSourceKinds { public const string QuestCompletion = "quest_completion"; + + public const string ContractCompletion = "contract_completion"; } diff --git a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs index f7c4860..523faea 100644 --- a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs +++ b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs @@ -1,16 +1,20 @@ namespace NeonSprawl.Server.Game.Rewards; -/// Idempotent per-player quest completion reward delivery record (NEO-126). +/// Idempotent per-player reward delivery records (NEO-126). public interface IRewardDeliveryStore { - /// First record returns true; replays for the same player+quest return false. + /// First record returns true; replays for the same player+source return false. bool TryRecord(RewardDeliveryEvent deliveryEvent); bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent); + bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent); + /// /// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset. /// Idempotent when the row is already absent. /// bool TryClear(string playerId, string questId); + + bool TryClear(string playerId, string sourceKind, string sourceId); } diff --git a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs index 2ecc621..383abf1 100644 --- a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs +++ b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs @@ -2,7 +2,7 @@ using System.Collections.Concurrent; namespace NeonSprawl.Server.Game.Rewards; -/// Thread-safe in-memory quest completion reward delivery records (NEO-126). +/// Thread-safe in-memory reward delivery records (NEO-126). public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore { private readonly ConcurrentDictionary eventsByKey = new(StringComparer.Ordinal); @@ -12,7 +12,7 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore /// public bool TryRecord(RewardDeliveryEvent deliveryEvent) { - var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.QuestId); + var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent); if (key.Length == 0) { return false; @@ -36,10 +36,14 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore } /// - public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) + public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) => + TryGet(playerId, RewardDeliverySourceKinds.QuestCompletion, questId, out deliveryEvent); + + /// + public bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent) { deliveryEvent = default; - var key = RewardDeliveryIds.MakeStoreKey(playerId, questId); + var key = RewardDeliveryIds.MakeStoreKey(playerId, sourceKind, sourceId); if (key.Length == 0) { return false; @@ -51,10 +55,14 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore } } - /// Dev fixture only — removes one player+quest delivery row (Bruno / test reset). - public bool TryClear(string playerId, string questId) + /// + public bool TryClear(string playerId, string questId) => + TryClear(playerId, RewardDeliverySourceKinds.QuestCompletion, questId); + + /// Dev fixture only — removes one player+source delivery row (Bruno / test reset). + public bool TryClear(string playerId, string sourceKind, string sourceId) { - var key = RewardDeliveryIds.MakeStoreKey(playerId, questId); + var key = RewardDeliveryIds.MakeStoreKey(playerId, sourceKind, sourceId); if (key.Length == 0) { return false; diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs index 15c4eeb..a71dc97 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs @@ -1,9 +1,9 @@ namespace NeonSprawl.Server.Game.Rewards; /// -/// Idempotent quest completion reward delivery record (NEO-126). +/// Idempotent reward delivery record (NEO-126). /// Persisted via after router apply succeeds (NEO-127). -/// E9.M1 reward_delivery hook site: TryDeliverQuestCompletion (NEO-130). +/// E9.M1 reward_delivery hook site: (NEO-130). /// public readonly record struct RewardDeliveryEvent( string PlayerId, @@ -12,4 +12,6 @@ public readonly record struct RewardDeliveryEvent( IReadOnlyList GrantedItems, IReadOnlyList GrantedSkillXp, IReadOnlyList GrantedReputation, - string IdempotencyKey); + string IdempotencyKey, + string SourceKind = RewardDeliverySourceKinds.QuestCompletion, + string ContractInstanceId = ""); diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs index cc018fe..5d50f0e 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Factions; namespace NeonSprawl.Server.Game.Rewards; @@ -20,6 +21,10 @@ public static class RewardDeliveryIds /// Trim + lowercase; empty when input is null/whitespace. public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId); + /// Trim + lowercase; empty when input is null/whitespace. + public static string NormalizeContractInstanceId(string? contractInstanceId) => + ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); + /// Stable idempotency key for quest completion delivery. public static string MakeIdempotencyKey(string? playerId, string? questId) { @@ -49,16 +54,47 @@ public static class RewardDeliveryIds public static string MakeReputationRollbackDeltaId(string reputationDeltaId) => reputationDeltaId.Length == 0 ? string.Empty : $"{reputationDeltaId}:rollback"; - /// Composite store key for + . - public static string MakeStoreKey(string? playerId, string? questId) + /// Composite store key for quest completion delivery. + public static string MakeStoreKey(string? playerId, string? questId) => + MakeStoreKey(playerId, RewardDeliverySourceKinds.QuestCompletion, questId); + + /// Composite store key for + + source id. + public static string MakeStoreKey(string? playerId, string? sourceKind, string? sourceId) { var p = NormalizePlayerId(playerId); - var q = NormalizeQuestId(questId); - if (p.Length == 0 || q.Length == 0) + var kind = NormalizeSourceKind(sourceKind); + var id = NormalizeSourceId(sourceKind, sourceId); + if (p.Length == 0 || kind.Length == 0 || id.Length == 0) { return string.Empty; } - return $"{p}\0{q}"; + return $"{p}\0{kind}\0{id}"; } + + /// Resolves the source id field for a persisted delivery event. + public static string GetSourceId(in RewardDeliveryEvent deliveryEvent) => + string.Equals(deliveryEvent.SourceKind, RewardDeliverySourceKinds.ContractCompletion, StringComparison.Ordinal) + ? NormalizeContractInstanceId(deliveryEvent.ContractInstanceId) + : NormalizeQuestId(deliveryEvent.QuestId); + + /// Composite store key for a persisted delivery event. + public static string MakeStoreKey(in RewardDeliveryEvent deliveryEvent) => + MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.SourceKind, GetSourceId(deliveryEvent)); + + private static string NormalizeSourceKind(string? sourceKind) + { + var trimmed = sourceKind?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return string.Empty; + } + + return trimmed.ToLowerInvariant(); + } + + private static string NormalizeSourceId(string? sourceKind, string? sourceId) => + string.Equals(sourceKind, RewardDeliverySourceKinds.ContractCompletion, StringComparison.OrdinalIgnoreCase) + ? NormalizeContractInstanceId(sourceId) + : NormalizeQuestId(sourceId); } diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs index 9e40cf2..73603f6 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs @@ -14,6 +14,8 @@ public static class RewardDeliveryReasonCodes public const string InvalidQuestId = "invalid_quest_id"; + public const string InvalidContractInstanceId = "invalid_contract_instance_id"; + public const string InventoryStoreMissing = "inventory_store_missing"; public const string ProgressionStoreMissing = "progression_store_missing"; diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs new file mode 100644 index 0000000..63b52a9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// Stable source-kind values for (NEO-148). +public static class RewardDeliverySourceKinds +{ + public const string QuestCompletion = "quest_completion"; + + public const string ContractCompletion = "contract_completion"; +} diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs index c2f989b..9e259b3 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; @@ -8,7 +9,7 @@ using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Rewards; /// -/// Applies quest completion grants idempotently (NEO-127). +/// Applies quest and contract completion grants idempotently (NEO-127, NEO-148). /// Quest-state wiring: (NEO-128). /// Reputation grants: (NEO-138). /// E9.M1 telemetry hook sites: (NEO-130). @@ -54,7 +55,105 @@ public static class RewardRouterOperations return Deny(RewardDeliveryReasonCodes.InvalidQuestId); } - if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var existingEvent)) + return TryDeliverBundle( + playerId, + bundle, + itemRegistry, + inventoryStore, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, + standingStore, + auditStore, + timeProvider, + new DeliveryTarget( + normalizedPlayerId, + RewardDeliverySourceKinds.QuestCompletion, + normalizedQuestId, + RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId), + ReputationDeltaSourceKinds.QuestCompletion, + normalizedQuestId, + normalizedQuestId, + string.Empty)); + } + + /// + /// Applies item, skill XP, and reputation rows for a contract completion and records + /// when grants succeed (NEO-148). + /// + public static RewardDeliveryResult TryDeliverContractCompletion( + string playerId, + string contractInstanceId, + QuestRewardBundleRow? bundle, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, + TimeProvider timeProvider) + { + var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId); + if (normalizedPlayerId.Length == 0) + { + return Deny(RewardDeliveryReasonCodes.InvalidPlayerId); + } + + var normalizedInstanceId = RewardDeliveryIds.NormalizeContractInstanceId(contractInstanceId); + if (normalizedInstanceId.Length == 0) + { + return Deny(RewardDeliveryReasonCodes.InvalidContractInstanceId); + } + + return TryDeliverBundle( + playerId, + bundle, + itemRegistry, + inventoryStore, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + deliveryStore, + standingStore, + auditStore, + timeProvider, + new DeliveryTarget( + normalizedPlayerId, + RewardDeliverySourceKinds.ContractCompletion, + normalizedInstanceId, + ContractOutcomeIds.MakeIdempotencyKey(normalizedPlayerId, normalizedInstanceId), + ReputationDeltaSourceKinds.ContractCompletion, + normalizedInstanceId, + string.Empty, + normalizedInstanceId)); + } + + private static RewardDeliveryResult TryDeliverBundle( + string playerId, + QuestRewardBundleRow? bundle, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, + TimeProvider timeProvider, + DeliveryTarget target) + { + if (deliveryStore.TryGet(target.NormalizedPlayerId, target.SourceKind, target.SourceId, out var existingEvent)) { return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered); } @@ -62,7 +161,6 @@ public static class RewardRouterOperations var itemGrants = bundle?.ItemGrants ?? EmptyBundleItemGrantRows; var skillGrants = bundle?.SkillXpGrants ?? EmptyBundleSkillXpGrantRows; var reputationGrants = bundle?.ReputationGrants ?? EmptyBundleReputationGrantRows; - var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId); if (itemGrants.Count > 0) { @@ -128,8 +226,7 @@ public static class RewardRouterOperations { CompensatingRevertAll( playerId, - normalizedQuestId, - idempotencyKey, + target, appliedItems, appliedSkillXp, perksUnlockedByThisCall, @@ -150,8 +247,7 @@ public static class RewardRouterOperations { CompensatingRevertAll( playerId, - normalizedQuestId, - idempotencyKey, + target, appliedItems, appliedSkillXp, perksUnlockedByThisCall, @@ -187,14 +283,14 @@ public static class RewardRouterOperations var appliedReputation = new List(reputationGrants.Count); foreach (var grant in reputationGrants) { - var deltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId); + var deltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId); var repOutcome = ReputationOperations.TryApplyDelta( playerId, grant.FactionId, grant.Amount, deltaId, - ReputationDeltaSourceKinds.QuestCompletion, - normalizedQuestId, + target.ReputationSourceKind, + target.ReputationSourceId, standingStore, auditStore, timeProvider); @@ -203,8 +299,7 @@ public static class RewardRouterOperations { CompensatingRevertAll( playerId, - normalizedQuestId, - idempotencyKey, + target, appliedItems, appliedSkillXp, perksUnlockedByThisCall, @@ -227,13 +322,15 @@ public static class RewardRouterOperations var deliveredAt = timeProvider.GetUtcNow(); var deliveryEvent = new RewardDeliveryEvent( - normalizedPlayerId, - normalizedQuestId, + target.NormalizedPlayerId, + target.QuestId, deliveredAt, appliedItems, appliedSkillXp, appliedReputation, - idempotencyKey); + target.IdempotencyKey, + target.SourceKind, + target.ContractInstanceId); if (deliveryStore.TryRecord(deliveryEvent)) { @@ -251,8 +348,7 @@ public static class RewardRouterOperations // EncounterCompletionOperations when TryMarkCompleted returns false). CompensatingRevertAll( playerId, - normalizedQuestId, - idempotencyKey, + target, appliedItems, appliedSkillXp, perksUnlockedByThisCall, @@ -267,7 +363,7 @@ public static class RewardRouterOperations auditStore, timeProvider); - if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent)) + if (deliveryStore.TryGet(target.NormalizedPlayerId, target.SourceKind, target.SourceId, out var racedEvent)) { return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered); } @@ -277,8 +373,7 @@ public static class RewardRouterOperations private static void CompensatingRevertAll( string playerId, - string normalizedQuestId, - string idempotencyKey, + DeliveryTarget target, IReadOnlyList appliedItems, IReadOnlyList appliedSkillXp, HashSet perksUnlockedByThisCall, @@ -295,8 +390,7 @@ public static class RewardRouterOperations { CompensatingRevertReputation( playerId, - normalizedQuestId, - idempotencyKey, + target, appliedReputation, standingStore, auditStore, @@ -318,8 +412,7 @@ public static class RewardRouterOperations private static void CompensatingRevertReputation( string playerId, - string normalizedQuestId, - string idempotencyKey, + DeliveryTarget target, IReadOnlyList grants, IFactionStandingStore standingStore, IReputationDeltaStore auditStore, @@ -333,15 +426,15 @@ public static class RewardRouterOperations continue; } - var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId); + var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId); var rollbackDeltaId = RewardDeliveryIds.MakeReputationRollbackDeltaId(forwardDeltaId); _ = ReputationOperations.TryApplyDelta( playerId, grant.FactionId, -grant.Amount, rollbackDeltaId, - ReputationDeltaSourceKinds.QuestCompletion, - normalizedQuestId, + target.ReputationSourceKind, + target.ReputationSourceId, standingStore, auditStore, timeProvider); @@ -428,4 +521,14 @@ public static class RewardRouterOperations GrantedReputation: deliveryEvent.GrantedReputation, DeliveryEvent: deliveryEvent, DeliveredAt: deliveryEvent.DeliveredAt); + + private readonly record struct DeliveryTarget( + string NormalizedPlayerId, + string SourceKind, + string SourceId, + string IdempotencyKey, + string ReputationSourceKind, + string ReputationSourceId, + string QuestId, + string ContractInstanceId); } diff --git a/server/README.md b/server/README.md index 5b39d9e..d0e71a4 100644 --- a/server/README.md +++ b/server/README.md @@ -451,39 +451,45 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: ## Reward delivery store (NEO-126) -**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully via **`RewardRouterOperations`** (NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**). +**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+source when completion bundle grants commit successfully via **`RewardRouterOperations`** (NEO-127, NEO-148). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**). | Field | Role | |-------|------| -| **`PlayerId`**, **`QuestId`** | Normalized keys (trim + lowercase). | +| **`PlayerId`** | Normalized key (trim + lowercase). | +| **`SourceKind`** | **`quest_completion`** (NEO-126) or **`contract_completion`** (NEO-148). | +| **`QuestId`** | Set for quest deliveries; empty for contract deliveries. | +| **`ContractInstanceId`** | Set for contract deliveries (NEO-148); empty for quest deliveries. | | **`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. | | **`GrantedReputation`** | Snapshot of applied reputation rows (`factionId`, `amount`) at commit time (NEO-138). | -| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. | +| **`IdempotencyKey`** | Stable dedupe key — quest: **`{playerId}:quest_complete:{questId}`**; contract: **`{playerId}:contract_complete:{contractInstanceId}`**. | **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). +- **`TryRecord`** — first delivery returns `true`; replays for the same player+source return `false` without overwriting the snapshot. +- **`TryGet(playerId, questId)`** — quest completion lookup for HTTP **`completionRewardSummary`** projection (NEO-129). +- **`TryGet(playerId, sourceKind, sourceId)`** — source-kind-aware lookup (contract completion in NEO-148). -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 loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest and contract completion rewards use this store. Contract outcome audit rows live separately on **`IContractOutcomeStore`** (NEO-146). Postgres persistence deferred. Plans: [NEO-126](../../docs/plans/NEO-126-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md). ## Reward router (NEO-127) **`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows), **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), and **`ReputationOperations.TryApplyDelta`** with **`ReputationDeltaSourceKinds.QuestCompletion`** (reputation rows — NEO-138), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**. +**`RewardRouterOperations.TryDeliverContractCompletion`** (NEO-148) mirrors the quest path for contract template **`completionRewardBundle`** rows with idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, **`ReputationDeltaSourceKinds.ContractCompletion`**, and **`SourceKind = contract_completion`** on the delivery event. + | Reason code | When | |-------------|------| -| **`already_delivered`** | Success replay — store already has a delivery for this player+quest (no re-grant). | -| **`invalid_player_id`**, **`invalid_quest_id`** | Normalized id empty after trim. | +| **`already_delivered`** | Success replay — store already has a delivery for this player+source (no re-grant). | +| **`invalid_player_id`**, **`invalid_quest_id`**, **`invalid_contract_instance_id`** | Normalized id empty after trim. | | **`inventory_store_missing`** | Player inventory store could not read/write. | | **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Item grant pre-flight or apply failed. | | **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. | | **`progression_store_missing`** | Skill progression store could not apply XP delta. | | **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). | -Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md). +Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations`** will call **`TryDeliverContractCompletion`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md). ### Reward telemetry hooks (NEO-130) From e75ac3932f07cb59da00271acf01fb1d6ef45beb Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 27 Jun 2026 19:59:31 -0400 Subject: [PATCH 3/4] NEO-148: add code review for contract reward router --- docs/reviews/2026-06-27-NEO-148.md | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/reviews/2026-06-27-NEO-148.md diff --git a/docs/reviews/2026-06-27-NEO-148.md b/docs/reviews/2026-06-27-NEO-148.md new file mode 100644 index 0000000..8e3cc40 --- /dev/null +++ b/docs/reviews/2026-06-27-NEO-148.md @@ -0,0 +1,56 @@ +# Code review — NEO-148 contract completion reward router + +**Date:** 2026-06-27 +**Scope:** Branch `NEO-148-e7m4-05-reward-router-contract-completion` — commits `b20d7d0`..`76ce194` vs `origin/main` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-148 extends the E7.M2 reward delivery stack for contract completion: `RewardDeliverySourceKinds`, source-kind-aware `IRewardDeliveryStore` / `InMemoryRewardDeliveryStore` keys, `RewardDeliveryEvent.SourceKind` + `ContractInstanceId`, `ReputationDeltaSourceKinds.ContractCompletion`, and `RewardRouterOperations.TryDeliverContractCompletion` with a shared private `TryDeliverBundle` core extracted from the quest path. Grant order, compensating rollback, and `TryRecord` race handling mirror NEO-127/NEO-138. Five contract router tests plus one store isolation test cover happy path, idempotent replay, faction deny, inventory deny, and invalid instance id. Implementation plan reconciliation, `server/README.md`, and E7.M4 module anchor are updated. Server-only slice; no client work expected. Full suite: **897** tests passed locally. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-148-implementation-plan.md` | **Matches** — AC checklist marked complete; shipped reconciliation aligns with diff | +| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-05) | **Matches** — router + store extension, orchestrator/outcome store out of scope | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-148 router anchor added | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Partially matches** — store/router extended in code but module page still quest-centric (no NEO-148 cross-ref) | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E7.M4 row stops at NEO-147; NEO-148 not yet recorded | +| `server/README.md` | **Matches** — delivery store fields, `TryGet` overloads, contract router + reason codes | +| Client counterpart (NEO-153) | **N/A** — explicitly out of scope; no prototype-complete claim | + +Register/tracking table update recommended for E7.M4 after merge. + +## Blocking issues + +None. + +## Suggestions + +1. **Update implementation alignment register** — Add **E7M4-05 / NEO-148 landed** to the E7.M4 row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` (router + source-kind store extension), consistent with NEO-144–NEO-147 entries. + +2. **Optional E7.M2 module cross-ref** — A one-line anchor on `E7_M2_RewardAndUnlockRouter.md` noting contract completion reuse via `TryDeliverContractCompletion` (NEO-148) would help readers who land on E7.M2 first. + +3. **Store key format note for future Postgres** — Quest store keys changed from `{playerId}\0{questId}` to `{playerId}\0{sourceKind}\0{sourceId}`. Harmless for in-memory prototype (process restart clears state). When Postgres delivery persistence lands, document or migrate old rows explicitly so idempotent replay cannot double-grant after upgrade. + +4. **Contract fixture reset (follow-on)** — `QuestFixtureOperations` still clears quest rows via `TryClear(playerId, questId)` only. When E7M4-06+ adds Bruno contract loops, contract delivery rows will need a parallel reset path — track on NEO-149 or fixture story, not blocking here. + +## Nits + +- Nit: Plan test table lists “unknown item / full bag deny”; `TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord` covers full bag only. Shared `TryDeliverBundle` item path is quest-tested for inventory deny; an explicit `invalid_item` contract test would lock naming to AC but is low value given shared core. +- Nit: NEO-130 telemetry hook comments in `TryDeliverBundle` still reference `questId` in payload notes; fine until contract telemetry wiring — consider generalizing when E9.M1 touches contract completion. +- Nit: Plan mentions `MakeContractStoreKey`; implementation uses `MakeStoreKey(playerId, sourceKind, sourceId)` instead — equivalent, no action needed. + +## Verification + +```bash +dotnet test NeonSprawl.sln +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~RewardRouterOperationsTests|FullyQualifiedName~InMemoryRewardDeliveryStoreTests" +``` + +Manual Godot QA: none (server engine per plan). From 7268935bc74e7728c067e0ef26bad025b204c0ba Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 27 Jun 2026 21:24:12 -0400 Subject: [PATCH 4/4] NEO-148: address code review doc follow-ups Add E7.M4 alignment register entry, E7.M2 cross-ref, Postgres store-key migration note, and defer contract fixture reset to NEO-149. --- .../modules/E7_M2_RewardAndUnlockRouter.md | 4 ++++ .../documentation_and_implementation_alignment.md | 2 +- docs/plans/NEO-148-implementation-plan.md | 2 ++ docs/reviews/2026-06-27-NEO-148.md | 14 +++++++------- server/README.md | 2 ++ 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md index 1988986..323a356 100644 --- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md +++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md @@ -72,6 +72,8 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r **NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). +**NEO-148 (E7.M4 contract reuse):** **`RewardRouterOperations.TryDeliverContractCompletion`** reuses the same **`IRewardDeliveryStore`** and shared **`TryDeliverBundle`** core for contract template **`completionRewardBundle`** rows (idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, **`SourceKind = contract_completion`**). See [NEO-148 implementation plan](../../plans/NEO-148-implementation-plan.md); [E7.M4 — ContractMissionGenerator](E7_M4_ContractMissionGenerator.md). + **NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **NEO-129 (E7M2-06 HTTP read):** **`GET /game/players/{id}/quest-progress`** projects optional **`completionRewardSummary`** on **`completed`** rows from **`IRewardDeliveryStore.TryGet`** — nested **`itemGrants`** + **`skillXpGrants`** mirroring delivery event snapshots; omitted when no delivery record. **`QuestAcceptApi`** embed rows use the same mapper. See [NEO-129 implementation plan](../../plans/NEO-129-implementation-plan.md); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. @@ -81,3 +83,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r **NEO-131 (E7M2-08 client HUD):** Godot **`QuestRewardDeliveryLabel`** + **`quest_hud_controller.gd`** transition detection paints **`completionRewardSummary`** grant lines when a quest newly becomes **`completed`** in-session (parse via **`quest_progress_client.gd`**). See [NEO-131 implementation plan](../../plans/NEO-131-implementation-plan.md); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **NEO-132 (E7M2-09 client capstone):** playable four-quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); economy HUD refresh on completion transition via **`quest_completion_reward_transition`** signal. Epic 7 Slice 2 client capstone complete. + +**NEO-148 (E7.M4 contract router reuse):** [E7.M4](E7_M4_ContractMissionGenerator.md) **`TryDeliverContractCompletion`** extends the same **`IRewardDeliveryStore`** + shared grant-apply core with **`contract_completion`** source kind and key **`{playerId}:contract_complete:{contractInstanceId}`** — see [NEO-148 implementation plan](../../plans/NEO-148-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 27136a5..b04993b 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -62,7 +62,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([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/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | | E7.M3 | Ready | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09 telemetry landed ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)):** comment-only **`reputation_delta`** + **`faction_gate_blocked`** hook sites in ops layers ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **E7M3-10 client faction HUD landed ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)):** `faction_standing_client.gd`, standing label + gate deny + rep reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). **E7M3-11 client capstone landed ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)):** playable faction reputation + gate capstone — [`NEO-143` manual QA](../../manual-qa/NEO-143.md); [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143); plan [NEO-143](../../plans/NEO-143-implementation-plan.md). **Epic 7 Slice 3 client capstone complete.** Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**. Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [NEO-141 plan](../../plans/NEO-141-implementation-plan.md), [NEO-142 plan](../../plans/NEO-142-implementation-plan.md), [NEO-143 plan](../../plans/NEO-143-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | -| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | +| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | --- diff --git a/docs/plans/NEO-148-implementation-plan.md b/docs/plans/NEO-148-implementation-plan.md index 0dc442c..9aaeda3 100644 --- a/docs/plans/NEO-148-implementation-plan.md +++ b/docs/plans/NEO-148-implementation-plan.md @@ -164,6 +164,8 @@ Manual Godot QA: **none** (server engine; capstone NEO-154). | Quest vs contract store key collision | Prefix store key with **`SourceKind`** — ids already namespaced (`prototype_quest_*` vs generated instance ids) | `adopted` | | Rep grants on future contract templates | Apply path ready; prototype template has no rep rows — test unknown-faction deny with synthetic bundle | `adopted` | | **`IContractOutcomeStore`** vs delivery store duplication | Delivery store = grant idempotency (E7.M2 pattern); outcome store = audit append from E7M4-06 orchestrator only | `adopted` | +| Contract delivery fixture reset | **`QuestFixtureOperations`** clears quest rows only today; add contract **`TryClear(playerId, contract_completion, instanceId)`** when E7M4-06+ Bruno loops land — track on **NEO-149** | `deferred` | +| Postgres store key migration | Pre-NEO-148 quest keys **`{playerId}\0{questId}`** → **`{playerId}\0{sourceKind}\0{sourceId}`**; migrate or document at Postgres delivery persistence | `deferred` | ## Client counterpart diff --git a/docs/reviews/2026-06-27-NEO-148.md b/docs/reviews/2026-06-27-NEO-148.md index 8e3cc40..95db1c5 100644 --- a/docs/reviews/2026-06-27-NEO-148.md +++ b/docs/reviews/2026-06-27-NEO-148.md @@ -19,12 +19,12 @@ NEO-148 extends the E7.M2 reward delivery stack for contract completion: `Reward | `docs/plans/NEO-148-implementation-plan.md` | **Matches** — AC checklist marked complete; shipped reconciliation aligns with diff | | `docs/plans/E7M4-pre-production-backlog.md` (E7M4-05) | **Matches** — router + store extension, orchestrator/outcome store out of scope | | `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-148 router anchor added | -| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Partially matches** — store/router extended in code but module page still quest-centric (no NEO-148 cross-ref) | -| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E7.M4 row stops at NEO-147; NEO-148 not yet recorded | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-148 contract reuse anchor added | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row includes NEO-148 | | `server/README.md` | **Matches** — delivery store fields, `TryGet` overloads, contract router + reason codes | | Client counterpart (NEO-153) | **N/A** — explicitly out of scope; no prototype-complete claim | -Register/tracking table update recommended for E7.M4 after merge. +Register/tracking table updated for E7.M4 (NEO-148). ## Blocking issues @@ -32,13 +32,13 @@ None. ## Suggestions -1. **Update implementation alignment register** — Add **E7M4-05 / NEO-148 landed** to the E7.M4 row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` (router + source-kind store extension), consistent with NEO-144–NEO-147 entries. +1. ~~**Update implementation alignment register** — Add **E7M4-05 / NEO-148 landed** to the E7.M4 row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` (router + source-kind store extension), consistent with NEO-144–NEO-147 entries.~~ **Done.** -2. **Optional E7.M2 module cross-ref** — A one-line anchor on `E7_M2_RewardAndUnlockRouter.md` noting contract completion reuse via `TryDeliverContractCompletion` (NEO-148) would help readers who land on E7.M2 first. +2. ~~**Optional E7.M2 module cross-ref** — A one-line anchor on `E7_M2_RewardAndUnlockRouter.md` noting contract completion reuse via `TryDeliverContractCompletion` (NEO-148) would help readers who land on E7.M2 first.~~ **Done.** -3. **Store key format note for future Postgres** — Quest store keys changed from `{playerId}\0{questId}` to `{playerId}\0{sourceKind}\0{sourceId}`. Harmless for in-memory prototype (process restart clears state). When Postgres delivery persistence lands, document or migrate old rows explicitly so idempotent replay cannot double-grant after upgrade. +3. ~~**Store key format note for future Postgres** — Quest store keys changed from `{playerId}\0{questId}` to `{playerId}\0{sourceKind}\0{sourceId}`. Harmless for in-memory prototype (process restart clears state). When Postgres delivery persistence lands, document or migrate old rows explicitly so idempotent replay cannot double-grant after upgrade.~~ **Done.** (`server/README.md` + plan open questions) -4. **Contract fixture reset (follow-on)** — `QuestFixtureOperations` still clears quest rows via `TryClear(playerId, questId)` only. When E7M4-06+ adds Bruno contract loops, contract delivery rows will need a parallel reset path — track on NEO-149 or fixture story, not blocking here. +4. ~~**Contract fixture reset (follow-on)** — `QuestFixtureOperations` still clears quest rows via `TryClear(playerId, questId)` only. When E7M4-06+ adds Bruno contract loops, contract delivery rows will need a parallel reset path — track on NEO-149 or fixture story, not blocking here.~~ **Done.** (deferred to NEO-149 in plan open questions) ## Nits diff --git a/server/README.md b/server/README.md index d0e71a4..aa9b8d0 100644 --- a/server/README.md +++ b/server/README.md @@ -473,6 +473,8 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest and contract completion rewards use this store. Contract outcome audit rows live separately on **`IContractOutcomeStore`** (NEO-146). Postgres persistence deferred. Plans: [NEO-126](../../docs/plans/NEO-126-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md). +**Store key format (NEO-148):** in-memory rows are keyed by **`{playerId}\0{sourceKind}\0{sourceId}`** (pre-NEO-148 quest-only keys used **`{playerId}\0{questId}`**). Harmless for the in-memory prototype — process restart clears state. When Postgres delivery persistence lands, document or migrate legacy quest rows explicitly so idempotent replay cannot double-grant after upgrade. + ## Reward router (NEO-127) **`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows), **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), and **`ReputationOperations.TryApplyDelta`** with **`ReputationDeltaSourceKinds.QuestCompletion`** (reputation rows — NEO-138), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**.