diff --git a/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru b/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru new file mode 100644 index 0000000..6204585 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru @@ -0,0 +1,41 @@ +meta { + name: Accept grid contract after operator chain + type: http + seq: 11 +} + +docs { + NEO-138: complete operator chain via HTTP quest flow (rep grant +15 Grid Operators), then accept prototype_quest_grid_contract succeeds without manual standing seed. + Pre-request runs operator-chain-quest-flow-helper (gather/combat/refine intros + chain steps). No standing GET until NEO-139. + Accept auto-completes: operator-chain reward already granted survey_drone_kit, satisfying grid contract inventory_has_item objective on activation. +} + +script:pre-request { + const { completeOperatorChainQuestFlow } = require("./scripts/operator-chain-quest-flow-helper"); + await completeOperatorChainQuestFlow(bru); +} + +post { + url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_grid_contract/accept + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1 + } +} + +tests { + test("accepts grid contract when operator chain rep grant met minStanding", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.accepted).to.equal(true); + expect(body.reasonCode).to.equal(undefined); + expect(body.quest).to.be.an("object"); + expect(body.quest.questId).to.equal("prototype_quest_grid_contract"); + expect(body.quest.status).to.equal("completed"); + }); +} diff --git a/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru b/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru index 0f7d7d0..7cbfb3c 100644 --- a/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru +++ b/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru @@ -6,8 +6,8 @@ meta { docs { NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15. - Pre-request marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing stays 0). - Success Bruno deferred to NEO-138 when operator-chain rep grants land. + Pre-request resets grid contract progress and prototype faction standing (stale state from prior seq 11 runs), then marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing 0). Quest fixture resetQuestIds also clears reward delivery + audit rows for reset quests. + Success sibling: seq 11 Accept grid contract after operator chain (NEO-138 organic rep grant). } script:pre-request { @@ -16,11 +16,18 @@ script:pre-request { const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; const chainQuestId = "prototype_quest_operator_chain"; + const gridContractQuestId = "prototype_quest_grid_contract"; + const prototypeFactionIds = [ + "prototype_faction_grid_operators", + "prototype_faction_rust_collective", + ]; const fixture = await axios.post( `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, { schemaVersion: 1, + resetQuestIds: [gridContractQuestId], + resetFactionIds: prototypeFactionIds, completedQuestIds: [chainQuestId], }, { ...jsonHeaders, validateStatus: () => true }, diff --git a/bruno/neon-sprawl-server/scripts/operator-chain-quest-flow-helper.js b/bruno/neon-sprawl-server/scripts/operator-chain-quest-flow-helper.js new file mode 100644 index 0000000..cef9230 --- /dev/null +++ b/bruno/neon-sprawl-server/scripts/operator-chain-quest-flow-helper.js @@ -0,0 +1,227 @@ +const axios = require("axios"); +const { + resetAllPrototypeQuestProgress, + resetPrototypeResourceNodes, +} = require("./bruno-dev-fixture-helper"); +const { resetPrototypeCombatTargets } = require("./combat-targets-reset-helper"); +const { clearInventory, sumItemQuantity, getInventory } = require("./inventory-api-helper"); +const { + moveNearPrototypeNpc, + PULSE_COOLDOWN_MS, + MAX_PULSE_DEFEAT_ATTEMPTS, + sleep, +} = require("./prototype-npc-bruno-helper"); + +const GATHER_QUEST_ID = "prototype_quest_gather_intro"; +const COMBAT_QUEST_ID = "prototype_quest_combat_intro"; +const REFINE_QUEST_ID = "prototype_quest_refine_intro"; +const CHAIN_QUEST_ID = "prototype_quest_operator_chain"; +const DELTA_NODE_ID = "prototype_urban_bulk_delta"; +const CAST_SLOT_INDEX = 3; + +function resolveConfig(bru) { + return { + baseUrl: bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"), + playerId: bru.getEnvVar("playerId") || bru.getVar("playerId"), + jsonHeaders: { headers: { "Content-Type": "application/json" } }, + }; +} + +async function getQuestProgress(bru) { + const { baseUrl, playerId } = resolveConfig(bru); + const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, { + validateStatus: () => true, + }); + if (response.status !== 200) { + throw new Error(`quest-progress GET failed: ${response.status}`); + } + return response.data; +} + +function questRow(body, questId) { + const row = body.quests.find((x) => x.questId === questId); + if (!row) { + throw new Error(`missing quest row ${questId}: ${JSON.stringify(body.quests)}`); + } + return row; +} + +async function acceptQuest(bru, questId) { + const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/quests/${questId}/accept`, + { schemaVersion: 1 }, + { ...jsonHeaders, validateStatus: () => true }, + ); + if (response.status !== 200 || response.data?.accepted !== true) { + throw new Error(`accept ${questId} failed: ${response.status} ${JSON.stringify(response.data)}`); + } +} + +async function moveNear(bru, x, z) { + const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { schemaVersion: 1, target: { x, y: 0.9, z } }, + jsonHeaders, + ); +} + +async function tryGather(bru, interactableId) { + const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/interact`, + { schemaVersion: 1, interactableId }, + { ...jsonHeaders, validateStatus: () => true }, + ); + if (response.status !== 200) { + throw new Error(`gather ${interactableId} HTTP ${response.status}`); + } + if (response.data?.allowed === true || response.data?.reasonCode === "node_depleted") { + return; + } + if (response.data?.reasonCode === "out_of_range") { + throw new Error(`gather ${interactableId} out_of_range — moveNear before interact`); + } + throw new Error(`gather ${interactableId} denied: ${JSON.stringify(response.data)}`); +} + +async function craft(bru, recipeId) { + const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/craft`, + { schemaVersion: 1, recipeId }, + { ...jsonHeaders, validateStatus: () => true }, + ); + if (response.status !== 200 || response.data?.success !== true) { + throw new Error(`craft ${recipeId} failed: ${response.status} ${JSON.stringify(response.data)}`); + } +} + +async function defeatNpc(bru, targetId) { + const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); + await moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders); + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId }, + jsonHeaders, + ); + await sleep(bru, PULSE_COOLDOWN_MS); + const castBody = { + schemaVersion: 1, + slotIndex: CAST_SLOT_INDEX, + abilityId: "prototype_pulse", + targetId, + }; + for (let i = 0; i < MAX_PULSE_DEFEAT_ATTEMPTS; i += 1) { + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + castBody, + jsonHeaders, + ); + const body = response.data; + if (!body?.accepted || !body?.combatResolution) { + throw new Error( + `cast ${i + 1} vs ${targetId} failed (${body?.reasonCode ?? "no_reason"}): ${JSON.stringify(body)}`, + ); + } + if (body.combatResolution.targetDefeated) { + return; + } + if (i < MAX_PULSE_DEFEAT_ATTEMPTS - 1) { + await sleep(bru, PULSE_COOLDOWN_MS); + } + } + throw new Error(`did not defeat ${targetId} within ${MAX_PULSE_DEFEAT_ATTEMPTS} pulses`); +} + +async function completeGatherIntro(bru) { + await acceptQuest(bru, GATHER_QUEST_ID); + await moveNear(bru, 10, -6); + for (let i = 0; i < 3; i += 1) { + await tryGather(bru, "prototype_resource_node_alpha"); + } + const progress = await getQuestProgress(bru); + const row = questRow(progress, GATHER_QUEST_ID); + if (row.status !== "completed") { + throw new Error(`gather intro not completed: ${JSON.stringify(row)}`); + } +} + +async function completeCombatIntro(bru) { + const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); + await acceptQuest(bru, COMBAT_QUEST_ID); + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex: CAST_SLOT_INDEX, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + await defeatNpc(bru, "prototype_npc_melee"); + await defeatNpc(bru, "prototype_npc_ranged"); + await defeatNpc(bru, "prototype_npc_elite"); + const progress = await getQuestProgress(bru); + const row = questRow(progress, COMBAT_QUEST_ID); + if (row.status !== "completed") { + throw new Error(`combat intro not completed: ${JSON.stringify(row)}`); + } +} + +async function completeRefineIntro(bru) { + await acceptQuest(bru, REFINE_QUEST_ID); + const scrap = sumItemQuantity(await getInventory(bru), "scrap_metal_bulk"); + if (scrap < 5) { + throw new Error(`refine intro needs >=5 scrap, have ${scrap}`); + } + await craft(bru, "refine_scrap_standard"); + const progress = await getQuestProgress(bru); + const row = questRow(progress, REFINE_QUEST_ID); + if (row.status !== "completed") { + throw new Error(`refine intro not completed: ${JSON.stringify(row)}`); + } +} + +async function completeOperatorChain(bru) { + await acceptQuest(bru, CHAIN_QUEST_ID); + await moveNear(bru, 16, -2); + await tryGather(bru, DELTA_NODE_ID); + let progress = await getQuestProgress(bru); + let row = questRow(progress, CHAIN_QUEST_ID); + if (row.status !== "active" || row.currentStepIndex !== 1) { + throw new Error(`chain step 1 not advanced: ${JSON.stringify(row)}`); + } + await craft(bru, "refine_scrap_standard"); + progress = await getQuestProgress(bru); + row = questRow(progress, CHAIN_QUEST_ID); + if (row.status !== "active" || row.currentStepIndex !== 2) { + throw new Error(`chain step 2 not advanced: ${JSON.stringify(row)}`); + } + await craft(bru, "make_field_stim_mk0"); + progress = await getQuestProgress(bru); + row = questRow(progress, CHAIN_QUEST_ID); + if (row.status !== "completed") { + throw new Error(`operator chain not completed: ${JSON.stringify(row)}`); + } +} + +/** + * NEO-138: organic operator-chain completion via HTTP (gather/craft/encounter wiring). + * Applies +15 Grid Operators rep on chain completion reward delivery. + */ +async function completeOperatorChainQuestFlow(bru) { + await resetPrototypeResourceNodes(bru); + await clearInventory(bru); + await resetPrototypeCombatTargets(bru); + await resetAllPrototypeQuestProgress(bru); + await completeGatherIntro(bru); + await completeCombatIntro(bru); + await completeRefineIntro(bru); + await completeOperatorChain(bru); +} + +module.exports = { + completeOperatorChainQuestFlow, + CHAIN_QUEST_ID, +}; diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md index 21f35aa..f4c564d 100644 --- a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md +++ b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md @@ -7,7 +7,7 @@ | **Module ID** | E7.M3 | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Stage target** | Pre-production | -| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-06** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | +| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-07** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) | ## Purpose diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 0594edf..8eae8eb 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -61,7 +61,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable 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); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-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/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | -| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06+** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | +| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07+** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | --- diff --git a/docs/plans/NEO-138-implementation-plan.md b/docs/plans/NEO-138-implementation-plan.md new file mode 100644 index 0000000..38dbdd6 --- /dev/null +++ b/docs/plans/NEO-138-implementation-plan.md @@ -0,0 +1,198 @@ +# 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. diff --git a/docs/reviews/2026-06-15-NEO-138.md b/docs/reviews/2026-06-15-NEO-138.md new file mode 100644 index 0000000..1c30c06 --- /dev/null +++ b/docs/reviews/2026-06-15-NEO-138.md @@ -0,0 +1,84 @@ +# Code review — NEO-138 (E7M3-06) + +**Date:** 2026-06-15 +**Scope:** Branch `NEO-138-e7m3-reward-router-reputation-grants` — commits `599d87b` … `69c3ad4` vs `main` +**Base:** `main` +**Follow-up:** Re-reviewed after `69c3ad4` (code-review follow-ups). Prior blocking/suggestions verified in code; **810** tests green. + +## Verdict + +**Approve** + +## Summary + +This branch lands **E7M3-06**: **`RewardRouterOperations.TryDeliverQuestCompletion`** applies **`reputationGrants`** via **`ReputationOperations.TryApplyDelta`** after items and skill XP, with compensating rollback (including inverse rep deltas with `:rollback` suffix) and **`GrantedReputation`** on delivery event/result snapshots. Wiring threads **`IFactionStandingStore`** + **`IReputationDeltaStore`** through quest state, objective wiring, and gameplay APIs. Tests cover happy path, idempotent replay, unknown-faction deny with item/skill rollback, and quest-state integration (operator chain +15 standing, grid contract accept after organic chain completion). Bruno adds **`operator-chain-quest-flow-helper.js`** and **`Accept grid contract after operator chain.bru`**. **`810`** server tests pass locally. Core router logic matches NEO-127/NEO-136 precedent and the implementation plan. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-138-implementation-plan.md` | **Matches** — router apply order, idempotency, rollback, wiring, README, module/alignment updates, Bruno quest-flow, and plan-listed tests (including race rep rollback and audit-row assertion). | +| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-06 rep grants on quest completion bundle via router; HTTP standing / client HUD explicitly deferred. | +| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-06 landed (NEO-138); rep flow through E7.M2 router documented. | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — bundle apply + idempotent delivery store extended with rep rows (implicit via router). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for NEO-138 router extension. | +| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc status. | +| `server/README.md` | **Matches** — reward router section documents rep apply order, reason codes, rollback policy, Bruno seq **11** success sibling, telemetry hook comment for **`reputation_delta`**. | + +## Blocking issues + +1. ~~**Bruno success smoke asserts wrong quest status after accept.** `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` expects `body.quest.status === "active"` and `currentStepIndex === 0`. After organic operator-chain completion the player receives **`survey_drone_kit`** in the completion bundle. **`prototype_quest_grid_contract`** has a single **`inventory_has_item`** objective for that item. **`QuestStateOperations.TryAccept`** runs **`QuestObjectiveWiring.TryCompleteStepIfSatisfied`** immediately after activation, so accept auto-completes the quest and returns **`completed`** (integration test `TryAccept_ShouldAcceptGridContract_WhenOperatorChainCompletedWithRepGrant` correctly asserts **`QuestProgressStatus.Completed`**). CI runs the full quest-progress collection with **`--bail`**; seq **11** will fail on the status assertion even when the gate and rep grant work. Fix: expect **`completed`** (and optionally assert Rust Collective +10 standing or delivery event), or change content/fixture so the kit is not held at accept time if **`active`** is the intended smoke signal.~~ **Done.** Bruno expects **`completed`**; docs note auto-complete when kit already held. + +## Suggestions + +1. ~~**Add rep rollback on `TryRecord` race loss.** Extend `TryDeliverQuestCompletion_ShouldRollbackGrants_WhenTryRecordLosesRace` (or add a sibling) with an operator-chain bundle including **`reputationGrants`** and assert standing returns to pre-call value after simulated race loss — listed explicitly in NEO-138 plan §6 tests table.~~ **Done.** `TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace`. +2. ~~**Assert audit row on operator-chain router happy path.** Plan scenario “audit row queryable” — add `IReputationDeltaStore` lookup for `{idempotencyKey}:rep:{factionId}` in `TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle` (or the dedicated rep test).~~ **Done.** Audit row asserted in operator-chain bundle test. +3. ~~**Document auto-complete on grid-contract accept in tests/README.** Integration test name says “Accept” but the meaningful gate check is standing ≥ 15; the returned snapshot is **`completed`** because the kit is already in inventory. A one-line comment in `QuestStateOperationsTests` and Bruno **docs** block would prevent future “active vs completed” confusion.~~ **Done.** Comment in `QuestStateOperationsTests`; Bruno docs block updated. +4. ~~**Optional: multi-row rep deny rollback test.** Plan lists rep deny after items with audit append failure on the second rep row; only **`unknown_faction`** rollback is covered today. Consider a store stub that fails **`TryAppend`** on the second row to lock compensating rollback across grant types.~~ **Done.** `TryDeliverQuestCompletion_ShouldRollbackAllGrants_WhenSecondReputationAuditAppendFails` with forward-rep-only audit stub. +5. ~~**Bugbot: fixture reset skips delivery store.** `resetQuestIds` cleared quest progress and faction standing but not **`IRewardDeliveryStore`** / audit rows, so repeated Bruno runs skipped rep re-apply via idempotent delivery (and duplicate audit ids).~~ **Done.** `resetQuestIds` clears delivery + quest-completion audit rows. + +## Nits + +- ~~Nit: `TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull` does not assert `GrantedReputation` is empty — mirror item/skill empty assertions for snapshot parity.~~ **Done.** +- Nit: `CompensatingRevertReputation` skips `Amount == 0` rows; fine for prototype bundles but a clamp-to-zero edge case could leave standing changed while snapshot says 0 — defer unless content adds negative grants near floor. +- Nit (re-review): `server/README.md` FactionGateOperations section still says grid-contract success Bruno is “deferred until NEO-138” — stale; seq **11** landed. One-line README tweak optional before merge. +- Nit (re-review): `TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace` asserts standing only on a full operator-chain bundle; item/skill rollback on the same path is covered by the older skill-only race test + shared `CompensatingRevertAll` — acceptable, not blocking. + +## Re-review (2026-06-15) + +| Original finding | Verified | +|------------------|----------| +| Bruno seq **11** status `active` vs `completed` | **Fixed** — `Accept grid contract after operator chain.bru` expects `completed`; docs note auto-complete when kit held. | +| Race rep rollback test | **Fixed** — `TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace`. | +| Audit row on operator-chain happy path | **Fixed** — delta id + row fields asserted in bundle test. | +| Grid-contract accept comment | **Fixed** — `QuestStateOperationsTests` Arrange comment + Bruno docs. | +| Multi-row audit-append rollback | **Fixed** — `AuditStoreFailingOnSecondForwardRepAppend` + full grant rollback test. | +| Empty `GrantedReputation` on null bundle | **Fixed** — result + stored event asserted. | + +No new blocking issues from follow-up commit. Test stubs (`AuditStoreFailingOnSecondForwardRepAppend`, race-loss store) are sound; AAA layout on new tests matches project conventions. + +## Verification + +```bash +# Full server suite (810 green locally) +dotnet test NeonSprawl.sln + +# NEO-138-focused filter +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~RewardRouterOperations|FullyQualifiedName~GridContract|FullyQualifiedName~OperatorChain" + +# Bruno quest-progress (seq 11 expects completed after kit held) +cd bruno/neon-sprawl-server +npx --yes @usebruno/cli@3.3.0 run quest-progress --env Local --sandbox=developer --bail +``` + +With Postgres + dev fixtures (matches CI): + +```bash +ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev' \ +Game__EnableCombatTargetFixtureApi=true \ +Game__EnableQuestFixtureApi=true \ +Game__EnableResourceNodeFixtureApi=true \ +dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj --urls http://127.0.0.1:5253 +``` + +Manual: run **`Accept grid contract after operator chain.bru`**; confirm **`accepted: true`** and quest row **`completed`** when **`survey_drone_kit`** is already in bag. diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs index 5ecc9ec..cee7760 100644 --- a/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -38,6 +39,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -84,6 +87,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -121,6 +126,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -160,6 +167,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -196,6 +205,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -233,6 +244,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -267,6 +280,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -297,6 +312,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -330,6 +347,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -365,6 +384,8 @@ public sealed class CraftOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -415,7 +436,9 @@ public sealed class CraftOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -454,7 +477,9 @@ public sealed class CraftOperationsTests IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore, IPlayerPerkStateStore PerkStore, - IRewardDeliveryStore DeliveryStore); + IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore); private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs index 95741a4..3361d16 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -46,6 +47,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -83,6 +86,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -124,6 +129,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -163,6 +170,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -195,6 +204,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var before); @@ -219,6 +230,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -257,6 +270,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -304,6 +319,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -348,6 +365,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -376,6 +395,8 @@ public sealed class EncounterCombatWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); } @@ -432,7 +453,9 @@ public sealed class EncounterCombatWiringTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -464,5 +487,7 @@ public sealed class EncounterCombatWiringTests ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IPlayerPerkStateStore PerkStore, - IRewardDeliveryStore DeliveryStore); + IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore); } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs index 4441b24..3f2d1fd 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -46,6 +47,8 @@ public sealed class EncounterCompletionOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, timeProvider); // Assert @@ -95,6 +98,8 @@ public sealed class EncounterCompletionOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, timeProvider); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst); @@ -117,6 +122,8 @@ public sealed class EncounterCompletionOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, timeProvider); // Assert @@ -181,6 +188,8 @@ public sealed class EncounterCompletionOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -221,6 +230,8 @@ public sealed class EncounterCompletionOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -263,6 +274,8 @@ public sealed class EncounterCompletionOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -303,6 +316,8 @@ public sealed class EncounterCompletionOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -341,6 +356,8 @@ public sealed class EncounterCompletionOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -409,7 +426,9 @@ public sealed class EncounterCompletionOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -455,7 +474,9 @@ public sealed class EncounterCompletionOperationsTests ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IPlayerPerkStateStore PerkStore, - IRewardDeliveryStore DeliveryStore); + IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore); private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs index 1afc3ca..f418cb6 100644 --- a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs @@ -134,6 +134,20 @@ public sealed class InMemoryFactionStandingStoreTests Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing); } + [Fact] + public void TryClearStanding_ShouldReturnZeroRead_WhenRowRemoved() + { + // Arrange + var store = CreateStore(); + store.SeedRawStandingForTests(PlayerId, GridFactionId, 15); + Assert.Equal(15, store.TryGetStanding(PlayerId, GridFactionId).Standing); + // Act + var cleared = store.TryClearStanding(PlayerId, GridFactionId); + // Assert + Assert.True(cleared); + Assert.Equal(0, store.TryGetStanding(PlayerId, GridFactionId).Standing); + } + [Fact] public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown() { diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs index 0ba538c..3f2a3d9 100644 --- a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs @@ -102,6 +102,31 @@ public sealed class InMemoryReputationDeltaStoreTests Assert.Equal("delta-1", rows[0].Id); } + [Fact] + public void TryClearQuestCompletionAudit_ShouldRemoveMatchingRows_WhenPresent() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryAppend(CreateRow("delta-forward", 15, 15))); + Assert.True(store.TryAppend(CreateRow("delta-rollback", -15, 0) with + { + Id = "delta-forward:rollback", + })); + Assert.True(store.TryAppend(CreateRow("delta-other-quest", 10, 10) with + { + Id = "other-quest-delta", + SourceId = "prototype_quest_gather_intro", + })); + // Act + var cleared = store.TryClearQuestCompletionAudit(PlayerId, "prototype_quest_operator_chain"); + // Assert + Assert.True(cleared); + Assert.DoesNotContain( + store.GetDeltasForPlayer(PlayerId), + row => row.SourceId == "prototype_quest_operator_chain"); + Assert.Single(store.GetDeltasForPlayer(PlayerId)); + } + private static InMemoryReputationDeltaStore CreateStore() => new(); private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) => diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs index a62e740..f22a44a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/GatherOperationsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -39,6 +40,8 @@ public sealed class GatherOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); @@ -95,6 +98,8 @@ public sealed class GatherOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); @@ -133,6 +138,8 @@ public sealed class GatherOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); Assert.True(gather.Success); } @@ -156,6 +163,8 @@ public sealed class GatherOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); @@ -193,6 +202,8 @@ public sealed class GatherOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _); @@ -228,6 +239,8 @@ public sealed class GatherOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); @@ -264,6 +277,8 @@ public sealed class GatherOperationsTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); @@ -290,7 +305,9 @@ public sealed class GatherOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountItem(PlayerInventorySnapshot snapshot, string itemId) @@ -330,7 +347,9 @@ public sealed class GatherOperationsTests IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore, IPlayerPerkStateStore PerkStore, - IRewardDeliveryStore DeliveryStore); + IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore); private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index 6e95f37..bd36417 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -71,6 +72,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl d.ServiceType == typeof(IResourceNodeInstanceStore) || d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(IPlayerQuestStateStore) || + d.ServiceType == typeof(IFactionStandingStore) || + d.ServiceType == typeof(IReputationDeltaStore) || d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(ISkillDefinitionRegistry) || @@ -92,6 +95,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); }); diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs index a2029c5..10076cd 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs @@ -221,6 +221,7 @@ public sealed class QuestAcceptApiTests var perkStore = services.GetRequiredService(); var deliveryStore = services.GetRequiredService(); var standingStore = services.GetRequiredService(); + var auditStore = services.GetRequiredService(); var timeProvider = TimeProvider.System; Assert.True(QuestStateOperations.TryAccept( PlayerId, @@ -236,6 +237,7 @@ public sealed class QuestAcceptApiTests perkStore, deliveryStore, standingStore, + auditStore, timeProvider).Success); Assert.True(QuestStateOperations.TryMarkComplete( PlayerId, @@ -250,6 +252,8 @@ public sealed class QuestAcceptApiTests perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider).Success); // Act diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs index 89a2f59..69a7ab1 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs @@ -3,7 +3,9 @@ using System.Net.Http.Json; using System.Text; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Tests; using Xunit; @@ -188,4 +190,85 @@ public sealed class QuestFixtureApiTests Assert.NotNull(body); Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status)); } + + [Fact] + public async Task PostQuestFixture_ShouldClearFactionStanding_WhenResetFactionIdsProvided() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var standingStore = factory.Services.GetRequiredService(); + const string gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + var apply = ReputationOperations.TryApplyDelta( + DevPlayer, + gridFactionId, + 15, + "fixture-seed-standing", + ReputationDeltaSourceKinds.QuestCompletion, + ChainQuestId, + standingStore, + factory.Services.GetRequiredService(), + factory.Services.GetRequiredService()); + Assert.True(apply.Success); + Assert.Equal(15, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing); + + // Act + var reset = await client.PostAsJsonAsync( + FixturePath, + new QuestFixtureRequest + { + SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion, + ResetFactionIds = [gridFactionId], + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + Assert.Equal(0, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing); + } + + [Fact] + public async Task PostQuestFixture_ShouldClearRewardDeliveryAndAudit_WhenResetQuestIdsProvided() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var deliveryStore = factory.Services.GetRequiredService(); + var auditStore = factory.Services.GetRequiredService(); + const string gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + var apply = ReputationOperations.TryApplyDelta( + DevPlayer, + gridFactionId, + 15, + RewardDeliveryIds.MakeReputationDeltaId( + RewardDeliveryIds.MakeIdempotencyKey(DevPlayer, ChainQuestId), + gridFactionId), + ReputationDeltaSourceKinds.QuestCompletion, + ChainQuestId, + factory.Services.GetRequiredService(), + auditStore, + factory.Services.GetRequiredService()); + Assert.True(apply.Success); + Assert.True(deliveryStore.TryRecord(new RewardDeliveryEvent( + DevPlayer, + ChainQuestId, + DateTimeOffset.UtcNow, + [], + [], + [new RewardReputationGrantApplied(gridFactionId, 15)], + RewardDeliveryIds.MakeIdempotencyKey(DevPlayer, ChainQuestId)))); + + // Act + var reset = await client.PostAsJsonAsync( + FixturePath, + new QuestFixtureRequest + { + SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion, + ResetQuestIds = [ChainQuestId], + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + Assert.False(deliveryStore.TryGet(DevPlayer, ChainQuestId, out _)); + Assert.Empty(auditStore.GetDeltasForPlayer(DevPlayer)); + } } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs index 530ba2a..1d7ae70 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs @@ -117,6 +117,8 @@ public sealed class QuestObjectiveWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); // Assert @@ -266,6 +268,8 @@ public sealed class QuestObjectiveWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); Assert.True(result.Success); } @@ -304,6 +308,7 @@ public sealed class QuestObjectiveWiringTests deps.PerkStore, deps.DeliveryStore, deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) => @@ -321,6 +326,8 @@ public sealed class QuestObjectiveWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => @@ -337,6 +344,8 @@ public sealed class QuestObjectiveWiringTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) @@ -361,6 +370,8 @@ public sealed class QuestObjectiveWiringTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => @@ -379,6 +390,8 @@ public sealed class QuestObjectiveWiringTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -445,6 +458,7 @@ public sealed class QuestObjectiveWiringTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -468,5 +482,6 @@ public sealed class QuestObjectiveWiringTests IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs index 2cdec63..b5eeca1 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -364,6 +364,8 @@ public sealed class QuestProgressApiTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); Assert.True(result.Success); } @@ -402,6 +404,7 @@ public sealed class QuestProgressApiTests deps.PerkStore, deps.DeliveryStore, deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => @@ -418,6 +421,8 @@ public sealed class QuestProgressApiTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) @@ -442,6 +447,8 @@ public sealed class QuestProgressApiTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => @@ -460,6 +467,8 @@ public sealed class QuestProgressApiTests deps.ProgressStore, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, deps.TimeProvider); private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) @@ -496,6 +505,7 @@ public sealed class QuestProgressApiTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), TimeProvider.System); } @@ -519,5 +529,6 @@ public sealed class QuestProgressApiTests IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore, TimeProvider TimeProvider); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs index 40dbed5..605cd8f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs @@ -389,9 +389,41 @@ public sealed class QuestStateOperationsTests Assert.Single(deliveryEvent.GrantedSkillXp); Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId); Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount); + Assert.Single(deliveryEvent.GrantedReputation); + Assert.Equal(GridFactionId, deliveryEvent.GrantedReputation[0].FactionId); + Assert.Equal(15, deliveryEvent.GrantedReputation[0].Amount); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); Assert.Equal(CountBagItem(beforeInventory!, "survey_drone_kit") + 1, CountBagItem(afterInventory!, "survey_drone_kit")); Assert.Equal(100, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); + Assert.Equal( + PrototypeE7M3QuestFactionRules.GridContractMinStanding, + deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing); + } + + [Fact] + public async Task TryAccept_ShouldAcceptGridContract_WhenOperatorChainCompletedWithRepGrant() + { + // Arrange — operator-chain completion grants survey_drone_kit; grid contract inventory_has_item + // objective satisfies on accept, so TryAccept returns completed (not active). Gate check is standing >= 15. + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + AcceptAndComplete(deps, GatherQuestId); + AcceptAndComplete(deps, RefineQuestId); + AcceptAndComplete(deps, CombatQuestId); + Assert.True(TryAccept(deps, ChainQuestId).Success); + Assert.True(TryMarkComplete(deps, ChainQuestId).Success); + + // Act + var result = TryAccept(deps, GridContractQuestId); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.NotNull(result.Snapshot); + Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status); + Assert.Equal( + PrototypeE7M3QuestFactionRules.GridContractMinStanding, + deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing); } [Fact] @@ -575,6 +607,8 @@ public sealed class QuestStateOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.ReputationDeltaStore, deps.TimeProvider); private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) => @@ -595,6 +629,7 @@ public sealed class QuestStateOperationsTests deps.PerkStore, deps.DeliveryStore, deps.StandingStore, + deps.ReputationDeltaStore, deps.TimeProvider); private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) => @@ -612,6 +647,8 @@ public sealed class QuestStateOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.ReputationDeltaStore, deps.TimeProvider); private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs index 8cf000a..e39699b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs @@ -99,6 +99,31 @@ public sealed class InMemoryRewardDeliveryStoreTests Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey); } + [Fact] + public void TryClear_ShouldRemoveDeliveryRow_WhenPresent() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + var deliveryEvent = CreatePrototypeEvent(); + Assert.True(store.TryRecord(deliveryEvent)); + // Act + var cleared = store.TryClear(PlayerId, QuestId); + // Assert + Assert.True(cleared); + Assert.False(store.TryGet(PlayerId, QuestId, out _)); + } + + [Fact] + public void TryClear_ShouldReturnTrue_WhenRowAlreadyAbsent() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + // Act + var cleared = store.TryClear(PlayerId, QuestId); + // Assert + Assert.True(cleared); + } + private static RewardDeliveryEvent CreatePrototypeEvent() => new( PlayerId, @@ -106,5 +131,6 @@ public sealed class InMemoryRewardDeliveryStoreTests DeliveredAt, [new RewardItemGrantApplied("survey_drone_kit", 1)], [new RewardSkillXpGrantApplied("salvage", 50)], + [], RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId)); } diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs index 3c45a7b..fa40ef2 100644 --- a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; @@ -15,6 +16,8 @@ public sealed class RewardRouterOperationsTests private const string PlayerId = "dev-local-1"; private const string GatherQuestId = "prototype_quest_gather_intro"; 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 SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1"; private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero); @@ -39,7 +42,7 @@ public sealed class RewardRouterOperationsTests Assert.Single(first.GrantedSkillXp); Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId); Assert.Equal(25, first.GrantedSkillXp[0].Amount); - Assert.NotNull(first.DeliveryEvent); + Assert.Empty(first.GrantedReputation); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored)); Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); @@ -59,9 +62,7 @@ public sealed class RewardRouterOperationsTests // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); - var bundle = new QuestRewardBundleRow( - [new RewardGrantRow("survey_drone_kit", 1)], - [new QuestSkillXpGrantRow("salvage", 50)]); + var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId]; var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); // Act @@ -76,6 +77,9 @@ public sealed class RewardRouterOperationsTests Assert.Single(result.GrantedSkillXp); Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId); Assert.Equal(50, result.GrantedSkillXp[0].Amount); + Assert.Single(result.GrantedReputation); + Assert.Equal(GridOperatorsFactionId, result.GrantedReputation[0].FactionId); + Assert.Equal(15, result.GrantedReputation[0].Amount); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit")); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); @@ -83,6 +87,146 @@ public sealed class RewardRouterOperationsTests Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp)); Assert.Equal(salvageBefore + 50, salvageXp); Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); + Assert.Equal( + PrototypeE7M3QuestFactionRules.GridContractMinStanding, + deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing); + var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId); + var expectedDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, GridOperatorsFactionId); + var auditRow = Assert.Single( + deps.AuditStore.GetDeltasForPlayer(PlayerId), + row => row.Id == expectedDeltaId); + Assert.Equal(GridOperatorsFactionId, auditRow.FactionId); + Assert.Equal(15, auditRow.DeltaAmount); + Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, auditRow.SourceKind); + Assert.Equal(OperatorChainQuestId, auditRow.SourceId); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldNotDoubleApplyReputation_WhenAlreadyDelivered() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId]; + var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing; + + // Act + var first = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + var second = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + + // Assert + Assert.True(first.Success); + Assert.Single(first.GrantedReputation); + Assert.Equal(15, first.GrantedReputation[0].Amount); + Assert.True(second.Success); + Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode); + Assert.Equal( + standingBefore + 15, + deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing); + } + + [Fact] + public async Task TryDeliverQuestCompletion_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 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _)); + Assert.Equal(0, deps.StandingStore.TryGetStanding(PlayerId, "prototype_faction_unknown").Standing); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldRollbackItemsAndSkillXp_WhenReputationApplyDenied() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; + var bundle = new QuestRewardBundleRow( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 50)], + [new ReputationGrantRow("prototype_faction_unknown", 10)]); + + // Act + var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.Equal( + CountBagItem(beforeInventory!, "survey_drone_kit"), + CountBagItem(afterInventory!, "survey_drone_kit")); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0; + Assert.Equal(salvageBefore, salvageAfter); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldRollbackAllGrants_WhenSecondReputationAuditAppendFails() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var auditStore = new AuditStoreFailingOnSecondForwardRepAppend(); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; + var gridStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing; + var rustStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing; + var bundle = new QuestRewardBundleRow( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 50)], + [ + new ReputationGrantRow(GridOperatorsFactionId, 15), + new ReputationGrantRow(RustCollectiveFactionId, 10), + ]); + + // Act + var result = RewardRouterOperations.TryDeliverQuestCompletion( + PlayerId, + OperatorChainQuestId, + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + deps.StandingStore, + auditStore, + TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.AuditAppendFailed, result.ReasonCode); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.Equal( + CountBagItem(beforeInventory!, "survey_drone_kit"), + CountBagItem(afterInventory!, "survey_drone_kit")); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0; + Assert.Equal(salvageBefore, salvageAfter); + Assert.Equal(gridStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing); + Assert.Equal(rustStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); + Assert.Equal(2, auditStore.GetDeltasForPlayer(PlayerId).Count); } [Fact] @@ -104,6 +248,7 @@ public sealed class RewardRouterOperationsTests Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode); Assert.Empty(result.GrantedItems); Assert.Empty(result.GrantedSkillXp); + Assert.Empty(result.GrantedReputation); Assert.Null(result.DeliveryEvent); Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); } @@ -144,6 +289,7 @@ public sealed class RewardRouterOperationsTests DeliveredAt, [new RewardItemGrantApplied("survey_drone_kit", 1)], [new RewardSkillXpGrantApplied("salvage", 50)], + [], RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId)); Assert.True(deps.DeliveryStore.TryRecord(existingEvent)); deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); @@ -220,10 +366,12 @@ public sealed class RewardRouterOperationsTests Assert.Null(result.ReasonCode); Assert.Empty(result.GrantedItems); Assert.Empty(result.GrantedSkillXp); + Assert.Empty(result.GrantedReputation); Assert.NotNull(result.DeliveryEvent); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored)); Assert.Empty(stored.GrantedItems); Assert.Empty(stored.GrantedSkillXp); + Assert.Empty(stored.GrantedReputation); Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId)); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter); Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!)); @@ -250,6 +398,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -279,6 +429,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -299,6 +451,7 @@ public sealed class RewardRouterOperationsTests DeliveredAt, [], [new RewardSkillXpGrantApplied("salvage", 25)], + [], RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId)); var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); @@ -318,6 +471,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, store, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -329,6 +484,47 @@ public sealed class RewardRouterOperationsTests Assert.Equal(salvageBefore, salvageAfter); } + [Fact] + public async Task TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId]; + var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing; + var winnerEvent = new RewardDeliveryEvent( + PlayerId, + OperatorChainQuestId, + DeliveredAt, + [new RewardItemGrantApplied("survey_drone_kit", 1)], + [new RewardSkillXpGrantApplied("salvage", 50)], + [new RewardReputationGrantApplied(GridOperatorsFactionId, 15)], + RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId)); + var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); + + // Act + var result = RewardRouterOperations.TryDeliverQuestCompletion( + PlayerId, + OperatorChainQuestId, + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + store, + deps.StandingStore, + deps.AuditStore, + TimeProvider.System); + + // Assert + Assert.True(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode); + Assert.Equal(standingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing); + } + [Fact] public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp() { @@ -346,6 +542,7 @@ public sealed class RewardRouterOperationsTests DeliveredAt, [], [new RewardSkillXpGrantApplied("salvage", 25)], + [], RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId)); var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); @@ -363,6 +560,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, store, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -471,6 +670,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, timeProvider); private static void FillBag(RewardRouterTestDependencies deps) @@ -498,7 +699,9 @@ public sealed class RewardRouterOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -537,7 +740,9 @@ public sealed class RewardRouterOperationsTests ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IPlayerPerkStateStore PerkStore, - IRewardDeliveryStore DeliveryStore); + IRewardDeliveryStore DeliveryStore, + IFactionStandingStore StandingStore, + IReputationDeltaStore AuditStore); /// TryGet misses until after a failed — simulates concurrent race loser. private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore @@ -561,5 +766,49 @@ public sealed class RewardRouterOperationsTests deliveryEvent = winnerEvent; return true; } + + public bool TryClear(string playerId, string questId) => true; + } + + /// Second forward rep audit append fails; rollback appends still succeed. + private sealed class AuditStoreFailingOnSecondForwardRepAppend : IReputationDeltaStore + { + private int forwardRepAppendCount; + private readonly List rows = []; + + public bool TryAppend(ReputationDeltaRow row) + { + if (row.Id.EndsWith(":rollback", StringComparison.Ordinal)) + { + rows.Add(row); + return true; + } + + if (row.Id.Contains(":rep:", StringComparison.Ordinal)) + { + forwardRepAppendCount++; + if (forwardRepAppendCount >= 2) + { + return false; + } + } + + rows.Add(row); + return true; + } + + public IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null) + { + var normalized = FactionStandingIds.NormalizePlayerId(playerId); + var filtered = rows.Where(r => r.PlayerId == normalized).ToList(); + if (limit is > 0) + { + return filtered.Take(limit.Value).ToList(); + } + + return filtered; + } + + public bool TryClearQuestCompletionAudit(string playerId, string questId) => true; } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 207f356..6b4cd95 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -1,6 +1,7 @@ using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -90,6 +91,8 @@ public static class AbilityCastApi PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, ILoggerFactory loggerFactory, TimeProvider clock) => { @@ -297,6 +300,8 @@ public static class AbilityCastApi perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, clock, loggerFactory.CreateLogger(nameof(EncounterCombatWiring))); diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs index 4d181db..2e42121 100644 --- a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs +++ b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; @@ -33,6 +34,8 @@ public static class CraftOperations IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { if (quantity <= 0) @@ -174,6 +177,8 @@ public static class CraftOperations perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); return new CraftResult( diff --git a/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs b/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs index b3023e4..a7ce75a 100644 --- a/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs +++ b/server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; @@ -20,7 +21,8 @@ public static class PlayerCraftApi IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore, - IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => + IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) => { if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion) { @@ -54,6 +56,8 @@ public static class PlayerCraftApi progressStore, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); if (!result.Success && diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs index 7266b3e..2799067 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; @@ -35,6 +36,8 @@ public static class EncounterCombatWiring PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -99,6 +102,8 @@ public static class EncounterCombatWiring perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); if (!completion.Success) diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs index cb2118b..b45350e 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; @@ -38,6 +39,8 @@ public static class EncounterCompletionOperations PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) || @@ -160,6 +163,8 @@ public static class EncounterCompletionOperations perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); return new EncounterCompletionResult( diff --git a/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs index 2d5086c..d0c7bfe 100644 --- a/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs @@ -20,4 +20,10 @@ public interface IFactionStandingStore /// Does not append audit rows — is orchestrated by NEO-136. /// FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount); + + /// + /// Removes persisted standing for one faction (missing row reads as neutral 0). + /// Dev fixture / Bruno reset only — not for gameplay rollback. + /// + bool TryClearStanding(string playerId, string factionId); } diff --git a/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs index 13ae723..595cf1a 100644 --- a/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs @@ -11,4 +11,10 @@ public interface IReputationDeltaStore /// Audit rows for one player ordered by then Id ascending. IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null); + + /// + /// Dev fixture only — removes quest-completion audit rows for one quest (includes rollback rows). + /// Idempotent when no rows match. + /// + bool TryClearQuestCompletionAudit(string playerId, string questId); } diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs index 4f814bd..26db61b 100644 --- a/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs @@ -99,6 +99,35 @@ public sealed class InMemoryFactionStandingStore( } } + /// + public bool TryClearStanding(string playerId, string factionId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0 || faction.Length == 0) + { + return false; + } + + if (!factionRegistry.TryGetDefinition(faction, out _)) + { + return false; + } + + if (!CanWritePlayer(player)) + { + return false; + } + + var key = FactionStandingIds.MakeStandingKey(player, faction); + lock (keyLocks.GetOrAdd(key, _ => new object())) + { + standingByKey.TryRemove(key, out _); + } + + return true; + } + private static FactionStandingReadOutcome DenyRead(string reasonCode) => new(false, reasonCode, 0); diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs index fcfff4f..bc226c1 100644 --- a/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Factions; @@ -60,6 +61,33 @@ public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore return query.ToArray(); } + /// + public bool TryClearQuestCompletionAudit(string playerId, string questId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var sourceId = RewardDeliveryIds.NormalizeQuestId(questId); + if (player.Length == 0 || sourceId.Length == 0) + { + return false; + } + + var idsToRemove = rowsById.Values + .Where(row => + string.Equals(row.PlayerId, player, StringComparison.Ordinal) && + string.Equals(row.SourceKind, ReputationDeltaSourceKinds.QuestCompletion, StringComparison.Ordinal) && + string.Equals(row.SourceId, sourceId, StringComparison.Ordinal)) + .Select(static row => row.Id) + .ToArray(); + + foreach (var id in idsToRemove) + { + rowsById.TryRemove(id, out _); + idLocks.TryRemove(id, out _); + } + + return true; + } + private static bool IsValidRow(ReputationDeltaRow row) => row.Id.Trim().Length > 0 && FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs index fdb3ffd..6595d58 100644 --- a/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs @@ -136,6 +136,40 @@ public sealed class PostgresFactionStandingStore( return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding); } + /// + public bool TryClearStanding(string playerId, string factionId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (player.Length == 0 || faction.Length == 0) + { + return false; + } + + if (!factionRegistry.TryGetDefinition(faction, out _)) + { + return false; + } + + PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + if (!PlayerExists(conn, player)) + { + return false; + } + + using var cmd = new Npgsql.NpgsqlCommand( + """ + DELETE FROM player_faction_standing + WHERE player_id = @pid AND faction_id = @fid; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("fid", faction); + cmd.ExecuteNonQuery(); + return true; + } + private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null) { using var cmd = new Npgsql.NpgsqlCommand( diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs index 14bdd8f..96e6aaa 100644 --- a/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs @@ -1,5 +1,7 @@ namespace NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Rewards; + /// PostgreSQL-backed append-only reputation delta audit log (NEO-135). public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore { @@ -91,6 +93,33 @@ public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSou return rows; } + /// + public bool TryClearQuestCompletionAudit(string playerId, string questId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var sourceId = RewardDeliveryIds.NormalizeQuestId(questId); + if (player.Length == 0 || sourceId.Length == 0) + { + return false; + } + + PostgresReputationDeltaBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + DELETE FROM reputation_delta_audit + WHERE player_id = @pid + AND source_kind = @kind + AND source_id = @source_id; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("kind", ReputationDeltaSourceKinds.QuestCompletion); + cmd.Parameters.AddWithValue("source_id", sourceId); + cmd.ExecuteNonQuery(); + return true; + } + private static bool IsValidRow(ReputationDeltaRow row) => row.Id.Trim().Length > 0 && FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && diff --git a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs index f12436b..aa81cec 100644 --- a/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs +++ b/server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; @@ -34,6 +35,8 @@ public static class GatherOperations IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { var key = ResourceNodeInstanceIds.Normalize(interactableId); @@ -149,6 +152,8 @@ public static class GatherOperations perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); return new GatherResult( diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs index d559a00..eb6279f 100644 --- a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs +++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -25,7 +26,8 @@ public static class InteractionApi IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore, - IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => + IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) => { if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion) { @@ -95,6 +97,8 @@ public static class InteractionApi progressStore, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); if (!gather.Success) diff --git a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs index 94f7330..2aafc11 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs @@ -20,7 +20,7 @@ public static class QuestAcceptApi ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, - TimeProvider timeProvider) => + IReputationDeltaStore auditStore, TimeProvider timeProvider) => { if (body is not null && body.SchemaVersion != 0 && @@ -49,6 +49,7 @@ public static class QuestAcceptApi perkStore, deliveryStore, standingStore, + auditStore, timeProvider); return Results.Json(MapResponse(trimmedId, deliveryStore, result)); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs index 74ccab9..d5e9ce7 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs @@ -1,4 +1,6 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Quests; @@ -10,7 +12,9 @@ public static class QuestFixtureApi app.MapPost( "/game/players/{id}/__dev/quest-fixture", (string id, QuestFixtureRequest? body, IPositionStateStore positions, - IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, + IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry, + IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore, + IRewardDeliveryStore deliveryStore, IReputationDeltaStore auditStore, TimeProvider timeProvider) => { if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion) @@ -28,7 +32,11 @@ public static class QuestFixtureApi trimmedId, body, questRegistry, + factionRegistry, progressStore, + standingStore, + deliveryStore, + auditStore, timeProvider)) { return Results.NotFound(); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs index e1d880e..3f8141e 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs @@ -17,6 +17,10 @@ public sealed class QuestFixtureRequest /// Quest ids to clear from player progress (dev Bruno reset). [JsonPropertyName("resetQuestIds")] public IReadOnlyList ResetQuestIds { get; init; } = []; + + /// Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0). + [JsonPropertyName("resetFactionIds")] + public IReadOnlyList ResetFactionIds { get; init; } = []; } /// POST response for quest fixture apply. diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs index d444d39..90b7b1b 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs @@ -1,5 +1,8 @@ namespace NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Rewards; + /// Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA). public static class QuestFixtureOperations { @@ -11,10 +14,16 @@ public static class QuestFixtureOperations string playerId, QuestFixtureRequest body, IQuestDefinitionRegistry questRegistry, + IFactionDefinitionRegistry factionRegistry, IPlayerQuestStateStore progressStore, + IFactionStandingStore standingStore, + IRewardDeliveryStore deliveryStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { - if (body.CompletedQuestIds.Count == 0 && body.ResetQuestIds.Count == 0) + if (body.CompletedQuestIds.Count == 0 && + body.ResetQuestIds.Count == 0 && + body.ResetFactionIds.Count == 0) { return true; } @@ -35,6 +44,29 @@ public static class QuestFixtureOperations { return false; } + + if (!deliveryStore.TryClear(playerId, normalizedQuestId)) + { + return false; + } + + if (!auditStore.TryClearQuestCompletionAudit(playerId, normalizedQuestId)) + { + return false; + } + } + + foreach (var factionId in body.ResetFactionIds) + { + if (!factionRegistry.TryGetDefinition(factionId, out var definition) || definition is null) + { + return false; + } + + if (!standingStore.TryClearStanding(playerId, definition.Id)) + { + return false; + } } foreach (var questId in body.CompletedQuestIds) diff --git a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs index 527faf7..f070c09 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Rewards; @@ -28,6 +29,8 @@ public static class QuestObjectiveWiring PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -74,6 +77,8 @@ public static class QuestObjectiveWiring perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); } catch (Exception ex) @@ -97,6 +102,8 @@ public static class QuestObjectiveWiring PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -143,6 +150,8 @@ public static class QuestObjectiveWiring perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); } catch (Exception ex) @@ -165,6 +174,8 @@ public static class QuestObjectiveWiring PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -210,6 +221,8 @@ public static class QuestObjectiveWiring perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); } catch (Exception ex) @@ -254,6 +267,8 @@ public static class QuestObjectiveWiring PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider, ILogger? logger = null) { @@ -278,6 +293,8 @@ public static class QuestObjectiveWiring perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); } catch (Exception ex) @@ -343,6 +360,8 @@ public static class QuestObjectiveWiring PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++) @@ -381,6 +400,8 @@ public static class QuestObjectiveWiring perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); return; } @@ -399,6 +420,8 @@ public static class QuestObjectiveWiring perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); if (!advance.Success) @@ -420,6 +443,8 @@ public static class QuestObjectiveWiring PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { foreach (var definition in questRegistry.GetDefinitionsInIdOrder()) @@ -439,6 +464,8 @@ public static class QuestObjectiveWiring perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); } } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs index a385fe8..3463dad 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; @@ -22,7 +23,8 @@ public static class QuestProgressApi IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, - IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger? logger) => + IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider, ILogger? logger) => { var trimmedId = id.Trim(); if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) @@ -42,6 +44,8 @@ public static class QuestProgressApi perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider, logger); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs index e4f28a5..9d3e019 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs @@ -31,6 +31,7 @@ public static class QuestStateOperations IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || @@ -100,6 +101,8 @@ public static class QuestStateOperations perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed)) @@ -130,6 +133,8 @@ public static class QuestStateOperations PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || @@ -185,6 +190,8 @@ public static class QuestStateOperations perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed)) @@ -215,6 +222,8 @@ public static class QuestStateOperations PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || @@ -250,6 +259,8 @@ public static class QuestStateOperations perkUnlockEngine, perkStore, deliveryStore, + standingStore, + auditStore, timeProvider); if (!delivery.Success) diff --git a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs index aa8d579..f7c4860 100644 --- a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs +++ b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs @@ -7,4 +7,10 @@ public interface IRewardDeliveryStore bool TryRecord(RewardDeliveryEvent deliveryEvent); bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent); + + /// + /// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset. + /// Idempotent when the row is already absent. + /// + bool TryClear(string playerId, string questId); } diff --git a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs index c5a6107..b9a89ed 100644 --- a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs +++ b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs @@ -29,6 +29,7 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore { GrantedItems = deliveryEvent.GrantedItems.ToArray(), GrantedSkillXp = deliveryEvent.GrantedSkillXp.ToArray(), + GrantedReputation = deliveryEvent.GrantedReputation.ToArray(), }; return true; } @@ -61,9 +62,9 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore lock (keyLocks.GetOrAdd(key, _ => new object())) { - var removed = eventsByKey.TryRemove(key, out _); + _ = eventsByKey.TryRemove(key, out _); keyLocks.TryRemove(key, out _); - return removed; + return true; } } } diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs index a161229..15c4eeb 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs @@ -11,4 +11,5 @@ public readonly record struct RewardDeliveryEvent( DateTimeOffset DeliveredAt, IReadOnlyList GrantedItems, IReadOnlyList GrantedSkillXp, + IReadOnlyList GrantedReputation, string IdempotencyKey); diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs index 665b9fe..cc018fe 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Factions; + namespace NeonSprawl.Server.Game.Rewards; /// Id normalization and composite keys for reward delivery stores (NEO-126). @@ -31,6 +33,22 @@ public static class RewardDeliveryIds return $"{p}:quest_complete:{q}"; } + /// Stable audit row id for one rep grant on a quest completion delivery (NEO-138). + public static string MakeReputationDeltaId(string idempotencyKey, string? factionId) + { + var faction = FactionStandingIds.NormalizeFactionId(factionId); + if (idempotencyKey.Length == 0 || faction.Length == 0) + { + return string.Empty; + } + + return $"{idempotencyKey}:rep:{faction}"; + } + + /// Rollback audit row id for compensating rep revert (NEO-138). + public static string MakeReputationRollbackDeltaId(string reputationDeltaId) => + reputationDeltaId.Length == 0 ? string.Empty : $"{reputationDeltaId}:rollback"; + /// Composite store key for + . public static string MakeStoreKey(string? playerId, string? questId) { diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs index 21bf15c..9e40cf2 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Skills; @@ -34,4 +35,19 @@ public static class RewardDeliveryReasonCodes /// Passthrough from . public const string SourceKindNotAllowed = SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed; + + /// Passthrough from via rep apply (NEO-138). + public const string UnknownFaction = FactionStandingReasonCodes.UnknownFaction; + + /// Passthrough from . + public const string InvalidDelta = ReputationApplyReasonCodes.InvalidDelta; + + /// Passthrough from . + public const string InvalidDeltaId = ReputationApplyReasonCodes.InvalidDeltaId; + + /// Passthrough from . + public const string InvalidSource = ReputationApplyReasonCodes.InvalidSource; + + /// Passthrough from . + public const string AuditAppendFailed = ReputationApplyReasonCodes.AuditAppendFailed; } diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs index 63078ad..9d53480 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs @@ -6,11 +6,13 @@ namespace NeonSprawl.Server.Game.Rewards; /// /// Item grants on success; empty when denied. /// Skill XP grants on success; empty when denied. +/// Reputation grants on success; empty when denied. /// Delivery payload on success; null when denied. public readonly record struct RewardDeliveryResult( bool Success, string? ReasonCode, IReadOnlyList GrantedItems, IReadOnlyList GrantedSkillXp, + IReadOnlyList GrantedReputation, RewardDeliveryEvent? DeliveryEvent, DateTimeOffset? DeliveredAt); diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardReputationGrantApplied.cs b/server/NeonSprawl.Server/Game/Rewards/RewardReputationGrantApplied.cs new file mode 100644 index 0000000..3d1d1b6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardReputationGrantApplied.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// One reputation grant applied on a successful quest completion reward delivery (NEO-138). +public readonly record struct RewardReputationGrantApplied(string FactionId, int Amount); diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs index 9090d58..e77a339 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Quests; @@ -9,15 +10,17 @@ namespace NeonSprawl.Server.Game.Rewards; /// /// Applies quest completion grants idempotently (NEO-127). /// Quest-state wiring: (NEO-128). +/// Reputation grants: (NEO-138). /// E9.M1 telemetry hook sites: (NEO-130). /// public static class RewardRouterOperations { private static readonly RewardItemGrantApplied[] EmptyItemGrants = []; private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = []; + private static readonly RewardReputationGrantApplied[] EmptyReputationGrants = []; /// - /// Applies item + skill XP rows for a quest completion and records + /// Applies item, skill XP, and reputation rows for a quest completion and records /// when grants succeed. /// public static RewardDeliveryResult TryDeliverQuestCompletion( @@ -32,6 +35,8 @@ public static class RewardRouterOperations PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, IRewardDeliveryStore deliveryStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId); @@ -53,6 +58,8 @@ public static class RewardRouterOperations var itemGrants = bundle?.ItemGrants ?? Array.Empty(); var skillGrants = bundle?.SkillXpGrants ?? Array.Empty(); + var reputationGrants = bundle?.ReputationGrants ?? Array.Empty(); + var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId); if (itemGrants.Count > 0) { @@ -87,7 +94,6 @@ public static class RewardRouterOperations if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) { - CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing); } @@ -117,13 +123,45 @@ public static class RewardRouterOperations if (skillOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing) { - CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); + CompensatingRevertAll( + playerId, + normalizedQuestId, + idempotencyKey, + appliedItems, + appliedSkillXp, + perksUnlockedByThisCall, + [], + itemRegistry, + inventoryStore, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + standingStore, + auditStore, + timeProvider); return Deny(RewardDeliveryReasonCodes.ProgressionStoreMissing); } if (skillOutcome.Kind == SkillProgressionGrantApplyKind.Denied) { - CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); + CompensatingRevertAll( + playerId, + normalizedQuestId, + idempotencyKey, + appliedItems, + appliedSkillXp, + perksUnlockedByThisCall, + [], + itemRegistry, + inventoryStore, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + standingStore, + auditStore, + timeProvider); var reasonCode = skillOutcome.Response?.ReasonCode ?? RewardDeliveryReasonCodes.UnknownSkill; return Deny(reasonCode); } @@ -143,6 +181,50 @@ public static class RewardRouterOperations // E9.M1 wiring: place per-row emits inside the apply loop and/or after TryRecord success only — not on // TryRecord race-rollback paths (grants applied then reverted below); mirror reward_delivery gating. + var appliedReputation = new List(reputationGrants.Count); + foreach (var grant in reputationGrants) + { + var deltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId); + var repOutcome = ReputationOperations.TryApplyDelta( + playerId, + grant.FactionId, + grant.Amount, + deltaId, + ReputationDeltaSourceKinds.QuestCompletion, + normalizedQuestId, + standingStore, + auditStore, + timeProvider); + + if (!repOutcome.Success) + { + CompensatingRevertAll( + playerId, + normalizedQuestId, + idempotencyKey, + appliedItems, + appliedSkillXp, + perksUnlockedByThisCall, + appliedReputation, + itemRegistry, + inventoryStore, + skillProgressionStore, + levelCurve, + perkUnlockEngine, + perkStore, + standingStore, + auditStore, + timeProvider); + return Deny(MapReputationReason(repOutcome.ReasonCode)); + } + + var appliedAmount = repOutcome.NewStanding - repOutcome.PreviousStanding; + appliedReputation.Add(new RewardReputationGrantApplied(grant.FactionId, appliedAmount)); + + // --- Telemetry hook site (NEO-141): future E9.M1 catalog event `reputation_delta` --- + // TODO(E9.M1): catalog emit — once per applied rep row on TryRecord success only (not rollback paths). + } + var deliveredAt = timeProvider.GetUtcNow(); var deliveryEvent = new RewardDeliveryEvent( normalizedPlayerId, @@ -150,7 +232,8 @@ public static class RewardRouterOperations deliveredAt, appliedItems, appliedSkillXp, - RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId)); + appliedReputation, + idempotencyKey); if (deliveryStore.TryRecord(deliveryEvent)) { @@ -158,7 +241,7 @@ public static class RewardRouterOperations // TODO(E9.M1): catalog emit — once per first-time quest completion delivery (TryRecord success). // Not on TryGet idempotent replay, deny paths, or TryRecord race-loser rollback paths. // Planned payload fields: playerId, questId, deliveredAt, grantedItems (itemId, quantity), - // grantedSkillXp (skillId, amount), idempotencyKey ({playerId}:quest_complete:{questId}). + // grantedSkillXp (skillId, amount), grantedReputation (factionId, amount), idempotencyKey. // No ingest or ILogger here (comments-only). return SuccessFromEvent(deliveryEvent, reasonCode: null); @@ -166,19 +249,23 @@ public static class RewardRouterOperations // Lost TryRecord race after apply — rollback this call's grants (mirror // EncounterCompletionOperations when TryMarkCompleted returns false). - CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); - _ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants( + CompensatingRevertAll( playerId, + normalizedQuestId, + idempotencyKey, + appliedItems, appliedSkillXp, perksUnlockedByThisCall, - skillProgressionStore, - perkStore); - ResyncPathAutoPerksAfterRaceRollback( - playerId, - appliedSkillXp, + appliedReputation, + itemRegistry, + inventoryStore, skillProgressionStore, levelCurve, - perkUnlockEngine); + perkUnlockEngine, + perkStore, + standingStore, + auditStore, + timeProvider); if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent)) { @@ -188,6 +275,79 @@ public static class RewardRouterOperations return Deny(RewardDeliveryReasonCodes.AlreadyDelivered); } + private static void CompensatingRevertAll( + string playerId, + string normalizedQuestId, + string idempotencyKey, + IReadOnlyList appliedItems, + IReadOnlyList appliedSkillXp, + HashSet perksUnlockedByThisCall, + IReadOnlyList appliedReputation, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, + TimeProvider timeProvider) + { + CompensatingRevertReputation( + playerId, + normalizedQuestId, + idempotencyKey, + appliedReputation, + standingStore, + auditStore, + timeProvider); + _ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants( + playerId, + appliedSkillXp, + perksUnlockedByThisCall, + skillProgressionStore, + perkStore); + CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); + ResyncPathAutoPerksAfterRaceRollback( + playerId, + appliedSkillXp, + skillProgressionStore, + levelCurve, + perkUnlockEngine); + } + + private static void CompensatingRevertReputation( + string playerId, + string normalizedQuestId, + string idempotencyKey, + IReadOnlyList grants, + IFactionStandingStore standingStore, + IReputationDeltaStore auditStore, + TimeProvider timeProvider) + { + for (var i = grants.Count - 1; i >= 0; i--) + { + var grant = grants[i]; + if (grant.Amount == 0) + { + continue; + } + + var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId); + var rollbackDeltaId = RewardDeliveryIds.MakeReputationRollbackDeltaId(forwardDeltaId); + _ = ReputationOperations.TryApplyDelta( + playerId, + grant.FactionId, + -grant.Amount, + rollbackDeltaId, + ReputationDeltaSourceKinds.QuestCompletion, + normalizedQuestId, + standingStore, + auditStore, + timeProvider); + } + } + private static void ResyncPathAutoPerksAfterRaceRollback( string playerId, IReadOnlyList appliedSkillXp, @@ -219,6 +379,18 @@ public static class RewardRouterOperations _ => reasonCode, }; + private static string MapReputationReason(string? reasonCode) => + reasonCode switch + { + FactionStandingReasonCodes.UnknownFaction => RewardDeliveryReasonCodes.UnknownFaction, + ReputationApplyReasonCodes.InvalidDelta => RewardDeliveryReasonCodes.InvalidDelta, + ReputationApplyReasonCodes.InvalidDeltaId => RewardDeliveryReasonCodes.InvalidDeltaId, + ReputationApplyReasonCodes.InvalidSource => RewardDeliveryReasonCodes.InvalidSource, + ReputationApplyReasonCodes.AuditAppendFailed => RewardDeliveryReasonCodes.AuditAppendFailed, + null => RewardDeliveryReasonCodes.UnknownFaction, + _ => reasonCode, + }; + private static void CompensatingRemoveItems( string playerId, IReadOnlyList grants, @@ -243,6 +415,7 @@ public static class RewardRouterOperations ReasonCode: reasonCode, GrantedItems: EmptyItemGrants, GrantedSkillXp: EmptySkillXpGrants, + GrantedReputation: EmptyReputationGrants, DeliveryEvent: null, DeliveredAt: null); @@ -252,6 +425,7 @@ public static class RewardRouterOperations ReasonCode: reasonCode, GrantedItems: deliveryEvent.GrantedItems, GrantedSkillXp: deliveryEvent.GrantedSkillXp, + GrantedReputation: deliveryEvent.GrantedReputation, DeliveryEvent: deliveryEvent, DeliveredAt: deliveryEvent.DeliveredAt); } diff --git a/server/README.md b/server/README.md index ec01ae3..8ea80f3 100644 --- a/server/README.md +++ b/server/README.md @@ -92,7 +92,7 @@ Game code applies standing changes through **`ReputationOperations.TryApplyDelta **Pre-flight denies (no store mutation):** `deltaAmount == 0` → **`invalid_delta`**; empty `deltaId` → **`invalid_delta_id`**; empty `sourceKind` or `sourceId` → **`invalid_source`**. Store boundary denies (**`unknown_faction`**, **`player_not_writable`**) pass through unchanged. -**Prototype source kind:** **`quest_completion`** (`ReputationDeltaSourceKinds.QuestCompletion`); quest id as `sourceId`. Quest bundle wiring lands in NEO-138 (`RewardRouterOperations`). +**Prototype source kind:** **`quest_completion`** (`ReputationDeltaSourceKinds.QuestCompletion`); quest id as `sourceId`. Quest bundle wiring: **`RewardRouterOperations.TryDeliverQuestCompletion`** (NEO-138). Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-implementation-plan.md). @@ -104,15 +104,16 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio **Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies). -**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. Rep grants that reach +15 standing land in NEO-138; success Bruno smoke deferred until then. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** to mark operator chain completed without reward delivery (standing **0**). +**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** with **`resetQuestIds`** for grid contract, **`resetFactionIds`** for both prototype factions (clears stale standing from prior seq **11** runs), plus **`completedQuestIds`** for operator chain — no reward delivery; standing **0**. **Success Bruno** (seq **11**, NEO-138): organic operator-chain flow then accept. Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md). -**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional: -- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed. -- **`resetQuestIds`** — deletes each quest's progress row for the player (idempotent when absent). Can be combined with **`completedQuestIds`** in one request (resets run first). +**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional (resets run before **`completedQuestIds`** when combined): +- **`resetQuestIds`** — deletes each quest's progress row for the player and clears matching **`IRewardDeliveryStore`** + quest-completion **`IReputationDeltaStore`** audit rows (idempotent when absent). +- **`resetFactionIds`** — clears each faction's standing row for the player (missing row reads as **0**; idempotent when absent). +- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged unless **`resetFactionIds`** also sent). Idempotent when a quest is already completed. -**400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` ( **`completedQuestIds`** ); order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**. +**400** for bad schema; **404** when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` uses **`resetQuestIds`**, **`resetFactionIds`**, and **`completedQuestIds`**; order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**. **Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**. @@ -348,6 +349,7 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: | **`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. | **Store interface:** @@ -359,7 +361,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp ## Reward router (NEO-127) -**`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows) and **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**. +**`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`**. | Reason code | When | |-------------|------| @@ -369,8 +371,9 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp | **`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**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — 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`**. Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-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 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). ### Reward telemetry hooks (NEO-130) @@ -379,6 +382,7 @@ Comment-only hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`* | Event | Anchor | When | |-------|--------|------| | **`reward_delivery`** | **`TryDeliverQuestCompletion`** | After **`TryRecord`** returns **`true`** on first-time delivery (not idempotent **`TryGet`** replay or race-loser paths). | +| **`reputation_delta`** | **`TryDeliverQuestCompletion`** (stub) | After each successful reputation row apply (NEO-138); consolidated in NEO-141. | | **`unlock_granted`** | **`TryDeliverQuestCompletion`** (stub) | Future **`UnlockGrant`** apply from bundle rows — prototype bundles have item + skill XP only; no runtime unlock apply in Slice 2. | **`perk_unlock`** side effects from skill XP grants use **`PerkUnlockEngine`** ([NEO-49](#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49)) — distinct from content **`UnlockGrant`** rows. Manual QA: [`docs/manual-qa/NEO-130.md`](../../docs/manual-qa/NEO-130.md); plan: [NEO-130 implementation plan](../../docs/plans/NEO-130-implementation-plan.md).