14 KiB
NEO-138 — E7M3-06: Extend RewardRouterOperations for reputationGrants
Linear: NEO-138
Branch: NEO-138-e7m3-reward-router-reputation-grants
Backlog: E7M3-pre-production-backlog.md — E7M3-06
Module: E7_M3_FactionReputationLedger.md
Pattern: NEO-127 — grant order + compensating rollback + TryRecord race mirror; NEO-136 — ReputationOperations.TryApplyDelta orchestration
Precursor: NEO-136 Done — ReputationOperations.TryApplyDelta (landed on main)
Blocks: NEO-140 (HTTP quest projections for rep + gates)
Client counterpart: 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.TryDeliverQuestCompletionto applybundle.ReputationGrantsviaReputationOperations.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
TryRecordrace loss. - Inject
IFactionStandingStore+IReputationDeltaStorethroughTryDeliverQuestCompletion,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.mdreward 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).
QuestFixtureOperationsreward delivery (fixture remains progress-only; Bruno success uses organic chain completion).- Postgres migration for
RewardDeliveryEventshape (in-memory store only today; rep snapshot fields additive).
Client counterpart: NEO-142 — surfaces rep/gate feedback after NEO-139/NEO-140.
Acceptance criteria checklist
- First-time quest completion applies rep grants exactly once per delivery key.
- Unknown faction id in bundle denies delivery before store record.
dotnet testcovers 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):
- Read
reputationGrantsfrom bundle (empty ⇒ skip). - For each row, call
ReputationOperations.TryApplyDeltawith:sourceKind=ReputationDeltaSourceKinds.QuestCompletionsourceId= normalized quest iddeltaId=MakeReputationDeltaId(idempotencyKey, factionId)
- On any rep deny:
CompensatingRevertAll— remove items, revert skill XP (existing helpers), revert applied rep rows via inverseTryApplyDeltawith:rollbackdelta ids. - Build
RewardDeliveryEventincludingGrantedReputationsnapshot. - On
TryRecordrace 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.