neon-sprawl/docs/plans/NEO-138-implementation-plan.md

199 lines
14 KiB
Markdown

# 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<RewardReputationGrantApplied>`).
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.