# NEO-138 — E7M3-06: Extend RewardRouterOperations for reputationGrants **Linear:** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) **Branch:** `NEO-138-e7m3-reward-router-reputation-grants` **Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-06** **Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md) **Pattern:** [NEO-127](NEO-127-implementation-plan.md) — grant order + compensating rollback + `TryRecord` race mirror; [NEO-136](NEO-136-implementation-plan.md) — `ReputationOperations.TryApplyDelta` orchestration **Precursor:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) **`Done`** — `ReputationOperations.TryApplyDelta` (**landed on `main`**) **Blocks:** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) (HTTP quest projections for rep + gates) **Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD gate feedback (blocked by E7M3-07/E7M3-08) ## Goal Quest completion bundles apply **`reputationGrants`** idempotently alongside existing item and skill XP rows, using the same **`{playerId}:quest_complete:{questId}`** delivery key. ## Kickoff clarifications | Topic | Question | Agent recommendation | Answer | |-------|----------|----------------------|--------| | Bruno scope | NEO-137 deferred grid-contract accept-success Bruno; backlog lists standing Bruno as optional (no GET until NEO-139). | **Quest-flow Bruno only** — complete operator chain via HTTP quest flow, then accept grid contract success; defer standing GET assertions to NEO-139. | **Adopted** — quest-flow Bruno | Other decisions settled by Linear AC, backlog kickoff defaults, and repo precedent (no additional questions): | Topic | Decision | Evidence | |-------|----------|----------| | Grant order | **Items → skill XP → reputation**; deny before **`TryRecord`** rolls back all prior grant types | NEO-127 item→skill order; backlog “with item/skill XP rows” | | Idempotency | Same delivery key; **`TryGet`** replay returns stored snapshot without re-applying standing | Backlog kickoff: “Rep grants ride `{playerId}:quest_complete:{questId}`” | | Rep apply path | **`ReputationOperations.TryApplyDelta`** only — not standing store directly | NEO-136 README + NEO-136 plan | | Source attribution | **`ReputationDeltaSourceKinds.QuestCompletion`** + quest id as **`sourceId`** | NEO-136 adopted; server README | | **`deltaId`** | **`{idempotencyKey}:rep:{factionId}`** per grant row | NEO-136 open-questions table | | Unknown faction at runtime | **Deny delivery** via ops passthrough **`unknown_faction`**; no store record | Linear AC; startup cross-ref (NEO-134) prevents prod content errors | | **`TryRecord` race** | Roll back items, skill XP, **and** rep grants on loser (mirror NEO-127) | Backlog compensating rollback policy | | Rep rollback helper | Inverse delta via **`ReputationOperations`** with rollback **`deltaId`** suffix (e.g. `:rollback`) | NEO-136 “do not call standing store alone” | | HTTP standing / projections | **Out of scope** | E7M3-07 (NEO-139), E7M3-08 (NEO-140) | | Telemetry | **Comment-only `reputation_delta` hook** on successful rep apply in router (NEO-141 consolidates) | NEO-137 gate hook precedent | ## Scope and out-of-scope **In scope (from Linear + backlog):** - Extend **`RewardRouterOperations.TryDeliverQuestCompletion`** to apply **`bundle.ReputationGrants`** via **`ReputationOperations.TryApplyDelta`**. - Extend **`RewardDeliveryEvent`**, **`RewardDeliveryResult`**, and store snapshots with rep grant lines. - Add router reason-code passthrough for rep denies (**`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, etc.). - Compensating rollback: rep deny after items/xp applied; rep revert on **`TryRecord`** race loss. - Inject **`IFactionStandingStore`** + **`IReputationDeltaStore`** through **`TryDeliverQuestCompletion`**, **`QuestStateOperations.TryMarkComplete`**, **`QuestObjectiveWiring`**, and test helpers. - Integration tests: operator-chain completion → **+15** Grid Operators once; replay idempotent; unknown faction deny before store record. - **Quest-flow Bruno:** operator chain complete via HTTP + grid contract accept success (closes NEO-137 deferral). - `server/README.md` reward router section update. - E7.M3 module + alignment register updates when shipped. **Out of scope:** - HTTP GET standing (NEO-139), quest HTTP rep projections (NEO-140), Godot HUD (NEO-142). - E9.M1 telemetry ingest (NEO-141 documents hooks only). - **`QuestFixtureOperations`** reward delivery (fixture remains progress-only; Bruno success uses organic chain completion). - Postgres migration for **`RewardDeliveryEvent`** shape (in-memory store only today; rep snapshot fields additive). **Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — surfaces rep/gate feedback after NEO-139/NEO-140. ## Acceptance criteria checklist - [x] First-time quest completion applies rep grants exactly once per delivery key. - [x] Unknown faction id in bundle denies delivery before store record. - [x] `dotnet test` covers rep happy path, idempotent replay, unknown-faction deny, and rollback paths. ## Technical approach ### 1. Types (`Game/Rewards/`) | Type | Role | |------|------| | **`RewardReputationGrantApplied`** | Snapshot row: `FactionId`, `Amount` (applied delta amount from ops outcome) | | **`RewardDeliveryReasonCodes`** (extend) | Passthrough: **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** from rep ops | Extend **`RewardDeliveryEvent`** and **`RewardDeliveryResult`** with **`GrantedReputation`** (`IReadOnlyList`). Add **`RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, factionId)`** → **`{key}:rep:{factionId}`**. ### 2. `RewardRouterOperations.TryDeliverQuestCompletion` **Signature additions:** `IFactionStandingStore standingStore`, `IReputationDeltaStore auditStore`. **Flow (after existing skill XP loop, before `TryRecord`):** 1. Read **`reputationGrants`** from bundle (empty ⇒ skip). 2. For each row, call **`ReputationOperations.TryApplyDelta`** with: - `sourceKind` = **`ReputationDeltaSourceKinds.QuestCompletion`** - `sourceId` = normalized quest id - `deltaId` = **`MakeReputationDeltaId(idempotencyKey, factionId)`** 3. On any rep deny: **`CompensatingRevertAll`** — remove items, revert skill XP (existing helpers), revert applied rep rows via inverse **`TryApplyDelta`** with `:rollback` delta ids. 4. Build **`RewardDeliveryEvent`** including **`GrantedReputation`** snapshot. 5. On **`TryRecord`** race loss: extend existing rollback to include rep revert (same compensating helper). **Idempotent replay:** unchanged — early **`TryGet`** returns stored event including rep snapshot; no standing mutation. **Empty bundle:** still records empty rep array when **`TryRecord`** succeeds (mirror empty item/skill arrays). ### 3. Wiring | Call site | Change | |-----------|--------| | **`QuestStateOperations.TryMarkComplete`** | Pass standing + audit stores to router | | **`QuestObjectiveWiring`** | Thread stores into **`TryMarkComplete`** (terminal step + bulk complete paths) | | Test helpers | **`QuestTestDependencies`**, **`QuestWiringTestDependencies`**, **`RewardRouterTestDependencies`** — resolve faction stores from factory | ### 4. Prototype test scenarios | Scenario | Setup | Expected | |----------|-------|----------| | Operator chain first complete | Prereqs done; accept chain; **`TryMarkComplete`** | Standing **+15** Grid Operators; delivery event rep snapshot; audit row queryable | | Idempotent replay | Second **`TryDeliverQuestCompletion`** | **`already_delivered`**; standing unchanged | | Unknown faction | Test bundle with fake **`factionId`** | **`unknown_faction`** deny; no delivery store record; items/xp unchanged if rep-only bundle | | Rep deny after items | Mock/inject audit append failure on second rep row of multi-row bundle (unit) | Items + skill + first rep rolled back | | Grid contract gate | Complete operator chain (applies +15); accept grid contract | Accept succeeds (integration — extends NEO-137 gate success test without manual standing seed) | Constants: **`PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles`** operator chain rep row; **`GridContractMinStanding`** = 15. ### 5. Bruno (quest-flow) | Request | Role | |---------|------| | **`Complete operator chain quest flow.bru`** (or pre-request helper in **`bruno-dev-fixture-helper.js`**) | Reset player; complete gather/refine/combat intros; accept + finish operator chain via HTTP (gather/craft/encounter wiring) so completion delivers **+15** rep organically | | **`Accept grid contract after operator chain.bru`** | POST accept **`prototype_quest_grid_contract`** ⇒ **`accepted: true`** (implies standing ≥ 15 from chain rep grant) | No standing GET assertions until NEO-139. Update deny Bruno docs to note success sibling at seq **11+**. ### 6. Tests **`RewardRouterOperationsTests`** (extend): | Test | Covers | |------|--------| | `TryDeliverQuestCompletion_ShouldApplyReputationGrants_WhenOperatorChainBundle` | +15 standing; rep snapshot on event; audit row | | `TryDeliverQuestCompletion_ShouldNotDoubleApplyReputation_WhenAlreadyDelivered` | Idempotent replay | | `TryDeliverQuestCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord` | Fake faction in test bundle | | `TryDeliverQuestCompletion_ShouldRollbackItemsAndSkillXp_WhenReputationApplyDenied` | Rep deny mid-bundle | | Extend race-rollback tests | Rep reverted on **`TryRecord`** loser | **`QuestStateOperationsTests`** (extend): | Test | Covers | |------|--------| | Extend `TryMarkComplete_ShouldDeliverOperatorChainBundle_WhenFirstCompletion` | Assert **`GrantedReputation`** + standing **15** | | `TryAccept_ShouldAcceptGridContract_WhenOperatorChainCompletedWithRepGrant` | End-to-end: complete chain via ops (no manual **`SeedGridStanding`**) | Manual QA: **none** — server engine; Bruno quest-flow smoke replaces player-visible checklist until NEO-142. ## Files to add | Path | Purpose | |------|---------| | `server/NeonSprawl.Server/Game/Rewards/RewardReputationGrantApplied.cs` | Rep grant snapshot row on delivery event | | `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` | Grid contract accept success after organic chain completion | | `bruno/neon-sprawl-server/quest-progress/scripts/operator-chain-quest-flow-helper.js` (or extend **`bruno-dev-fixture-helper.js`**) | HTTP helper to complete operator chain quest flow for Bruno pre-requests | ## Files to modify | Path | Rationale | |------|-----------| | `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | Apply rep grants; compensating rollback; inject faction stores | | `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` | Add **`GrantedReputation`** field | | `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs` | Add **`GrantedReputation`** field | | `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs` | Rep deny passthrough constants | | `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs` | **`MakeReputationDeltaId`** helper | | `server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs` | Clone **`GrantedReputation`** array on record | | `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Pass standing + audit stores to router in **`TryMarkComplete`** | | `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Thread stores into **`TryMarkComplete`** call sites | | `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | Rep grant AAA tests; extend **`Deliver`** helper + deps | | `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Operator chain rep assertions; grid contract accept without manual seed | | `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Update **`TryMarkComplete`** helper signature | | `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Update **`TryMarkComplete`** helper signature | | `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | Update any **`TryMarkComplete`** call sites if present | | `server/README.md` | Document rep grant apply order, rollback, reason codes, Bruno seq | | `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status: E7M3-06 in progress / landed | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-138 plan + shipped router extension | | `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` | Docs: success sibling request added in NEO-138 | ## Tests | File | Action | Coverage | |------|--------|----------| | `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | **Change** | Rep happy path, idempotent replay, unknown faction deny, rep-failure rollback, race rep rollback | | `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | **Change** | Operator chain +15 standing; grid contract accept after chain completion | | `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | **Change** | Helper signature only (compile fix) | | `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | **Change** | Helper signature only (compile fix) | No new Postgres integration tests — rep persistence covered by NEO-135/NEO-136; router tests use in-memory stores. ## Open questions / risks | Question / risk | Agent recommendation | Status | |-----------------|----------------------|--------| | Operator chain Bruno weight | Encapsulate in shared JS helper; reuse craft/gather/encounter patterns from existing spines | `adopted` | | Rollback audit rows on compensating rep revert | Prototype: inverse **`TryApplyDelta`** with rollback **`deltaId`**; no delete of failed-forward audit row if append succeeded then race — mirror skill XP revert policy | `adopted` | | **`completionRewardSummary`** rep fields | **Defer to NEO-140** — Bruno success asserts accept only, not summary shape | `adopted` | | Concurrent rep + standing reads during gate eval | Out of scope — accept gate reads standing before chain completion rep in separate requests | `deferred` | **NEO-140** (HTTP quest projections) and **NEO-141** (telemetry README) follow this router extension landing.