Merge pull request #189 from ViPro-Technologies/NEO-148-e7m4-05-reward-router-contract-completion

NEO-148: E7M4-05 contract completion reward router
pull/191/head
VinPropane 2026-06-27 21:41:26 -04:00 committed by GitHub
commit 36ffec94e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 689 additions and 55 deletions

View File

@ -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).

View File

@ -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.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,172 @@
# 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
- [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
### 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` |
| 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
[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+.

View File

@ -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` | **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 updated for E7.M4 (NEO-148).
## 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-144NEO-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.~~ **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.~~ **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.~~ **Done.** (deferred to NEO-149 in plan open questions)
## 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).

View File

@ -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,

View File

@ -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;
}
/// <summary>Second forward rep audit append fails; rollback appends still succeed.</summary>

View File

@ -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";
}

View File

@ -1,16 +1,20 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Idempotent per-player quest completion reward delivery record (NEO-126).</summary>
/// <summary>Idempotent per-player reward delivery records (NEO-126).</summary>
public interface IRewardDeliveryStore
{
/// <summary>First record returns <c>true</c>; replays for the same player+quest return <c>false</c>.</summary>
/// <summary>First record returns <c>true</c>; replays for the same player+source return <c>false</c>.</summary>
bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent);
/// <summary>
/// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset.
/// Idempotent when the row is already absent.
/// </summary>
bool TryClear(string playerId, string questId);
bool TryClear(string playerId, string sourceKind, string sourceId);
}

View File

@ -2,7 +2,7 @@ using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Thread-safe in-memory quest completion reward delivery records (NEO-126).</summary>
/// <summary>Thread-safe in-memory reward delivery records (NEO-126).</summary>
public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
{
private readonly ConcurrentDictionary<string, RewardDeliveryEvent> eventsByKey = new(StringComparer.Ordinal);
@ -12,7 +12,7 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
/// <inheritdoc />
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
}
/// <inheritdoc />
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);
/// <inheritdoc />
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
}
}
/// <summary>Dev fixture only — removes one player+quest delivery row (Bruno / test reset).</summary>
public bool TryClear(string playerId, string questId)
/// <inheritdoc />
public bool TryClear(string playerId, string questId) =>
TryClear(playerId, RewardDeliverySourceKinds.QuestCompletion, questId);
/// <summary>Dev fixture only — removes one player+source delivery row (Bruno / test reset).</summary>
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;

View File

@ -1,9 +1,9 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>
/// Idempotent quest completion reward delivery record (NEO-126).
/// Idempotent reward delivery record (NEO-126).
/// Persisted via <see cref="IRewardDeliveryStore"/> after router apply succeeds (NEO-127).
/// E9.M1 <c>reward_delivery</c> hook site: <see cref="RewardRouterOperations"/> <c>TryDeliverQuestCompletion</c> (NEO-130).
/// E9.M1 <c>reward_delivery</c> hook site: <see cref="RewardRouterOperations"/> (NEO-130).
/// </summary>
public readonly record struct RewardDeliveryEvent(
string PlayerId,
@ -12,4 +12,6 @@ public readonly record struct RewardDeliveryEvent(
IReadOnlyList<RewardItemGrantApplied> GrantedItems,
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
IReadOnlyList<RewardReputationGrantApplied> GrantedReputation,
string IdempotencyKey);
string IdempotencyKey,
string SourceKind = RewardDeliverySourceKinds.QuestCompletion,
string ContractInstanceId = "");

View File

@ -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
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId);
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeContractInstanceId(string? contractInstanceId) =>
ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
/// <summary>Stable idempotency key for quest completion delivery.</summary>
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";
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="questId"/>.</summary>
public static string MakeStoreKey(string? playerId, string? questId)
/// <summary>Composite store key for quest completion delivery.</summary>
public static string MakeStoreKey(string? playerId, string? questId) =>
MakeStoreKey(playerId, RewardDeliverySourceKinds.QuestCompletion, questId);
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="sourceKind"/> + source id.</summary>
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}";
}
/// <summary>Resolves the source id field for a persisted delivery event.</summary>
public static string GetSourceId(in RewardDeliveryEvent deliveryEvent) =>
string.Equals(deliveryEvent.SourceKind, RewardDeliverySourceKinds.ContractCompletion, StringComparison.Ordinal)
? NormalizeContractInstanceId(deliveryEvent.ContractInstanceId)
: NormalizeQuestId(deliveryEvent.QuestId);
/// <summary>Composite store key for a persisted delivery event.</summary>
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);
}

View File

@ -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";

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Stable source-kind values for <see cref="RewardDeliveryEvent"/> (NEO-148).</summary>
public static class RewardDeliverySourceKinds
{
public const string QuestCompletion = "quest_completion";
public const string ContractCompletion = "contract_completion";
}

View File

@ -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;
/// <summary>
/// Applies quest <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127).
/// Applies quest and contract <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127, NEO-148).
/// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128).
/// Reputation grants: <see cref="ReputationOperations"/> (NEO-138).
/// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (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));
}
/// <summary>
/// Applies <paramref name="bundle"/> item, skill XP, and reputation rows for a contract completion and records
/// <see cref="RewardDeliveryEvent"/> when grants succeed (NEO-148).
/// </summary>
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<RewardReputationGrantApplied>(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<RewardItemGrantApplied> appliedItems,
IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp,
HashSet<string> 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<RewardReputationGrantApplied> 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);
}

View File

@ -451,39 +451,47 @@ 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).
**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`**.
**`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)