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..7badb1c --- /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. +} + +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("active"); + expect(body.quest.currentStepIndex).to.equal(0); + }); +} 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..a746280 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 @@ -7,7 +7,7 @@ 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. + Success sibling: seq 11 Accept grid contract after operator chain (NEO-138 organic rep grant). } script:pre-request { 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 index f728756..38dbdd6 100644 --- a/docs/plans/NEO-138-implementation-plan.md +++ b/docs/plans/NEO-138-implementation-plan.md @@ -59,9 +59,9 @@ Other decisions settled by Linear AC, backlog kickoff defaults, and repo precede ## Acceptance criteria checklist -- [ ] First-time quest completion applies rep grants exactly once per delivery key. -- [ ] Unknown faction id in bundle denies delivery before store record. -- [ ] `dotnet test` covers rep happy path, idempotent replay, unknown-faction deny, and rollback paths. +- [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 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/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/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..914d7a7 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs @@ -389,9 +389,40 @@ 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 + 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 +606,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 +628,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 +646,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..f2c691c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs @@ -106,5 +106,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..97da55e 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,7 @@ 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 SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1"; private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero); @@ -39,7 +41,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 +61,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 +76,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 +86,84 @@ 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); + } + + [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] @@ -144,6 +225,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); @@ -250,6 +332,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -279,6 +363,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -299,6 +385,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 +405,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, store, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -346,6 +435,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 +453,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, store, + deps.StandingStore, + deps.AuditStore, TimeProvider.System); // Assert @@ -471,6 +563,8 @@ public sealed class RewardRouterOperationsTests deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, + deps.StandingStore, + deps.AuditStore, timeProvider); private static void FillBag(RewardRouterTestDependencies deps) @@ -498,7 +592,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 +633,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 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/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/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/InMemoryRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs index c5a6107..faf49b4 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; } 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..f099153 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). @@ -348,6 +348,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 +360,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 +370,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 +381,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).