NEO-138: reward router reputation grants, Bruno quest-flow, docs.

Apply completionRewardBundle.reputationGrants via ReputationOperations with
compensating rollback; thread faction stores through quest wiring; add operator
chain Bruno helper and grid-contract accept success; update README and E7.M3 docs.
pull/178/head
VinPropane 2026-06-15 22:18:27 -04:00
parent 599d87bd0d
commit 3466f1ee32
36 changed files with 864 additions and 41 deletions

View File

@ -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);
});
}

View File

@ -7,7 +7,7 @@ meta {
docs { docs {
NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15. 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). 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 { script:pre-request {

View File

@ -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,
};

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M3 | | **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production | | **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) | | **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
## Purpose ## Purpose

File diff suppressed because one or more lines are too long

View File

@ -59,9 +59,9 @@ Other decisions settled by Linear AC, backlog kickoff defaults, and repo precede
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] First-time quest completion applies rep grants exactly once per delivery key. - [x] First-time quest completion applies rep grants exactly once per delivery key.
- [ ] Unknown faction id in bundle denies delivery before store record. - [x] 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] `dotnet test` covers rep happy path, idempotent replay, unknown-faction deny, and rollback paths.
## Technical approach ## Technical approach

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -38,6 +39,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -84,6 +87,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -121,6 +126,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -160,6 +167,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -196,6 +205,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -233,6 +244,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -267,6 +280,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -297,6 +312,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -330,6 +347,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -365,6 +384,8 @@ public sealed class CraftOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -415,7 +436,9 @@ public sealed class CraftOperationsTests
services.GetRequiredService<IQuestDefinitionRegistry>(), services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(), services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>()); services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
} }
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -454,7 +477,9 @@ public sealed class CraftOperationsTests
IQuestDefinitionRegistry QuestRegistry, IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore, IPlayerQuestStateStore ProgressStore,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore); IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
{ {

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -46,6 +47,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -83,6 +86,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -124,6 +129,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -163,6 +170,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -195,6 +204,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before); deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
@ -219,6 +230,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -257,6 +270,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -304,6 +319,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -348,6 +365,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -376,6 +395,8 @@ public sealed class EncounterCombatWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
} }
@ -432,7 +453,9 @@ public sealed class EncounterCombatWiringTests
services.GetRequiredService<ISkillLevelCurve>(), services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(), services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>()); services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
} }
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -464,5 +487,7 @@ public sealed class EncounterCombatWiringTests
ISkillLevelCurve LevelCurve, ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine, PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore); IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
} }

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -46,6 +47,8 @@ public sealed class EncounterCompletionOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
timeProvider); timeProvider);
// Assert // Assert
@ -95,6 +98,8 @@ public sealed class EncounterCompletionOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
timeProvider); timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
@ -117,6 +122,8 @@ public sealed class EncounterCompletionOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
timeProvider); timeProvider);
// Assert // Assert
@ -181,6 +188,8 @@ public sealed class EncounterCompletionOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -221,6 +230,8 @@ public sealed class EncounterCompletionOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -263,6 +274,8 @@ public sealed class EncounterCompletionOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -303,6 +316,8 @@ public sealed class EncounterCompletionOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -341,6 +356,8 @@ public sealed class EncounterCompletionOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -409,7 +426,9 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<ISkillLevelCurve>(), services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(), services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>()); services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
} }
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -455,7 +474,9 @@ public sealed class EncounterCompletionOperationsTests
ISkillLevelCurve LevelCurve, ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine, PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore); IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{ {

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -39,6 +40,8 @@ public sealed class GatherOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -95,6 +98,8 @@ public sealed class GatherOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -133,6 +138,8 @@ public sealed class GatherOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
Assert.True(gather.Success); Assert.True(gather.Success);
} }
@ -156,6 +163,8 @@ public sealed class GatherOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -193,6 +202,8 @@ public sealed class GatherOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _); var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _);
@ -228,6 +239,8 @@ public sealed class GatherOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -264,6 +277,8 @@ public sealed class GatherOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -290,7 +305,9 @@ public sealed class GatherOperationsTests
services.GetRequiredService<IQuestDefinitionRegistry>(), services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(), services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>()); services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
} }
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
@ -330,7 +347,9 @@ public sealed class GatherOperationsTests
IQuestDefinitionRegistry QuestRegistry, IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore, IPlayerQuestStateStore ProgressStore,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore); IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
{ {

View File

@ -8,6 +8,7 @@ using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -71,6 +72,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
d.ServiceType == typeof(IResourceNodeInstanceStore) || d.ServiceType == typeof(IResourceNodeInstanceStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(IPlayerQuestStateStore) || d.ServiceType == typeof(IPlayerQuestStateStore) ||
d.ServiceType == typeof(IFactionStandingStore) ||
d.ServiceType == typeof(IReputationDeltaStore) ||
d.ServiceType == typeof(TimeProvider) || d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) || d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(ISkillDefinitionRegistry) || d.ServiceType == typeof(ISkillDefinitionRegistry) ||
@ -92,6 +95,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>(); services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>(); services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>(); services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>(); services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>(); services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
}); });

View File

@ -221,6 +221,7 @@ public sealed class QuestAcceptApiTests
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>(); var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>(); var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
var standingStore = services.GetRequiredService<IFactionStandingStore>(); var standingStore = services.GetRequiredService<IFactionStandingStore>();
var auditStore = services.GetRequiredService<IReputationDeltaStore>();
var timeProvider = TimeProvider.System; var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept( Assert.True(QuestStateOperations.TryAccept(
PlayerId, PlayerId,
@ -236,6 +237,7 @@ public sealed class QuestAcceptApiTests
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore, standingStore,
auditStore,
timeProvider).Success); timeProvider).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId, PlayerId,
@ -250,6 +252,8 @@ public sealed class QuestAcceptApiTests
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider).Success); timeProvider).Success);
// Act // Act

View File

@ -117,6 +117,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
// Assert // Assert
@ -266,6 +268,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
Assert.True(result.Success); Assert.True(result.Success);
} }
@ -304,6 +308,7 @@ public sealed class QuestObjectiveWiringTests
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore, deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) => private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
@ -321,6 +326,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
@ -337,6 +344,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
@ -361,6 +370,8 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
@ -379,6 +390,8 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -445,6 +458,7 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(), services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(), services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -468,5 +482,6 @@ public sealed class QuestObjectiveWiringTests
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore, IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore, IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -364,6 +364,8 @@ public sealed class QuestProgressApiTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
Assert.True(result.Success); Assert.True(result.Success);
} }
@ -402,6 +404,7 @@ public sealed class QuestProgressApiTests
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore, deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
@ -418,6 +421,8 @@ public sealed class QuestProgressApiTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
@ -442,6 +447,8 @@ public sealed class QuestProgressApiTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
@ -460,6 +467,8 @@ public sealed class QuestProgressApiTests
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider); deps.TimeProvider);
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
@ -496,6 +505,7 @@ public sealed class QuestProgressApiTests
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(), services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(), services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -519,5 +529,6 @@ public sealed class QuestProgressApiTests
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore, IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore, IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -389,9 +389,40 @@ public sealed class QuestStateOperationsTests
Assert.Single(deliveryEvent.GrantedSkillXp); Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId); Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId);
Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount); 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); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(CountBagItem(beforeInventory!, "survey_drone_kit") + 1, CountBagItem(afterInventory!, "survey_drone_kit")); Assert.Equal(CountBagItem(beforeInventory!, "survey_drone_kit") + 1, CountBagItem(afterInventory!, "survey_drone_kit"));
Assert.Equal(100, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); 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] [Fact]
@ -575,6 +606,8 @@ public sealed class QuestStateOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.ReputationDeltaStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) => private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) =>
@ -595,6 +628,7 @@ public sealed class QuestStateOperationsTests
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore, deps.StandingStore,
deps.ReputationDeltaStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) => private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
@ -612,6 +646,8 @@ public sealed class QuestStateOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.ReputationDeltaStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)

View File

@ -106,5 +106,6 @@ public sealed class InMemoryRewardDeliveryStoreTests
DeliveredAt, DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)], [new RewardItemGrantApplied("survey_drone_kit", 1)],
[new RewardSkillXpGrantApplied("salvage", 50)], [new RewardSkillXpGrantApplied("salvage", 50)],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId)); RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId));
} }

View File

@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
@ -15,6 +16,7 @@ public sealed class RewardRouterOperationsTests
private const string PlayerId = "dev-local-1"; private const string PlayerId = "dev-local-1";
private const string GatherQuestId = "prototype_quest_gather_intro"; private const string GatherQuestId = "prototype_quest_gather_intro";
private const string OperatorChainQuestId = "prototype_quest_operator_chain"; private const string OperatorChainQuestId = "prototype_quest_operator_chain";
private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1"; private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1";
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero); 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.Single(first.GrantedSkillXp);
Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId); Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId);
Assert.Equal(25, first.GrantedSkillXp[0].Amount); 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.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey); Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey);
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
@ -59,9 +61,7 @@ public sealed class RewardRouterOperationsTests
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory); var deps = ResolveDependencies(factory);
var bundle = new QuestRewardBundleRow( var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)]);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
// Act // Act
@ -76,6 +76,9 @@ public sealed class RewardRouterOperationsTests
Assert.Single(result.GrantedSkillXp); Assert.Single(result.GrantedSkillXp);
Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId); Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId);
Assert.Equal(50, result.GrantedSkillXp[0].Amount); 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); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit")); Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
@ -83,6 +86,84 @@ public sealed class RewardRouterOperationsTests
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp)); Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
Assert.Equal(salvageBefore + 50, salvageXp); Assert.Equal(salvageBefore + 50, salvageXp);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); 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] [Fact]
@ -144,6 +225,7 @@ public sealed class RewardRouterOperationsTests
DeliveredAt, DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)], [new RewardItemGrantApplied("survey_drone_kit", 1)],
[new RewardSkillXpGrantApplied("salvage", 50)], [new RewardSkillXpGrantApplied("salvage", 50)],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId)); RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId));
Assert.True(deps.DeliveryStore.TryRecord(existingEvent)); Assert.True(deps.DeliveryStore.TryRecord(existingEvent));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
@ -250,6 +332,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -279,6 +363,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -299,6 +385,7 @@ public sealed class RewardRouterOperationsTests
DeliveredAt, DeliveredAt,
[], [],
[new RewardSkillXpGrantApplied("salvage", 25)], [new RewardSkillXpGrantApplied("salvage", 25)],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId)); RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
@ -318,6 +405,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
store, store,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -346,6 +435,7 @@ public sealed class RewardRouterOperationsTests
DeliveredAt, DeliveredAt,
[], [],
[new RewardSkillXpGrantApplied("salvage", 25)], [new RewardSkillXpGrantApplied("salvage", 25)],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId)); RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
@ -363,6 +453,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
store, store,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -471,6 +563,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
timeProvider); timeProvider);
private static void FillBag(RewardRouterTestDependencies deps) private static void FillBag(RewardRouterTestDependencies deps)
@ -498,7 +592,9 @@ public sealed class RewardRouterOperationsTests
services.GetRequiredService<ISkillLevelCurve>(), services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(), services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>()); services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
} }
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -537,7 +633,9 @@ public sealed class RewardRouterOperationsTests
ISkillLevelCurve LevelCurve, ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine, PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore); IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
/// <summary>TryGet misses until after a failed <see cref="IRewardDeliveryStore.TryRecord"/> — simulates concurrent race loser.</summary> /// <summary>TryGet misses until after a failed <see cref="IRewardDeliveryStore.TryRecord"/> — simulates concurrent race loser.</summary>
private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore

View File

@ -1,6 +1,7 @@
using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -90,6 +91,8 @@ public static class AbilityCastApi
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
TimeProvider clock) => TimeProvider clock) =>
{ {
@ -297,6 +300,8 @@ public static class AbilityCastApi
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
clock, clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring))); loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
@ -33,6 +34,8 @@ public static class CraftOperations
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (quantity <= 0) if (quantity <= 0)
@ -174,6 +177,8 @@ public static class CraftOperations
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
return new CraftResult( return new CraftResult(

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
@ -20,7 +21,8 @@ public static class PlayerCraftApi
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry, PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore, IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
IReputationDeltaStore auditStore, TimeProvider timeProvider) =>
{ {
if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion)
{ {
@ -54,6 +56,8 @@ public static class PlayerCraftApi
progressStore, progressStore,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
if (!result.Success && if (!result.Success &&

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
@ -35,6 +36,8 @@ public static class EncounterCombatWiring
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -99,6 +102,8 @@ public static class EncounterCombatWiring
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
if (!completion.Success) if (!completion.Success)

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
@ -38,6 +39,8 @@ public static class EncounterCompletionOperations
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) || if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
@ -160,6 +163,8 @@ public static class EncounterCompletionOperations
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
return new EncounterCompletionResult( return new EncounterCompletionResult(

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
@ -34,6 +35,8 @@ public static class GatherOperations
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
var key = ResourceNodeInstanceIds.Normalize(interactableId); var key = ResourceNodeInstanceIds.Normalize(interactableId);
@ -149,6 +152,8 @@ public static class GatherOperations
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
return new GatherResult( return new GatherResult(

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -25,7 +26,8 @@ public static class InteractionApi
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry, IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore, IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
IReputationDeltaStore auditStore, TimeProvider timeProvider) =>
{ {
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
{ {
@ -95,6 +97,8 @@ public static class InteractionApi
progressStore, progressStore,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
if (!gather.Success) if (!gather.Success)

View File

@ -20,7 +20,7 @@ public static class QuestAcceptApi
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore, IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
TimeProvider timeProvider) => IReputationDeltaStore auditStore, TimeProvider timeProvider) =>
{ {
if (body is not null && if (body is not null &&
body.SchemaVersion != 0 && body.SchemaVersion != 0 &&
@ -49,6 +49,7 @@ public static class QuestAcceptApi
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore, standingStore,
auditStore,
timeProvider); timeProvider);
return Results.Json(MapResponse(trimmedId, deliveryStore, result)); return Results.Json(MapResponse(trimmedId, deliveryStore, result));

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Rewards;
@ -28,6 +29,8 @@ public static class QuestObjectiveWiring
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -74,6 +77,8 @@ public static class QuestObjectiveWiring
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
} }
catch (Exception ex) catch (Exception ex)
@ -97,6 +102,8 @@ public static class QuestObjectiveWiring
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -143,6 +150,8 @@ public static class QuestObjectiveWiring
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
} }
catch (Exception ex) catch (Exception ex)
@ -165,6 +174,8 @@ public static class QuestObjectiveWiring
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -210,6 +221,8 @@ public static class QuestObjectiveWiring
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
} }
catch (Exception ex) catch (Exception ex)
@ -254,6 +267,8 @@ public static class QuestObjectiveWiring
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -278,6 +293,8 @@ public static class QuestObjectiveWiring
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
} }
catch (Exception ex) catch (Exception ex)
@ -343,6 +360,8 @@ public static class QuestObjectiveWiring
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++) for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++)
@ -381,6 +400,8 @@ public static class QuestObjectiveWiring
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
return; return;
} }
@ -399,6 +420,8 @@ public static class QuestObjectiveWiring
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
if (!advance.Success) if (!advance.Success)
@ -420,6 +443,8 @@ public static class QuestObjectiveWiring
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
foreach (var definition in questRegistry.GetDefinitionsInIdOrder()) foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
@ -439,6 +464,8 @@ public static class QuestObjectiveWiring
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
} }
} }

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
@ -22,7 +23,8 @@ public static class QuestProgressApi
IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry, IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve, IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger<Program>? logger) => IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
IReputationDeltaStore auditStore, TimeProvider timeProvider, ILogger<Program>? logger) =>
{ {
var trimmedId = id.Trim(); var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
@ -42,6 +44,8 @@ public static class QuestProgressApi
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider, timeProvider,
logger); logger);

View File

@ -31,6 +31,7 @@ public static class QuestStateOperations
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore, IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -100,6 +101,8 @@ public static class QuestStateOperations
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed)) if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
@ -130,6 +133,8 @@ public static class QuestStateOperations
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -185,6 +190,8 @@ public static class QuestStateOperations
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed)) if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
@ -215,6 +222,8 @@ public static class QuestStateOperations
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -250,6 +259,8 @@ public static class QuestStateOperations
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
auditStore,
timeProvider); timeProvider);
if (!delivery.Success) if (!delivery.Success)

View File

@ -29,6 +29,7 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
{ {
GrantedItems = deliveryEvent.GrantedItems.ToArray(), GrantedItems = deliveryEvent.GrantedItems.ToArray(),
GrantedSkillXp = deliveryEvent.GrantedSkillXp.ToArray(), GrantedSkillXp = deliveryEvent.GrantedSkillXp.ToArray(),
GrantedReputation = deliveryEvent.GrantedReputation.ToArray(),
}; };
return true; return true;
} }

View File

@ -11,4 +11,5 @@ public readonly record struct RewardDeliveryEvent(
DateTimeOffset DeliveredAt, DateTimeOffset DeliveredAt,
IReadOnlyList<RewardItemGrantApplied> GrantedItems, IReadOnlyList<RewardItemGrantApplied> GrantedItems,
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp, IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
IReadOnlyList<RewardReputationGrantApplied> GrantedReputation,
string IdempotencyKey); string IdempotencyKey);

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Id normalization and composite keys for reward delivery stores (NEO-126).</summary> /// <summary>Id normalization and composite keys for reward delivery stores (NEO-126).</summary>
@ -31,6 +33,22 @@ public static class RewardDeliveryIds
return $"{p}:quest_complete:{q}"; return $"{p}:quest_complete:{q}";
} }
/// <summary>Stable audit row id for one rep grant on a quest completion delivery (NEO-138).</summary>
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}";
}
/// <summary>Rollback audit row id for compensating rep revert (NEO-138).</summary>
public static string MakeReputationRollbackDeltaId(string reputationDeltaId) =>
reputationDeltaId.Length == 0 ? string.Empty : $"{reputationDeltaId}:rollback";
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="questId"/>.</summary> /// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="questId"/>.</summary>
public static string MakeStoreKey(string? playerId, string? questId) public static string MakeStoreKey(string? playerId, string? questId)
{ {

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
@ -34,4 +35,19 @@ public static class RewardDeliveryReasonCodes
/// <summary>Passthrough from <see cref="SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed"/>.</summary> /// <summary>Passthrough from <see cref="SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed"/>.</summary>
public const string SourceKindNotAllowed = SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed; public const string SourceKindNotAllowed = SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed;
/// <summary>Passthrough from <see cref="FactionStandingReasonCodes.UnknownFaction"/> via rep apply (NEO-138).</summary>
public const string UnknownFaction = FactionStandingReasonCodes.UnknownFaction;
/// <summary>Passthrough from <see cref="ReputationApplyReasonCodes.InvalidDelta"/>.</summary>
public const string InvalidDelta = ReputationApplyReasonCodes.InvalidDelta;
/// <summary>Passthrough from <see cref="ReputationApplyReasonCodes.InvalidDeltaId"/>.</summary>
public const string InvalidDeltaId = ReputationApplyReasonCodes.InvalidDeltaId;
/// <summary>Passthrough from <see cref="ReputationApplyReasonCodes.InvalidSource"/>.</summary>
public const string InvalidSource = ReputationApplyReasonCodes.InvalidSource;
/// <summary>Passthrough from <see cref="ReputationApplyReasonCodes.AuditAppendFailed"/>.</summary>
public const string AuditAppendFailed = ReputationApplyReasonCodes.AuditAppendFailed;
} }

View File

@ -6,11 +6,13 @@ namespace NeonSprawl.Server.Game.Rewards;
/// </summary> /// </summary>
/// <param name="GrantedItems">Item grants on success; empty when denied.</param> /// <param name="GrantedItems">Item grants on success; empty when denied.</param>
/// <param name="GrantedSkillXp">Skill XP grants on success; empty when denied.</param> /// <param name="GrantedSkillXp">Skill XP grants on success; empty when denied.</param>
/// <param name="GrantedReputation">Reputation grants on success; empty when denied.</param>
/// <param name="DeliveryEvent">Delivery payload on success; null when denied.</param> /// <param name="DeliveryEvent">Delivery payload on success; null when denied.</param>
public readonly record struct RewardDeliveryResult( public readonly record struct RewardDeliveryResult(
bool Success, bool Success,
string? ReasonCode, string? ReasonCode,
IReadOnlyList<RewardItemGrantApplied> GrantedItems, IReadOnlyList<RewardItemGrantApplied> GrantedItems,
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp, IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
IReadOnlyList<RewardReputationGrantApplied> GrantedReputation,
RewardDeliveryEvent? DeliveryEvent, RewardDeliveryEvent? DeliveryEvent,
DateTimeOffset? DeliveredAt); DateTimeOffset? DeliveredAt);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>One reputation grant applied on a successful quest completion reward delivery (NEO-138).</summary>
public readonly record struct RewardReputationGrantApplied(string FactionId, int Amount);

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
@ -9,15 +10,17 @@ namespace NeonSprawl.Server.Game.Rewards;
/// <summary> /// <summary>
/// Applies quest <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127). /// Applies quest <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127).
/// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128). /// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128).
/// Reputation grants: <see cref="ReputationOperations"/> (NEO-138).
/// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (NEO-130). /// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (NEO-130).
/// </summary> /// </summary>
public static class RewardRouterOperations public static class RewardRouterOperations
{ {
private static readonly RewardItemGrantApplied[] EmptyItemGrants = []; private static readonly RewardItemGrantApplied[] EmptyItemGrants = [];
private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = []; private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = [];
private static readonly RewardReputationGrantApplied[] EmptyReputationGrants = [];
/// <summary> /// <summary>
/// Applies <paramref name="bundle"/> item + skill XP rows for a quest completion and records /// Applies <paramref name="bundle"/> item, skill XP, and reputation rows for a quest completion and records
/// <see cref="RewardDeliveryEvent"/> when grants succeed. /// <see cref="RewardDeliveryEvent"/> when grants succeed.
/// </summary> /// </summary>
public static RewardDeliveryResult TryDeliverQuestCompletion( public static RewardDeliveryResult TryDeliverQuestCompletion(
@ -32,6 +35,8 @@ public static class RewardRouterOperations
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId); var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId);
@ -53,6 +58,8 @@ public static class RewardRouterOperations
var itemGrants = bundle?.ItemGrants ?? Array.Empty<RewardGrantRow>(); var itemGrants = bundle?.ItemGrants ?? Array.Empty<RewardGrantRow>();
var skillGrants = bundle?.SkillXpGrants ?? Array.Empty<QuestSkillXpGrantRow>(); var skillGrants = bundle?.SkillXpGrants ?? Array.Empty<QuestSkillXpGrantRow>();
var reputationGrants = bundle?.ReputationGrants ?? Array.Empty<ReputationGrantRow>();
var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId);
if (itemGrants.Count > 0) if (itemGrants.Count > 0)
{ {
@ -87,7 +94,6 @@ public static class RewardRouterOperations
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{ {
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing); return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing);
} }
@ -117,13 +123,45 @@ public static class RewardRouterOperations
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing) 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); return Deny(RewardDeliveryReasonCodes.ProgressionStoreMissing);
} }
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.Denied) 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; var reasonCode = skillOutcome.Response?.ReasonCode ?? RewardDeliveryReasonCodes.UnknownSkill;
return Deny(reasonCode); 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 // 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. // TryRecord race-rollback paths (grants applied then reverted below); mirror reward_delivery gating.
var appliedReputation = new List<RewardReputationGrantApplied>(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 deliveredAt = timeProvider.GetUtcNow();
var deliveryEvent = new RewardDeliveryEvent( var deliveryEvent = new RewardDeliveryEvent(
normalizedPlayerId, normalizedPlayerId,
@ -150,7 +232,8 @@ public static class RewardRouterOperations
deliveredAt, deliveredAt,
appliedItems, appliedItems,
appliedSkillXp, appliedSkillXp,
RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId)); appliedReputation,
idempotencyKey);
if (deliveryStore.TryRecord(deliveryEvent)) 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). // 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. // Not on TryGet idempotent replay, deny paths, or TryRecord race-loser rollback paths.
// Planned payload fields: playerId, questId, deliveredAt, grantedItems (itemId, quantity), // 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). // No ingest or ILogger here (comments-only).
return SuccessFromEvent(deliveryEvent, reasonCode: null); return SuccessFromEvent(deliveryEvent, reasonCode: null);
@ -166,19 +249,23 @@ public static class RewardRouterOperations
// Lost TryRecord race after apply — rollback this call's grants (mirror // Lost TryRecord race after apply — rollback this call's grants (mirror
// EncounterCompletionOperations when TryMarkCompleted returns false). // EncounterCompletionOperations when TryMarkCompleted returns false).
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); CompensatingRevertAll(
_ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants(
playerId, playerId,
normalizedQuestId,
idempotencyKey,
appliedItems,
appliedSkillXp, appliedSkillXp,
perksUnlockedByThisCall, perksUnlockedByThisCall,
skillProgressionStore, appliedReputation,
perkStore); itemRegistry,
ResyncPathAutoPerksAfterRaceRollback( inventoryStore,
playerId,
appliedSkillXp,
skillProgressionStore, skillProgressionStore,
levelCurve, levelCurve,
perkUnlockEngine); perkUnlockEngine,
perkStore,
standingStore,
auditStore,
timeProvider);
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent)) if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent))
{ {
@ -188,6 +275,79 @@ public static class RewardRouterOperations
return Deny(RewardDeliveryReasonCodes.AlreadyDelivered); return Deny(RewardDeliveryReasonCodes.AlreadyDelivered);
} }
private static void CompensatingRevertAll(
string playerId,
string normalizedQuestId,
string idempotencyKey,
IReadOnlyList<RewardItemGrantApplied> appliedItems,
IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp,
HashSet<string> perksUnlockedByThisCall,
IReadOnlyList<RewardReputationGrantApplied> 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<RewardReputationGrantApplied> 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( private static void ResyncPathAutoPerksAfterRaceRollback(
string playerId, string playerId,
IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp, IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp,
@ -219,6 +379,18 @@ public static class RewardRouterOperations
_ => reasonCode, _ => 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( private static void CompensatingRemoveItems(
string playerId, string playerId,
IReadOnlyList<RewardItemGrantApplied> grants, IReadOnlyList<RewardItemGrantApplied> grants,
@ -243,6 +415,7 @@ public static class RewardRouterOperations
ReasonCode: reasonCode, ReasonCode: reasonCode,
GrantedItems: EmptyItemGrants, GrantedItems: EmptyItemGrants,
GrantedSkillXp: EmptySkillXpGrants, GrantedSkillXp: EmptySkillXpGrants,
GrantedReputation: EmptyReputationGrants,
DeliveryEvent: null, DeliveryEvent: null,
DeliveredAt: null); DeliveredAt: null);
@ -252,6 +425,7 @@ public static class RewardRouterOperations
ReasonCode: reasonCode, ReasonCode: reasonCode,
GrantedItems: deliveryEvent.GrantedItems, GrantedItems: deliveryEvent.GrantedItems,
GrantedSkillXp: deliveryEvent.GrantedSkillXp, GrantedSkillXp: deliveryEvent.GrantedSkillXp,
GrantedReputation: deliveryEvent.GrantedReputation,
DeliveryEvent: deliveryEvent, DeliveryEvent: deliveryEvent,
DeliveredAt: deliveryEvent.DeliveredAt); DeliveredAt: deliveryEvent.DeliveredAt);
} }

View File

@ -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. **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). 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. | | **`DeliveredAt`** | UTC timestamp at delivery record commit. |
| **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. | | **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. |
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`skillId`, `amount`) 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. | | **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. |
**Store interface:** **Store interface:**
@ -359,7 +360,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp
## Reward router (NEO-127) ## 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 | | 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. | | **`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. | | **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
| **`progression_store_missing`** | Skill progression store could not apply XP delta. | | **`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) ### Reward telemetry hooks (NEO-130)
@ -379,6 +381,7 @@ Comment-only hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`*
| Event | Anchor | When | | Event | Anchor | When |
|-------|--------|------| |-------|--------|------|
| **`reward_delivery`** | **`TryDeliverQuestCompletion`** | After **`TryRecord`** returns **`true`** on first-time delivery (not idempotent **`TryGet`** replay or race-loser paths). | | **`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. | | **`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). **`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).