Merge pull request #189 from ViPro-Technologies/NEO-148-e7m4-05-reward-router-contract-completion
NEO-148: E7M4-05 contract completion reward routerpull/191/head
commit
36ffec94e9
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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+.
|
||||
|
|
@ -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-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.~~ **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).
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = "");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue