Merge pull request #178 from ViPro-Technologies/NEO-138-e7m3-reward-router-reputation-grants

NEO-138: Extend reward router for reputationGrants
pull/180/head
VinPropane 2026-06-15 23:16:54 -04:00 committed by GitHub
commit e405d43d43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1642 additions and 48 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.
Accept auto-completes: operator-chain reward already granted survey_drone_kit, satisfying grid contract inventory_has_item objective on activation.
}
script:pre-request {
const { completeOperatorChainQuestFlow } = require("./scripts/operator-chain-quest-flow-helper");
await completeOperatorChainQuestFlow(bru);
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_grid_contract/accept
body: json
auth: none
}
body:json {
{
"schemaVersion": 1
}
}
tests {
test("accepts grid contract when operator chain rep grant met minStanding", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.accepted).to.equal(true);
expect(body.reasonCode).to.equal(undefined);
expect(body.quest).to.be.an("object");
expect(body.quest.questId).to.equal("prototype_quest_grid_contract");
expect(body.quest.status).to.equal("completed");
});
}

View File

@ -6,8 +6,8 @@ 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 resets grid contract progress and prototype faction standing (stale state from prior seq 11 runs), then marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing 0). Quest fixture resetQuestIds also clears reward delivery + audit rows for reset quests.
Success 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 {
@ -16,11 +16,18 @@ script:pre-request {
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } }; const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const chainQuestId = "prototype_quest_operator_chain"; const chainQuestId = "prototype_quest_operator_chain";
const gridContractQuestId = "prototype_quest_grid_contract";
const prototypeFactionIds = [
"prototype_faction_grid_operators",
"prototype_faction_rust_collective",
];
const fixture = await axios.post( const fixture = await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`, `${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
{ {
schemaVersion: 1, schemaVersion: 1,
resetQuestIds: [gridContractQuestId],
resetFactionIds: prototypeFactionIds,
completedQuestIds: [chainQuestId], completedQuestIds: [chainQuestId],
}, },
{ ...jsonHeaders, validateStatus: () => true }, { ...jsonHeaders, validateStatus: () => true },

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

@ -0,0 +1,198 @@
# NEO-138 — E7M3-06: Extend RewardRouterOperations for reputationGrants
**Linear:** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)
**Branch:** `NEO-138-e7m3-reward-router-reputation-grants`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-06**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-127](NEO-127-implementation-plan.md) — grant order + compensating rollback + `TryRecord` race mirror; [NEO-136](NEO-136-implementation-plan.md) — `ReputationOperations.TryApplyDelta` orchestration
**Precursor:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) **`Done`** — `ReputationOperations.TryApplyDelta` (**landed on `main`**)
**Blocks:** [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) (HTTP quest projections for rep + gates)
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD gate feedback (blocked by E7M3-07/E7M3-08)
## Goal
Quest completion bundles apply **`reputationGrants`** idempotently alongside existing item and skill XP rows, using the same **`{playerId}:quest_complete:{questId}`** delivery key.
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|----------------------|--------|
| Bruno scope | NEO-137 deferred grid-contract accept-success Bruno; backlog lists standing Bruno as optional (no GET until NEO-139). | **Quest-flow Bruno only** — complete operator chain via HTTP quest flow, then accept grid contract success; defer standing GET assertions to NEO-139. | **Adopted** — quest-flow Bruno |
Other decisions settled by Linear AC, backlog kickoff defaults, and repo precedent (no additional questions):
| Topic | Decision | Evidence |
|-------|----------|----------|
| Grant order | **Items → skill XP → reputation**; deny before **`TryRecord`** rolls back all prior grant types | NEO-127 item→skill order; backlog “with item/skill XP rows” |
| Idempotency | Same delivery key; **`TryGet`** replay returns stored snapshot without re-applying standing | Backlog kickoff: “Rep grants ride `{playerId}:quest_complete:{questId}`” |
| Rep apply path | **`ReputationOperations.TryApplyDelta`** only — not standing store directly | NEO-136 README + NEO-136 plan |
| Source attribution | **`ReputationDeltaSourceKinds.QuestCompletion`** + quest id as **`sourceId`** | NEO-136 adopted; server README |
| **`deltaId`** | **`{idempotencyKey}:rep:{factionId}`** per grant row | NEO-136 open-questions table |
| Unknown faction at runtime | **Deny delivery** via ops passthrough **`unknown_faction`**; no store record | Linear AC; startup cross-ref (NEO-134) prevents prod content errors |
| **`TryRecord` race** | Roll back items, skill XP, **and** rep grants on loser (mirror NEO-127) | Backlog compensating rollback policy |
| Rep rollback helper | Inverse delta via **`ReputationOperations`** with rollback **`deltaId`** suffix (e.g. `:rollback`) | NEO-136 “do not call standing store alone” |
| HTTP standing / projections | **Out of scope** | E7M3-07 (NEO-139), E7M3-08 (NEO-140) |
| Telemetry | **Comment-only `reputation_delta` hook** on successful rep apply in router (NEO-141 consolidates) | NEO-137 gate hook precedent |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Extend **`RewardRouterOperations.TryDeliverQuestCompletion`** to apply **`bundle.ReputationGrants`** via **`ReputationOperations.TryApplyDelta`**.
- Extend **`RewardDeliveryEvent`**, **`RewardDeliveryResult`**, and store snapshots with rep grant lines.
- Add router reason-code passthrough for rep denies (**`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, etc.).
- Compensating rollback: rep deny after items/xp applied; rep revert on **`TryRecord`** race loss.
- Inject **`IFactionStandingStore`** + **`IReputationDeltaStore`** through **`TryDeliverQuestCompletion`**, **`QuestStateOperations.TryMarkComplete`**, **`QuestObjectiveWiring`**, and test helpers.
- Integration tests: operator-chain completion → **+15** Grid Operators once; replay idempotent; unknown faction deny before store record.
- **Quest-flow Bruno:** operator chain complete via HTTP + grid contract accept success (closes NEO-137 deferral).
- `server/README.md` reward router section update.
- E7.M3 module + alignment register updates when shipped.
**Out of scope:**
- HTTP GET standing (NEO-139), quest HTTP rep projections (NEO-140), Godot HUD (NEO-142).
- E9.M1 telemetry ingest (NEO-141 documents hooks only).
- **`QuestFixtureOperations`** reward delivery (fixture remains progress-only; Bruno success uses organic chain completion).
- Postgres migration for **`RewardDeliveryEvent`** shape (in-memory store only today; rep snapshot fields additive).
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — surfaces rep/gate feedback after NEO-139/NEO-140.
## Acceptance criteria checklist
- [x] First-time quest completion applies rep grants exactly once per delivery key.
- [x] Unknown faction id in bundle denies delivery before store record.
- [x] `dotnet test` covers rep happy path, idempotent replay, unknown-faction deny, and rollback paths.
## Technical approach
### 1. Types (`Game/Rewards/`)
| Type | Role |
|------|------|
| **`RewardReputationGrantApplied`** | Snapshot row: `FactionId`, `Amount` (applied delta amount from ops outcome) |
| **`RewardDeliveryReasonCodes`** (extend) | Passthrough: **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** from rep ops |
Extend **`RewardDeliveryEvent`** and **`RewardDeliveryResult`** with **`GrantedReputation`** (`IReadOnlyList<RewardReputationGrantApplied>`).
Add **`RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, factionId)`** → **`{key}:rep:{factionId}`**.
### 2. `RewardRouterOperations.TryDeliverQuestCompletion`
**Signature additions:** `IFactionStandingStore standingStore`, `IReputationDeltaStore auditStore`.
**Flow (after existing skill XP loop, before `TryRecord`):**
1. Read **`reputationGrants`** from bundle (empty ⇒ skip).
2. For each row, call **`ReputationOperations.TryApplyDelta`** with:
- `sourceKind` = **`ReputationDeltaSourceKinds.QuestCompletion`**
- `sourceId` = normalized quest id
- `deltaId` = **`MakeReputationDeltaId(idempotencyKey, factionId)`**
3. On any rep deny: **`CompensatingRevertAll`** — remove items, revert skill XP (existing helpers), revert applied rep rows via inverse **`TryApplyDelta`** with `:rollback` delta ids.
4. Build **`RewardDeliveryEvent`** including **`GrantedReputation`** snapshot.
5. On **`TryRecord`** race loss: extend existing rollback to include rep revert (same compensating helper).
**Idempotent replay:** unchanged — early **`TryGet`** returns stored event including rep snapshot; no standing mutation.
**Empty bundle:** still records empty rep array when **`TryRecord`** succeeds (mirror empty item/skill arrays).
### 3. Wiring
| Call site | Change |
|-----------|--------|
| **`QuestStateOperations.TryMarkComplete`** | Pass standing + audit stores to router |
| **`QuestObjectiveWiring`** | Thread stores into **`TryMarkComplete`** (terminal step + bulk complete paths) |
| Test helpers | **`QuestTestDependencies`**, **`QuestWiringTestDependencies`**, **`RewardRouterTestDependencies`** — resolve faction stores from factory |
### 4. Prototype test scenarios
| Scenario | Setup | Expected |
|----------|-------|----------|
| Operator chain first complete | Prereqs done; accept chain; **`TryMarkComplete`** | Standing **+15** Grid Operators; delivery event rep snapshot; audit row queryable |
| Idempotent replay | Second **`TryDeliverQuestCompletion`** | **`already_delivered`**; standing unchanged |
| Unknown faction | Test bundle with fake **`factionId`** | **`unknown_faction`** deny; no delivery store record; items/xp unchanged if rep-only bundle |
| Rep deny after items | Mock/inject audit append failure on second rep row of multi-row bundle (unit) | Items + skill + first rep rolled back |
| Grid contract gate | Complete operator chain (applies +15); accept grid contract | Accept succeeds (integration — extends NEO-137 gate success test without manual standing seed) |
Constants: **`PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles`** operator chain rep row; **`GridContractMinStanding`** = 15.
### 5. Bruno (quest-flow)
| Request | Role |
|---------|------|
| **`Complete operator chain quest flow.bru`** (or pre-request helper in **`bruno-dev-fixture-helper.js`**) | Reset player; complete gather/refine/combat intros; accept + finish operator chain via HTTP (gather/craft/encounter wiring) so completion delivers **+15** rep organically |
| **`Accept grid contract after operator chain.bru`** | POST accept **`prototype_quest_grid_contract`** ⇒ **`accepted: true`** (implies standing ≥ 15 from chain rep grant) |
No standing GET assertions until NEO-139. Update deny Bruno docs to note success sibling at seq **11+**.
### 6. Tests
**`RewardRouterOperationsTests`** (extend):
| Test | Covers |
|------|--------|
| `TryDeliverQuestCompletion_ShouldApplyReputationGrants_WhenOperatorChainBundle` | +15 standing; rep snapshot on event; audit row |
| `TryDeliverQuestCompletion_ShouldNotDoubleApplyReputation_WhenAlreadyDelivered` | Idempotent replay |
| `TryDeliverQuestCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord` | Fake faction in test bundle |
| `TryDeliverQuestCompletion_ShouldRollbackItemsAndSkillXp_WhenReputationApplyDenied` | Rep deny mid-bundle |
| Extend race-rollback tests | Rep reverted on **`TryRecord`** loser |
**`QuestStateOperationsTests`** (extend):
| Test | Covers |
|------|--------|
| Extend `TryMarkComplete_ShouldDeliverOperatorChainBundle_WhenFirstCompletion` | Assert **`GrantedReputation`** + standing **15** |
| `TryAccept_ShouldAcceptGridContract_WhenOperatorChainCompletedWithRepGrant` | End-to-end: complete chain via ops (no manual **`SeedGridStanding`**) |
Manual QA: **none** — server engine; Bruno quest-flow smoke replaces player-visible checklist until NEO-142.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Rewards/RewardReputationGrantApplied.cs` | Rep grant snapshot row on delivery event |
| `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` | Grid contract accept success after organic chain completion |
| `bruno/neon-sprawl-server/quest-progress/scripts/operator-chain-quest-flow-helper.js` (or extend **`bruno-dev-fixture-helper.js`**) | HTTP helper to complete operator chain quest flow for Bruno pre-requests |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | Apply rep grants; compensating rollback; inject faction stores |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` | Add **`GrantedReputation`** field |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs` | Add **`GrantedReputation`** field |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs` | Rep deny passthrough constants |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs` | **`MakeReputationDeltaId`** helper |
| `server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs` | Clone **`GrantedReputation`** array on record |
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Pass standing + audit stores to router in **`TryMarkComplete`** |
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Thread stores into **`TryMarkComplete`** call sites |
| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | Rep grant AAA tests; extend **`Deliver`** helper + deps |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Operator chain rep assertions; grid contract accept without manual seed |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Update **`TryMarkComplete`** helper signature |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Update **`TryMarkComplete`** helper signature |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | Update any **`TryMarkComplete`** call sites if present |
| `server/README.md` | Document rep grant apply order, rollback, reason codes, Bruno seq |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status: E7M3-06 in progress / landed |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-138 plan + shipped router extension |
| `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` | Docs: success sibling request added in NEO-138 |
## Tests
| File | Action | Coverage |
|------|--------|----------|
| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | **Change** | Rep happy path, idempotent replay, unknown faction deny, rep-failure rollback, race rep rollback |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | **Change** | Operator chain +15 standing; grid contract accept after chain completion |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | **Change** | Helper signature only (compile fix) |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | **Change** | Helper signature only (compile fix) |
No new Postgres integration tests — rep persistence covered by NEO-135/NEO-136; router tests use in-memory stores.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Operator chain Bruno weight | Encapsulate in shared JS helper; reuse craft/gather/encounter patterns from existing spines | `adopted` |
| Rollback audit rows on compensating rep revert | Prototype: inverse **`TryApplyDelta`** with rollback **`deltaId`**; no delete of failed-forward audit row if append succeeded then race — mirror skill XP revert policy | `adopted` |
| **`completionRewardSummary`** rep fields | **Defer to NEO-140** — Bruno success asserts accept only, not summary shape | `adopted` |
| Concurrent rep + standing reads during gate eval | Out of scope — accept gate reads standing before chain completion rep in separate requests | `deferred` |
**NEO-140** (HTTP quest projections) and **NEO-141** (telemetry README) follow this router extension landing.

View File

@ -0,0 +1,84 @@
# Code review — NEO-138 (E7M3-06)
**Date:** 2026-06-15
**Scope:** Branch `NEO-138-e7m3-reward-router-reputation-grants` — commits `599d87b``69c3ad4` vs `main`
**Base:** `main`
**Follow-up:** Re-reviewed after `69c3ad4` (code-review follow-ups). Prior blocking/suggestions verified in code; **810** tests green.
## Verdict
**Approve**
## Summary
This branch lands **E7M3-06**: **`RewardRouterOperations.TryDeliverQuestCompletion`** applies **`reputationGrants`** via **`ReputationOperations.TryApplyDelta`** after items and skill XP, with compensating rollback (including inverse rep deltas with `:rollback` suffix) and **`GrantedReputation`** on delivery event/result snapshots. Wiring threads **`IFactionStandingStore`** + **`IReputationDeltaStore`** through quest state, objective wiring, and gameplay APIs. Tests cover happy path, idempotent replay, unknown-faction deny with item/skill rollback, and quest-state integration (operator chain +15 standing, grid contract accept after organic chain completion). Bruno adds **`operator-chain-quest-flow-helper.js`** and **`Accept grid contract after operator chain.bru`**. **`810`** server tests pass locally. Core router logic matches NEO-127/NEO-136 precedent and the implementation plan.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-138-implementation-plan.md` | **Matches** — router apply order, idempotency, rollback, wiring, README, module/alignment updates, Bruno quest-flow, and plan-listed tests (including race rep rollback and audit-row assertion). |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-06 rep grants on quest completion bundle via router; HTTP standing / client HUD explicitly deferred. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-06 landed (NEO-138); rep flow through E7.M2 router documented. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — bundle apply + idempotent delivery store extended with rep rows (implicit via router). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for NEO-138 router extension. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register row edit required beyond module doc status. |
| `server/README.md` | **Matches** — reward router section documents rep apply order, reason codes, rollback policy, Bruno seq **11** success sibling, telemetry hook comment for **`reputation_delta`**. |
## Blocking issues
1. ~~**Bruno success smoke asserts wrong quest status after accept.** `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` expects `body.quest.status === "active"` and `currentStepIndex === 0`. After organic operator-chain completion the player receives **`survey_drone_kit`** in the completion bundle. **`prototype_quest_grid_contract`** has a single **`inventory_has_item`** objective for that item. **`QuestStateOperations.TryAccept`** runs **`QuestObjectiveWiring.TryCompleteStepIfSatisfied`** immediately after activation, so accept auto-completes the quest and returns **`completed`** (integration test `TryAccept_ShouldAcceptGridContract_WhenOperatorChainCompletedWithRepGrant` correctly asserts **`QuestProgressStatus.Completed`**). CI runs the full quest-progress collection with **`--bail`**; seq **11** will fail on the status assertion even when the gate and rep grant work. Fix: expect **`completed`** (and optionally assert Rust Collective +10 standing or delivery event), or change content/fixture so the kit is not held at accept time if **`active`** is the intended smoke signal.~~ **Done.** Bruno expects **`completed`**; docs note auto-complete when kit already held.
## Suggestions
1. ~~**Add rep rollback on `TryRecord` race loss.** Extend `TryDeliverQuestCompletion_ShouldRollbackGrants_WhenTryRecordLosesRace` (or add a sibling) with an operator-chain bundle including **`reputationGrants`** and assert standing returns to pre-call value after simulated race loss — listed explicitly in NEO-138 plan §6 tests table.~~ **Done.** `TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace`.
2. ~~**Assert audit row on operator-chain router happy path.** Plan scenario “audit row queryable” — add `IReputationDeltaStore` lookup for `{idempotencyKey}:rep:{factionId}` in `TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle` (or the dedicated rep test).~~ **Done.** Audit row asserted in operator-chain bundle test.
3. ~~**Document auto-complete on grid-contract accept in tests/README.** Integration test name says “Accept” but the meaningful gate check is standing ≥ 15; the returned snapshot is **`completed`** because the kit is already in inventory. A one-line comment in `QuestStateOperationsTests` and Bruno **docs** block would prevent future “active vs completed” confusion.~~ **Done.** Comment in `QuestStateOperationsTests`; Bruno docs block updated.
4. ~~**Optional: multi-row rep deny rollback test.** Plan lists rep deny after items with audit append failure on the second rep row; only **`unknown_faction`** rollback is covered today. Consider a store stub that fails **`TryAppend`** on the second row to lock compensating rollback across grant types.~~ **Done.** `TryDeliverQuestCompletion_ShouldRollbackAllGrants_WhenSecondReputationAuditAppendFails` with forward-rep-only audit stub.
5. ~~**Bugbot: fixture reset skips delivery store.** `resetQuestIds` cleared quest progress and faction standing but not **`IRewardDeliveryStore`** / audit rows, so repeated Bruno runs skipped rep re-apply via idempotent delivery (and duplicate audit ids).~~ **Done.** `resetQuestIds` clears delivery + quest-completion audit rows.
## Nits
- ~~Nit: `TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull` does not assert `GrantedReputation` is empty — mirror item/skill empty assertions for snapshot parity.~~ **Done.**
- Nit: `CompensatingRevertReputation` skips `Amount == 0` rows; fine for prototype bundles but a clamp-to-zero edge case could leave standing changed while snapshot says 0 — defer unless content adds negative grants near floor.
- Nit (re-review): `server/README.md` FactionGateOperations section still says grid-contract success Bruno is “deferred until NEO-138” — stale; seq **11** landed. One-line README tweak optional before merge.
- Nit (re-review): `TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace` asserts standing only on a full operator-chain bundle; item/skill rollback on the same path is covered by the older skill-only race test + shared `CompensatingRevertAll` — acceptable, not blocking.
## Re-review (2026-06-15)
| Original finding | Verified |
|------------------|----------|
| Bruno seq **11** status `active` vs `completed` | **Fixed**`Accept grid contract after operator chain.bru` expects `completed`; docs note auto-complete when kit held. |
| Race rep rollback test | **Fixed**`TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace`. |
| Audit row on operator-chain happy path | **Fixed** — delta id + row fields asserted in bundle test. |
| Grid-contract accept comment | **Fixed**`QuestStateOperationsTests` Arrange comment + Bruno docs. |
| Multi-row audit-append rollback | **Fixed**`AuditStoreFailingOnSecondForwardRepAppend` + full grant rollback test. |
| Empty `GrantedReputation` on null bundle | **Fixed** — result + stored event asserted. |
No new blocking issues from follow-up commit. Test stubs (`AuditStoreFailingOnSecondForwardRepAppend`, race-loss store) are sound; AAA layout on new tests matches project conventions.
## Verification
```bash
# Full server suite (810 green locally)
dotnet test NeonSprawl.sln
# NEO-138-focused filter
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~RewardRouterOperations|FullyQualifiedName~GridContract|FullyQualifiedName~OperatorChain"
# Bruno quest-progress (seq 11 expects completed after kit held)
cd bruno/neon-sprawl-server
npx --yes @usebruno/cli@3.3.0 run quest-progress --env Local --sandbox=developer --bail
```
With Postgres + dev fixtures (matches CI):
```bash
ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev' \
Game__EnableCombatTargetFixtureApi=true \
Game__EnableQuestFixtureApi=true \
Game__EnableResourceNodeFixtureApi=true \
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj --urls http://127.0.0.1:5253
```
Manual: run **`Accept grid contract after operator chain.bru`**; confirm **`accepted: true`** and quest row **`completed`** when **`survey_drone_kit`** is already in bag.

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

@ -134,6 +134,20 @@ public sealed class InMemoryFactionStandingStoreTests
Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing); Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing);
} }
[Fact]
public void TryClearStanding_ShouldReturnZeroRead_WhenRowRemoved()
{
// Arrange
var store = CreateStore();
store.SeedRawStandingForTests(PlayerId, GridFactionId, 15);
Assert.Equal(15, store.TryGetStanding(PlayerId, GridFactionId).Standing);
// Act
var cleared = store.TryClearStanding(PlayerId, GridFactionId);
// Assert
Assert.True(cleared);
Assert.Equal(0, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact] [Fact]
public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown() public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown()
{ {

View File

@ -102,6 +102,31 @@ public sealed class InMemoryReputationDeltaStoreTests
Assert.Equal("delta-1", rows[0].Id); Assert.Equal("delta-1", rows[0].Id);
} }
[Fact]
public void TryClearQuestCompletionAudit_ShouldRemoveMatchingRows_WhenPresent()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryAppend(CreateRow("delta-forward", 15, 15)));
Assert.True(store.TryAppend(CreateRow("delta-rollback", -15, 0) with
{
Id = "delta-forward:rollback",
}));
Assert.True(store.TryAppend(CreateRow("delta-other-quest", 10, 10) with
{
Id = "other-quest-delta",
SourceId = "prototype_quest_gather_intro",
}));
// Act
var cleared = store.TryClearQuestCompletionAudit(PlayerId, "prototype_quest_operator_chain");
// Assert
Assert.True(cleared);
Assert.DoesNotContain(
store.GetDeltasForPlayer(PlayerId),
row => row.SourceId == "prototype_quest_operator_chain");
Assert.Single(store.GetDeltasForPlayer(PlayerId));
}
private static InMemoryReputationDeltaStore CreateStore() => new(); private static InMemoryReputationDeltaStore CreateStore() => new();
private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) => private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) =>

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

@ -3,7 +3,9 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -188,4 +190,85 @@ public sealed class QuestFixtureApiTests
Assert.NotNull(body); Assert.NotNull(body);
Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status)); Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status));
} }
[Fact]
public async Task PostQuestFixture_ShouldClearFactionStanding_WhenResetFactionIdsProvided()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
const string gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
var apply = ReputationOperations.TryApplyDelta(
DevPlayer,
gridFactionId,
15,
"fixture-seed-standing",
ReputationDeltaSourceKinds.QuestCompletion,
ChainQuestId,
standingStore,
factory.Services.GetRequiredService<IReputationDeltaStore>(),
factory.Services.GetRequiredService<TimeProvider>());
Assert.True(apply.Success);
Assert.Equal(15, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing);
// Act
var reset = await client.PostAsJsonAsync(
FixturePath,
new QuestFixtureRequest
{
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
ResetFactionIds = [gridFactionId],
});
// Assert
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
Assert.Equal(0, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing);
}
[Fact]
public async Task PostQuestFixture_ShouldClearRewardDeliveryAndAudit_WhenResetQuestIdsProvided()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var deliveryStore = factory.Services.GetRequiredService<IRewardDeliveryStore>();
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
const string gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
var apply = ReputationOperations.TryApplyDelta(
DevPlayer,
gridFactionId,
15,
RewardDeliveryIds.MakeReputationDeltaId(
RewardDeliveryIds.MakeIdempotencyKey(DevPlayer, ChainQuestId),
gridFactionId),
ReputationDeltaSourceKinds.QuestCompletion,
ChainQuestId,
factory.Services.GetRequiredService<IFactionStandingStore>(),
auditStore,
factory.Services.GetRequiredService<TimeProvider>());
Assert.True(apply.Success);
Assert.True(deliveryStore.TryRecord(new RewardDeliveryEvent(
DevPlayer,
ChainQuestId,
DateTimeOffset.UtcNow,
[],
[],
[new RewardReputationGrantApplied(gridFactionId, 15)],
RewardDeliveryIds.MakeIdempotencyKey(DevPlayer, ChainQuestId))));
// Act
var reset = await client.PostAsJsonAsync(
FixturePath,
new QuestFixtureRequest
{
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
ResetQuestIds = [ChainQuestId],
});
// Assert
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
Assert.False(deliveryStore.TryGet(DevPlayer, ChainQuestId, out _));
Assert.Empty(auditStore.GetDeltasForPlayer(DevPlayer));
}
} }

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,41 @@ 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 — operator-chain completion grants survey_drone_kit; grid contract inventory_has_item
// objective satisfies on accept, so TryAccept returns completed (not active). Gate check is standing >= 15.
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndComplete(deps, GatherQuestId);
AcceptAndComplete(deps, RefineQuestId);
AcceptAndComplete(deps, CombatQuestId);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(TryMarkComplete(deps, ChainQuestId).Success);
// Act
var result = TryAccept(deps, GridContractQuestId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
Assert.Equal(
PrototypeE7M3QuestFactionRules.GridContractMinStanding,
deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
} }
[Fact] [Fact]
@ -575,6 +607,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 +629,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 +647,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

@ -99,6 +99,31 @@ public sealed class InMemoryRewardDeliveryStoreTests
Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey); Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey);
} }
[Fact]
public void TryClear_ShouldRemoveDeliveryRow_WhenPresent()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var deliveryEvent = CreatePrototypeEvent();
Assert.True(store.TryRecord(deliveryEvent));
// Act
var cleared = store.TryClear(PlayerId, QuestId);
// Assert
Assert.True(cleared);
Assert.False(store.TryGet(PlayerId, QuestId, out _));
}
[Fact]
public void TryClear_ShouldReturnTrue_WhenRowAlreadyAbsent()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
// Act
var cleared = store.TryClear(PlayerId, QuestId);
// Assert
Assert.True(cleared);
}
private static RewardDeliveryEvent CreatePrototypeEvent() => private static RewardDeliveryEvent CreatePrototypeEvent() =>
new( new(
PlayerId, PlayerId,
@ -106,5 +131,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,8 @@ 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 RustCollectiveFactionId = "prototype_faction_rust_collective";
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 +42,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 +62,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 +77,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 +87,146 @@ 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);
var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId);
var expectedDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, GridOperatorsFactionId);
var auditRow = Assert.Single(
deps.AuditStore.GetDeltasForPlayer(PlayerId),
row => row.Id == expectedDeltaId);
Assert.Equal(GridOperatorsFactionId, auditRow.FactionId);
Assert.Equal(15, auditRow.DeltaAmount);
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, auditRow.SourceKind);
Assert.Equal(OperatorChainQuestId, auditRow.SourceId);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldNotDoubleApplyReputation_WhenAlreadyDelivered()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
// Act
var first = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
var second = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
// Assert
Assert.True(first.Success);
Assert.Single(first.GrantedReputation);
Assert.Equal(15, first.GrantedReputation[0].Amount);
Assert.True(second.Success);
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode);
Assert.Equal(
standingBefore + 15,
deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = new QuestRewardBundleRow(
[],
[],
[new ReputationGrantRow("prototype_faction_unknown", 10)]);
// Act
var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
Assert.Equal(0, deps.StandingStore.TryGetStanding(PlayerId, "prototype_faction_unknown").Standing);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRollbackItemsAndSkillXp_WhenReputationApplyDenied()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
var bundle = new QuestRewardBundleRow(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)],
[new ReputationGrantRow("prototype_faction_unknown", 10)]);
// Act
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(
CountBagItem(beforeInventory!, "survey_drone_kit"),
CountBagItem(afterInventory!, "survey_drone_kit"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
Assert.Equal(salvageBefore, salvageAfter);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRollbackAllGrants_WhenSecondReputationAuditAppendFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var auditStore = new AuditStoreFailingOnSecondForwardRepAppend();
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
var gridStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
var rustStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing;
var bundle = new QuestRewardBundleRow(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)],
[
new ReputationGrantRow(GridOperatorsFactionId, 15),
new ReputationGrantRow(RustCollectiveFactionId, 10),
]);
// Act
var result = RewardRouterOperations.TryDeliverQuestCompletion(
PlayerId,
OperatorChainQuestId,
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.AuditAppendFailed, result.ReasonCode);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(
CountBagItem(beforeInventory!, "survey_drone_kit"),
CountBagItem(afterInventory!, "survey_drone_kit"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
Assert.Equal(salvageBefore, salvageAfter);
Assert.Equal(gridStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
Assert.Equal(rustStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
Assert.Equal(2, auditStore.GetDeltasForPlayer(PlayerId).Count);
} }
[Fact] [Fact]
@ -104,6 +248,7 @@ public sealed class RewardRouterOperationsTests
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode); Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
Assert.Empty(result.GrantedItems); Assert.Empty(result.GrantedItems);
Assert.Empty(result.GrantedSkillXp); Assert.Empty(result.GrantedSkillXp);
Assert.Empty(result.GrantedReputation);
Assert.Null(result.DeliveryEvent); Assert.Null(result.DeliveryEvent);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
} }
@ -144,6 +289,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);
@ -220,10 +366,12 @@ public sealed class RewardRouterOperationsTests
Assert.Null(result.ReasonCode); Assert.Null(result.ReasonCode);
Assert.Empty(result.GrantedItems); Assert.Empty(result.GrantedItems);
Assert.Empty(result.GrantedSkillXp); Assert.Empty(result.GrantedSkillXp);
Assert.Empty(result.GrantedReputation);
Assert.NotNull(result.DeliveryEvent); Assert.NotNull(result.DeliveryEvent);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored)); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
Assert.Empty(stored.GrantedItems); Assert.Empty(stored.GrantedItems);
Assert.Empty(stored.GrantedSkillXp); Assert.Empty(stored.GrantedSkillXp);
Assert.Empty(stored.GrantedReputation);
Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId)); Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!)); Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!));
@ -250,6 +398,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 +429,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 +451,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 +471,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
@ -329,6 +484,47 @@ public sealed class RewardRouterOperationsTests
Assert.Equal(salvageBefore, salvageAfter); Assert.Equal(salvageBefore, salvageAfter);
} }
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
var winnerEvent = new RewardDeliveryEvent(
PlayerId,
OperatorChainQuestId,
DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)],
[new RewardSkillXpGrantApplied("salvage", 50)],
[new RewardReputationGrantApplied(GridOperatorsFactionId, 15)],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId));
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
// Act
var result = RewardRouterOperations.TryDeliverQuestCompletion(
PlayerId,
OperatorChainQuestId,
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
store,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System);
// Assert
Assert.True(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
Assert.Equal(standingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
}
[Fact] [Fact]
public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp() public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp()
{ {
@ -346,6 +542,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 +560,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 +670,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 +699,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 +740,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
@ -561,5 +766,49 @@ public sealed class RewardRouterOperationsTests
deliveryEvent = winnerEvent; deliveryEvent = winnerEvent;
return true; return true;
} }
public bool TryClear(string playerId, string questId) => true;
}
/// <summary>Second forward rep audit append fails; rollback appends still succeed.</summary>
private sealed class AuditStoreFailingOnSecondForwardRepAppend : IReputationDeltaStore
{
private int forwardRepAppendCount;
private readonly List<ReputationDeltaRow> rows = [];
public bool TryAppend(ReputationDeltaRow row)
{
if (row.Id.EndsWith(":rollback", StringComparison.Ordinal))
{
rows.Add(row);
return true;
}
if (row.Id.Contains(":rep:", StringComparison.Ordinal))
{
forwardRepAppendCount++;
if (forwardRepAppendCount >= 2)
{
return false;
}
}
rows.Add(row);
return true;
}
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
{
var normalized = FactionStandingIds.NormalizePlayerId(playerId);
var filtered = rows.Where(r => r.PlayerId == normalized).ToList();
if (limit is > 0)
{
return filtered.Take(limit.Value).ToList();
}
return filtered;
}
public bool TryClearQuestCompletionAudit(string playerId, string questId) => true;
} }
} }

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

@ -20,4 +20,10 @@ public interface IFactionStandingStore
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136. /// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
/// </summary> /// </summary>
FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount); FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount);
/// <summary>
/// Removes persisted standing for one faction (missing row reads as neutral <c>0</c>).
/// Dev fixture / Bruno reset only — not for gameplay rollback.
/// </summary>
bool TryClearStanding(string playerId, string factionId);
} }

View File

@ -11,4 +11,10 @@ public interface IReputationDeltaStore
/// <summary>Audit rows for one player ordered by <see cref="ReputationDeltaRow.RecordedAt"/> then <c>Id</c> ascending.</summary> /// <summary>Audit rows for one player ordered by <see cref="ReputationDeltaRow.RecordedAt"/> then <c>Id</c> ascending.</summary>
IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null); IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null);
/// <summary>
/// Dev fixture only — removes quest-completion audit rows for one quest (includes rollback rows).
/// Idempotent when no rows match.
/// </summary>
bool TryClearQuestCompletionAudit(string playerId, string questId);
} }

View File

@ -99,6 +99,35 @@ public sealed class InMemoryFactionStandingStore(
} }
} }
/// <inheritdoc />
public bool TryClearStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0)
{
return false;
}
if (!factionRegistry.TryGetDefinition(faction, out _))
{
return false;
}
if (!CanWritePlayer(player))
{
return false;
}
var key = FactionStandingIds.MakeStandingKey(player, faction);
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
standingByKey.TryRemove(key, out _);
}
return true;
}
private static FactionStandingReadOutcome DenyRead(string reasonCode) => private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
new(false, reasonCode, 0); new(false, reasonCode, 0);

View File

@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Factions; namespace NeonSprawl.Server.Game.Factions;
@ -60,6 +61,33 @@ public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
return query.ToArray(); return query.ToArray();
} }
/// <inheritdoc />
public bool TryClearQuestCompletionAudit(string playerId, string questId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var sourceId = RewardDeliveryIds.NormalizeQuestId(questId);
if (player.Length == 0 || sourceId.Length == 0)
{
return false;
}
var idsToRemove = rowsById.Values
.Where(row =>
string.Equals(row.PlayerId, player, StringComparison.Ordinal) &&
string.Equals(row.SourceKind, ReputationDeltaSourceKinds.QuestCompletion, StringComparison.Ordinal) &&
string.Equals(row.SourceId, sourceId, StringComparison.Ordinal))
.Select(static row => row.Id)
.ToArray();
foreach (var id in idsToRemove)
{
rowsById.TryRemove(id, out _);
idLocks.TryRemove(id, out _);
}
return true;
}
private static bool IsValidRow(ReputationDeltaRow row) => private static bool IsValidRow(ReputationDeltaRow row) =>
row.Id.Trim().Length > 0 && row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&

View File

@ -136,6 +136,40 @@ public sealed class PostgresFactionStandingStore(
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding); return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
} }
/// <inheritdoc />
public bool TryClearStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0)
{
return false;
}
if (!factionRegistry.TryGetDefinition(faction, out _))
{
return false;
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, player))
{
return false;
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
DELETE FROM player_faction_standing
WHERE player_id = @pid AND faction_id = @fid;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("fid", faction);
cmd.ExecuteNonQuery();
return true;
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null) private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
{ {
using var cmd = new Npgsql.NpgsqlCommand( using var cmd = new Npgsql.NpgsqlCommand(

View File

@ -1,5 +1,7 @@
namespace NeonSprawl.Server.Game.Factions; namespace NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Rewards;
/// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary> /// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary>
public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore
{ {
@ -91,6 +93,33 @@ public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSou
return rows; return rows;
} }
/// <inheritdoc />
public bool TryClearQuestCompletionAudit(string playerId, string questId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var sourceId = RewardDeliveryIds.NormalizeQuestId(questId);
if (player.Length == 0 || sourceId.Length == 0)
{
return false;
}
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
DELETE FROM reputation_delta_audit
WHERE player_id = @pid
AND source_kind = @kind
AND source_id = @source_id;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("kind", ReputationDeltaSourceKinds.QuestCompletion);
cmd.Parameters.AddWithValue("source_id", sourceId);
cmd.ExecuteNonQuery();
return true;
}
private static bool IsValidRow(ReputationDeltaRow row) => private static bool IsValidRow(ReputationDeltaRow row) =>
row.Id.Trim().Length > 0 && row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&

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,4 +1,6 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
@ -10,7 +12,9 @@ public static class QuestFixtureApi
app.MapPost( app.MapPost(
"/game/players/{id}/__dev/quest-fixture", "/game/players/{id}/__dev/quest-fixture",
(string id, QuestFixtureRequest? body, IPositionStateStore positions, (string id, QuestFixtureRequest? body, IPositionStateStore positions,
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore, IReputationDeltaStore auditStore,
TimeProvider timeProvider) => TimeProvider timeProvider) =>
{ {
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
@ -28,7 +32,11 @@ public static class QuestFixtureApi
trimmedId, trimmedId,
body, body,
questRegistry, questRegistry,
factionRegistry,
progressStore, progressStore,
standingStore,
deliveryStore,
auditStore,
timeProvider)) timeProvider))
{ {
return Results.NotFound(); return Results.NotFound();

View File

@ -17,6 +17,10 @@ public sealed class QuestFixtureRequest
/// <summary>Quest ids to clear from player progress (dev Bruno reset).</summary> /// <summary>Quest ids to clear from player progress (dev Bruno reset).</summary>
[JsonPropertyName("resetQuestIds")] [JsonPropertyName("resetQuestIds")]
public IReadOnlyList<string> ResetQuestIds { get; init; } = []; public IReadOnlyList<string> ResetQuestIds { get; init; } = [];
/// <summary>Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0).</summary>
[JsonPropertyName("resetFactionIds")]
public IReadOnlyList<string> ResetFactionIds { get; init; } = [];
} }
/// <summary>POST response for quest fixture apply.</summary> /// <summary>POST response for quest fixture apply.</summary>

View File

@ -1,5 +1,8 @@
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Rewards;
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary> /// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
public static class QuestFixtureOperations public static class QuestFixtureOperations
{ {
@ -11,10 +14,16 @@ public static class QuestFixtureOperations
string playerId, string playerId,
QuestFixtureRequest body, QuestFixtureRequest body,
IQuestDefinitionRegistry questRegistry, IQuestDefinitionRegistry questRegistry,
IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (body.CompletedQuestIds.Count == 0 && body.ResetQuestIds.Count == 0) if (body.CompletedQuestIds.Count == 0 &&
body.ResetQuestIds.Count == 0 &&
body.ResetFactionIds.Count == 0)
{ {
return true; return true;
} }
@ -35,6 +44,29 @@ public static class QuestFixtureOperations
{ {
return false; return false;
} }
if (!deliveryStore.TryClear(playerId, normalizedQuestId))
{
return false;
}
if (!auditStore.TryClearQuestCompletionAudit(playerId, normalizedQuestId))
{
return false;
}
}
foreach (var factionId in body.ResetFactionIds)
{
if (!factionRegistry.TryGetDefinition(factionId, out var definition) || definition is null)
{
return false;
}
if (!standingStore.TryClearStanding(playerId, definition.Id))
{
return false;
}
} }
foreach (var questId in body.CompletedQuestIds) foreach (var questId in body.CompletedQuestIds)

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

@ -7,4 +7,10 @@ public interface IRewardDeliveryStore
bool TryRecord(RewardDeliveryEvent deliveryEvent); bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent); bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
/// <summary>
/// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset.
/// Idempotent when the row is already absent.
/// </summary>
bool TryClear(string playerId, string questId);
} }

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;
} }
@ -61,9 +62,9 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
lock (keyLocks.GetOrAdd(key, _ => new object())) lock (keyLocks.GetOrAdd(key, _ => new object()))
{ {
var removed = eventsByKey.TryRemove(key, out _); _ = eventsByKey.TryRemove(key, out _);
keyLocks.TryRemove(key, out _); keyLocks.TryRemove(key, out _);
return removed; 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).
@ -104,15 +104,16 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio
**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies). **Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies).
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. Rep grants that reach +15 standing land in NEO-138; success Bruno smoke deferred until then. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** to mark operator chain completed without reward delivery (standing **0**). **Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** with **`resetQuestIds`** for grid contract, **`resetFactionIds`** for both prototype factions (clears stale standing from prior seq **11** runs), plus **`completedQuestIds`** for operator chain — no reward delivery; standing **0**. **Success Bruno** (seq **11**, NEO-138): organic operator-chain flow then accept.
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md). Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional: **Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional (resets run before **`completedQuestIds`** when combined):
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed. - **`resetQuestIds`** — deletes each quest's progress row for the player and clears matching **`IRewardDeliveryStore`** + quest-completion **`IReputationDeltaStore`** audit rows (idempotent when absent).
- **`resetQuestIds`** — deletes each quest's progress row for the player (idempotent when absent). Can be combined with **`completedQuestIds`** in one request (resets run first). - **`resetFactionIds`** — clears each faction's standing row for the player (missing row reads as **0**; idempotent when absent).
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged unless **`resetFactionIds`** also sent). Idempotent when a quest is already completed.
**400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` ( **`completedQuestIds`** ); order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**. **400** for bad schema; **404** when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` uses **`resetQuestIds`**, **`resetFactionIds`**, and **`completedQuestIds`**; order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**.
**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**. **Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**.
@ -348,6 +349,7 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan:
| **`DeliveredAt`** | UTC timestamp at delivery record commit. | | **`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 +361,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 +371,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 +382,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).