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

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 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production |
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-06** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)); **E7M3-02 server faction catalog load landed** ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)): fail-fast `content/factions/*_factions.json`, `IFactionDefinitionRegistry`, quest faction cross-ref + E7M3 bundle/grid gates; **E7M3-03 faction standing + reputation delta stores landed** ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)): `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010`; **E7M3-04 `ReputationOperations` landed** ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)): auditable `TryApplyDelta` orchestration ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); **E7M3-05 `FactionGateOperations` landed** ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)): quest accept gate eval + `faction_gate_blocked` ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); **E7M3-06 reward router rep grants landed** ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)): `RewardRouterOperations` applies `reputationGrants` idempotently via `ReputationOperations` ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)). Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-07** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
## Purpose

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,9 @@ using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests;
using Xunit;
@ -188,4 +190,85 @@ public sealed class QuestFixtureApiTests
Assert.NotNull(body);
Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status));
}
[Fact]
public async Task PostQuestFixture_ShouldClearFactionStanding_WhenResetFactionIdsProvided()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var standingStore = factory.Services.GetRequiredService<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.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
// Assert
@ -266,6 +268,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
Assert.True(result.Success);
}
@ -304,6 +308,7 @@ public sealed class QuestObjectiveWiringTests
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
@ -321,6 +326,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
@ -337,6 +344,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
@ -361,6 +370,8 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
@ -379,6 +390,8 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -445,6 +458,7 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
TimeProvider.System);
}
@ -468,5 +482,6 @@ public sealed class QuestObjectiveWiringTests
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider);
}

View File

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

View File

@ -389,9 +389,41 @@ public sealed class QuestStateOperationsTests
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId);
Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount);
Assert.Single(deliveryEvent.GrantedReputation);
Assert.Equal(GridFactionId, deliveryEvent.GrantedReputation[0].FactionId);
Assert.Equal(15, deliveryEvent.GrantedReputation[0].Amount);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(CountBagItem(beforeInventory!, "survey_drone_kit") + 1, CountBagItem(afterInventory!, "survey_drone_kit"));
Assert.Equal(100, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.Equal(
PrototypeE7M3QuestFactionRules.GridContractMinStanding,
deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public async Task TryAccept_ShouldAcceptGridContract_WhenOperatorChainCompletedWithRepGrant()
{
// Arrange — operator-chain completion grants survey_drone_kit; grid contract inventory_has_item
// objective satisfies on accept, so TryAccept returns completed (not active). Gate check is standing >= 15.
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndComplete(deps, GatherQuestId);
AcceptAndComplete(deps, RefineQuestId);
AcceptAndComplete(deps, CombatQuestId);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(TryMarkComplete(deps, ChainQuestId).Success);
// Act
var result = TryAccept(deps, GridContractQuestId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
Assert.Equal(
PrototypeE7M3QuestFactionRules.GridContractMinStanding,
deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
@ -575,6 +607,8 @@ public sealed class QuestStateOperationsTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.ReputationDeltaStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) =>
@ -595,6 +629,7 @@ public sealed class QuestStateOperationsTests
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.ReputationDeltaStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
@ -612,6 +647,8 @@ public sealed class QuestStateOperationsTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.ReputationDeltaStore,
deps.TimeProvider);
private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)

View File

@ -99,6 +99,31 @@ public sealed class InMemoryRewardDeliveryStoreTests
Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey);
}
[Fact]
public void TryClear_ShouldRemoveDeliveryRow_WhenPresent()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var deliveryEvent = CreatePrototypeEvent();
Assert.True(store.TryRecord(deliveryEvent));
// Act
var cleared = store.TryClear(PlayerId, QuestId);
// Assert
Assert.True(cleared);
Assert.False(store.TryGet(PlayerId, QuestId, out _));
}
[Fact]
public void TryClear_ShouldReturnTrue_WhenRowAlreadyAbsent()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
// Act
var cleared = store.TryClear(PlayerId, QuestId);
// Assert
Assert.True(cleared);
}
private static RewardDeliveryEvent CreatePrototypeEvent() =>
new(
PlayerId,
@ -106,5 +131,6 @@ public sealed class InMemoryRewardDeliveryStoreTests
DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)],
[new RewardSkillXpGrantApplied("salvage", 50)],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId));
}

View File

@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
@ -15,6 +16,8 @@ public sealed class RewardRouterOperationsTests
private const string PlayerId = "dev-local-1";
private const string GatherQuestId = "prototype_quest_gather_intro";
private const string OperatorChainQuestId = "prototype_quest_operator_chain";
private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
private const string RustCollectiveFactionId = "prototype_faction_rust_collective";
private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1";
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero);
@ -39,7 +42,7 @@ public sealed class RewardRouterOperationsTests
Assert.Single(first.GrantedSkillXp);
Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId);
Assert.Equal(25, first.GrantedSkillXp[0].Amount);
Assert.NotNull(first.DeliveryEvent);
Assert.Empty(first.GrantedReputation);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey);
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
@ -59,9 +62,7 @@ public sealed class RewardRouterOperationsTests
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = new QuestRewardBundleRow(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)]);
var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
// Act
@ -76,6 +77,9 @@ public sealed class RewardRouterOperationsTests
Assert.Single(result.GrantedSkillXp);
Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId);
Assert.Equal(50, result.GrantedSkillXp[0].Amount);
Assert.Single(result.GrantedReputation);
Assert.Equal(GridOperatorsFactionId, result.GrantedReputation[0].FactionId);
Assert.Equal(15, result.GrantedReputation[0].Amount);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
@ -83,6 +87,146 @@ public sealed class RewardRouterOperationsTests
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
Assert.Equal(salvageBefore + 50, salvageXp);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
Assert.Equal(
PrototypeE7M3QuestFactionRules.GridContractMinStanding,
deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId);
var expectedDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, GridOperatorsFactionId);
var auditRow = Assert.Single(
deps.AuditStore.GetDeltasForPlayer(PlayerId),
row => row.Id == expectedDeltaId);
Assert.Equal(GridOperatorsFactionId, auditRow.FactionId);
Assert.Equal(15, auditRow.DeltaAmount);
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, auditRow.SourceKind);
Assert.Equal(OperatorChainQuestId, auditRow.SourceId);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldNotDoubleApplyReputation_WhenAlreadyDelivered()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
// Act
var first = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
var second = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
// Assert
Assert.True(first.Success);
Assert.Single(first.GrantedReputation);
Assert.Equal(15, first.GrantedReputation[0].Amount);
Assert.True(second.Success);
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode);
Assert.Equal(
standingBefore + 15,
deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = new QuestRewardBundleRow(
[],
[],
[new ReputationGrantRow("prototype_faction_unknown", 10)]);
// Act
var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
Assert.Equal(0, deps.StandingStore.TryGetStanding(PlayerId, "prototype_faction_unknown").Standing);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRollbackItemsAndSkillXp_WhenReputationApplyDenied()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
var bundle = new QuestRewardBundleRow(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)],
[new ReputationGrantRow("prototype_faction_unknown", 10)]);
// Act
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(
CountBagItem(beforeInventory!, "survey_drone_kit"),
CountBagItem(afterInventory!, "survey_drone_kit"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
Assert.Equal(salvageBefore, salvageAfter);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRollbackAllGrants_WhenSecondReputationAuditAppendFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var auditStore = new AuditStoreFailingOnSecondForwardRepAppend();
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
var gridStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
var rustStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing;
var bundle = new QuestRewardBundleRow(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)],
[
new ReputationGrantRow(GridOperatorsFactionId, 15),
new ReputationGrantRow(RustCollectiveFactionId, 10),
]);
// Act
var result = RewardRouterOperations.TryDeliverQuestCompletion(
PlayerId,
OperatorChainQuestId,
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.AuditAppendFailed, result.ReasonCode);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(
CountBagItem(beforeInventory!, "survey_drone_kit"),
CountBagItem(afterInventory!, "survey_drone_kit"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
Assert.Equal(salvageBefore, salvageAfter);
Assert.Equal(gridStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
Assert.Equal(rustStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
Assert.Equal(2, auditStore.GetDeltasForPlayer(PlayerId).Count);
}
[Fact]
@ -104,6 +248,7 @@ public sealed class RewardRouterOperationsTests
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
Assert.Empty(result.GrantedItems);
Assert.Empty(result.GrantedSkillXp);
Assert.Empty(result.GrantedReputation);
Assert.Null(result.DeliveryEvent);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
}
@ -144,6 +289,7 @@ public sealed class RewardRouterOperationsTests
DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)],
[new RewardSkillXpGrantApplied("salvage", 50)],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId));
Assert.True(deps.DeliveryStore.TryRecord(existingEvent));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
@ -220,10 +366,12 @@ public sealed class RewardRouterOperationsTests
Assert.Null(result.ReasonCode);
Assert.Empty(result.GrantedItems);
Assert.Empty(result.GrantedSkillXp);
Assert.Empty(result.GrantedReputation);
Assert.NotNull(result.DeliveryEvent);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
Assert.Empty(stored.GrantedItems);
Assert.Empty(stored.GrantedSkillXp);
Assert.Empty(stored.GrantedReputation);
Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!));
@ -250,6 +398,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System);
// Assert
@ -279,6 +429,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System);
// Assert
@ -299,6 +451,7 @@ public sealed class RewardRouterOperationsTests
DeliveredAt,
[],
[new RewardSkillXpGrantApplied("salvage", 25)],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
@ -318,6 +471,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine,
deps.PerkStore,
store,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System);
// Assert
@ -329,6 +484,47 @@ public sealed class RewardRouterOperationsTests
Assert.Equal(salvageBefore, salvageAfter);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
var winnerEvent = new RewardDeliveryEvent(
PlayerId,
OperatorChainQuestId,
DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)],
[new RewardSkillXpGrantApplied("salvage", 50)],
[new RewardReputationGrantApplied(GridOperatorsFactionId, 15)],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId));
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
// Act
var result = RewardRouterOperations.TryDeliverQuestCompletion(
PlayerId,
OperatorChainQuestId,
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
store,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System);
// Assert
Assert.True(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
Assert.Equal(standingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp()
{
@ -346,6 +542,7 @@ public sealed class RewardRouterOperationsTests
DeliveredAt,
[],
[new RewardSkillXpGrantApplied("salvage", 25)],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
@ -363,6 +560,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine,
deps.PerkStore,
store,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System);
// Assert
@ -471,6 +670,8 @@ public sealed class RewardRouterOperationsTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
timeProvider);
private static void FillBag(RewardRouterTestDependencies deps)
@ -498,7 +699,9 @@ public sealed class RewardRouterOperationsTests
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>());
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -537,7 +740,9 @@ public sealed class RewardRouterOperationsTests
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
/// <summary>TryGet misses until after a failed <see cref="IRewardDeliveryStore.TryRecord"/> — simulates concurrent race loser.</summary>
private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore
@ -561,5 +766,49 @@ public sealed class RewardRouterOperationsTests
deliveryEvent = winnerEvent;
return true;
}
public bool TryClear(string playerId, string questId) => true;
}
/// <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.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
@ -90,6 +91,8 @@ public static class AbilityCastApi
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
{
@ -297,6 +300,8 @@ public static class AbilityCastApi
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));

View File

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

View File

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

View File

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

View File

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

View File

@ -20,4 +20,10 @@ public interface IFactionStandingStore
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
/// </summary>
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>
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) =>
new(false, reasonCode, 0);

View File

@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Factions;
@ -60,6 +61,33 @@ public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
return query.ToArray();
}
/// <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) =>
row.Id.Trim().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);
}
/// <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)
{
using var cmd = new Npgsql.NpgsqlCommand(

View File

@ -1,5 +1,7 @@
namespace NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Rewards;
/// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary>
public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore
{
@ -91,6 +93,33 @@ public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSou
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) =>
row.Id.Trim().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.Mastery;
using NeonSprawl.Server.Game.Quests;
@ -34,6 +35,8 @@ public static class GatherOperations
IPlayerQuestStateStore progressStore,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
var key = ResourceNodeInstanceIds.Normalize(interactableId);
@ -149,6 +152,8 @@ public static class GatherOperations
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider);
return new GatherResult(

View File

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

View File

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

View File

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

View File

@ -17,6 +17,10 @@ public sealed class QuestFixtureRequest
/// <summary>Quest ids to clear from player progress (dev Bruno reset).</summary>
[JsonPropertyName("resetQuestIds")]
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>

View File

@ -1,5 +1,8 @@
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>
public static class QuestFixtureOperations
{
@ -11,10 +14,16 @@ public static class QuestFixtureOperations
string playerId,
QuestFixtureRequest body,
IQuestDefinitionRegistry questRegistry,
IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore,
IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
if (body.CompletedQuestIds.Count == 0 && body.ResetQuestIds.Count == 0)
if (body.CompletedQuestIds.Count == 0 &&
body.ResetQuestIds.Count == 0 &&
body.ResetFactionIds.Count == 0)
{
return true;
}
@ -35,6 +44,29 @@ public static class QuestFixtureOperations
{
return false;
}
if (!deliveryStore.TryClear(playerId, normalizedQuestId))
{
return false;
}
if (!auditStore.TryClearQuestCompletionAudit(playerId, normalizedQuestId))
{
return false;
}
}
foreach (var factionId in body.ResetFactionIds)
{
if (!factionRegistry.TryGetDefinition(factionId, out var definition) || definition is null)
{
return false;
}
if (!standingStore.TryClearStanding(playerId, definition.Id))
{
return false;
}
}
foreach (var questId in body.CompletedQuestIds)

View File

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

View File

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

View File

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

View File

@ -7,4 +7,10 @@ public interface IRewardDeliveryStore
bool TryRecord(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(),
GrantedSkillXp = deliveryEvent.GrantedSkillXp.ToArray(),
GrantedReputation = deliveryEvent.GrantedReputation.ToArray(),
};
return true;
}
@ -61,9 +62,9 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
var removed = eventsByKey.TryRemove(key, out _);
_ = eventsByKey.TryRemove(key, out _);
keyLocks.TryRemove(key, out _);
return removed;
return true;
}
}
}

View File

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

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Game.Rewards;
/// <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}";
}
/// <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>
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.Skills;
@ -34,4 +35,19 @@ public static class RewardDeliveryReasonCodes
/// <summary>Passthrough from <see cref="SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed"/>.</summary>
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>
/// <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="GrantedReputation">Reputation grants on success; empty when denied.</param>
/// <param name="DeliveryEvent">Delivery payload on success; null when denied.</param>
public readonly record struct RewardDeliveryResult(
bool Success,
string? ReasonCode,
IReadOnlyList<RewardItemGrantApplied> GrantedItems,
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
IReadOnlyList<RewardReputationGrantApplied> GrantedReputation,
RewardDeliveryEvent? DeliveryEvent,
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.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
@ -9,15 +10,17 @@ namespace NeonSprawl.Server.Game.Rewards;
/// <summary>
/// Applies quest <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127).
/// 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).
/// </summary>
public static class RewardRouterOperations
{
private static readonly RewardItemGrantApplied[] EmptyItemGrants = [];
private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = [];
private static readonly RewardReputationGrantApplied[] EmptyReputationGrants = [];
/// <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.
/// </summary>
public static RewardDeliveryResult TryDeliverQuestCompletion(
@ -32,6 +35,8 @@ public static class RewardRouterOperations
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId);
@ -53,6 +58,8 @@ public static class RewardRouterOperations
var itemGrants = bundle?.ItemGrants ?? Array.Empty<RewardGrantRow>();
var skillGrants = bundle?.SkillXpGrants ?? Array.Empty<QuestSkillXpGrantRow>();
var reputationGrants = bundle?.ReputationGrants ?? Array.Empty<ReputationGrantRow>();
var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId);
if (itemGrants.Count > 0)
{
@ -87,7 +94,6 @@ public static class RewardRouterOperations
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing);
}
@ -117,13 +123,45 @@ public static class RewardRouterOperations
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing)
{
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
CompensatingRevertAll(
playerId,
normalizedQuestId,
idempotencyKey,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
[],
itemRegistry,
inventoryStore,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
standingStore,
auditStore,
timeProvider);
return Deny(RewardDeliveryReasonCodes.ProgressionStoreMissing);
}
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.Denied)
{
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
CompensatingRevertAll(
playerId,
normalizedQuestId,
idempotencyKey,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
[],
itemRegistry,
inventoryStore,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
standingStore,
auditStore,
timeProvider);
var reasonCode = skillOutcome.Response?.ReasonCode ?? RewardDeliveryReasonCodes.UnknownSkill;
return Deny(reasonCode);
}
@ -143,6 +181,50 @@ public static class RewardRouterOperations
// E9.M1 wiring: place per-row emits inside the apply loop and/or after TryRecord success only — not on
// TryRecord race-rollback paths (grants applied then reverted below); mirror reward_delivery gating.
var appliedReputation = new List<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 deliveryEvent = new RewardDeliveryEvent(
normalizedPlayerId,
@ -150,7 +232,8 @@ public static class RewardRouterOperations
deliveredAt,
appliedItems,
appliedSkillXp,
RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId));
appliedReputation,
idempotencyKey);
if (deliveryStore.TryRecord(deliveryEvent))
{
@ -158,7 +241,7 @@ public static class RewardRouterOperations
// TODO(E9.M1): catalog emit — once per first-time quest completion delivery (TryRecord success).
// Not on TryGet idempotent replay, deny paths, or TryRecord race-loser rollback paths.
// Planned payload fields: playerId, questId, deliveredAt, grantedItems (itemId, quantity),
// grantedSkillXp (skillId, amount), idempotencyKey ({playerId}:quest_complete:{questId}).
// grantedSkillXp (skillId, amount), grantedReputation (factionId, amount), idempotencyKey.
// No ingest or ILogger here (comments-only).
return SuccessFromEvent(deliveryEvent, reasonCode: null);
@ -166,19 +249,23 @@ public static class RewardRouterOperations
// Lost TryRecord race after apply — rollback this call's grants (mirror
// EncounterCompletionOperations when TryMarkCompleted returns false).
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
_ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants(
CompensatingRevertAll(
playerId,
normalizedQuestId,
idempotencyKey,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
skillProgressionStore,
perkStore);
ResyncPathAutoPerksAfterRaceRollback(
playerId,
appliedSkillXp,
appliedReputation,
itemRegistry,
inventoryStore,
skillProgressionStore,
levelCurve,
perkUnlockEngine);
perkUnlockEngine,
perkStore,
standingStore,
auditStore,
timeProvider);
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent))
{
@ -188,6 +275,79 @@ public static class RewardRouterOperations
return Deny(RewardDeliveryReasonCodes.AlreadyDelivered);
}
private static void CompensatingRevertAll(
string playerId,
string normalizedQuestId,
string idempotencyKey,
IReadOnlyList<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(
string playerId,
IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp,
@ -219,6 +379,18 @@ public static class RewardRouterOperations
_ => reasonCode,
};
private static string MapReputationReason(string? reasonCode) =>
reasonCode switch
{
FactionStandingReasonCodes.UnknownFaction => RewardDeliveryReasonCodes.UnknownFaction,
ReputationApplyReasonCodes.InvalidDelta => RewardDeliveryReasonCodes.InvalidDelta,
ReputationApplyReasonCodes.InvalidDeltaId => RewardDeliveryReasonCodes.InvalidDeltaId,
ReputationApplyReasonCodes.InvalidSource => RewardDeliveryReasonCodes.InvalidSource,
ReputationApplyReasonCodes.AuditAppendFailed => RewardDeliveryReasonCodes.AuditAppendFailed,
null => RewardDeliveryReasonCodes.UnknownFaction,
_ => reasonCode,
};
private static void CompensatingRemoveItems(
string playerId,
IReadOnlyList<RewardItemGrantApplied> grants,
@ -243,6 +415,7 @@ public static class RewardRouterOperations
ReasonCode: reasonCode,
GrantedItems: EmptyItemGrants,
GrantedSkillXp: EmptySkillXpGrants,
GrantedReputation: EmptyReputationGrants,
DeliveryEvent: null,
DeliveredAt: null);
@ -252,6 +425,7 @@ public static class RewardRouterOperations
ReasonCode: reasonCode,
GrantedItems: deliveryEvent.GrantedItems,
GrantedSkillXp: deliveryEvent.GrantedSkillXp,
GrantedReputation: deliveryEvent.GrantedReputation,
DeliveryEvent: deliveryEvent,
DeliveredAt: deliveryEvent.DeliveredAt);
}

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.
**Prototype source kind:** **`quest_completion`** (`ReputationDeltaSourceKinds.QuestCompletion`); quest id as `sourceId`. Quest bundle wiring lands in NEO-138 (`RewardRouterOperations`).
**Prototype source kind:** **`quest_completion`** (`ReputationDeltaSourceKinds.QuestCompletion`); quest id as `sourceId`. Quest bundle wiring: **`RewardRouterOperations.TryDeliverQuestCompletion`** (NEO-138).
Plan: [NEO-136 implementation plan](../../docs/plans/NEO-136-implementation-plan.md).
@ -104,15 +104,16 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio
**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies).
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. Rep grants that reach +15 standing land in NEO-138; success Bruno smoke deferred until then. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** to mark operator chain completed without reward delivery (standing **0**).
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** with **`resetQuestIds`** for grid contract, **`resetFactionIds`** for both prototype factions (clears stale standing from prior seq **11** runs), plus **`completedQuestIds`** for operator chain — no reward delivery; standing **0**. **Success Bruno** (seq **11**, NEO-138): organic operator-chain flow then accept.
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional:
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed.
- **`resetQuestIds`** — deletes each quest's progress row for the player (idempotent when absent). Can be combined with **`completedQuestIds`** in one request (resets run first).
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional (resets run before **`completedQuestIds`** when combined):
- **`resetQuestIds`** — deletes each quest's progress row for the player and clears matching **`IRewardDeliveryStore`** + quest-completion **`IReputationDeltaStore`** audit rows (idempotent when absent).
- **`resetFactionIds`** — clears each faction's standing row for the player (missing row reads as **0**; idempotent when absent).
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged unless **`resetFactionIds`** also sent). Idempotent when a quest is already completed.
**400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` ( **`completedQuestIds`** ); order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**.
**400** for bad schema; **404** when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` uses **`resetQuestIds`**, **`resetFactionIds`**, and **`completedQuestIds`**; order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**.
**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**.
@ -348,6 +349,7 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan:
| **`DeliveredAt`** | UTC timestamp at delivery record commit. |
| **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. |
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`skillId`, `amount`) at commit time. |
| **`GrantedReputation`** | Snapshot of applied reputation rows (`factionId`, `amount`) at commit time (NEO-138). |
| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. |
**Store interface:**
@ -359,7 +361,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp
## Reward router (NEO-127)
**`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows) and **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**.
**`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows), **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), and **`ReputationOperations.TryApplyDelta`** with **`ReputationDeltaSourceKinds.QuestCompletion`** (reputation rows — NEO-138), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**.
| Reason code | When |
|-------------|------|
@ -369,8 +371,9 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp
| **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Item grant pre-flight or apply failed. |
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
| **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). |
Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md).
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md).
### Reward telemetry hooks (NEO-130)
@ -379,6 +382,7 @@ Comment-only hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`*
| Event | Anchor | When |
|-------|--------|------|
| **`reward_delivery`** | **`TryDeliverQuestCompletion`** | After **`TryRecord`** returns **`true`** on first-time delivery (not idempotent **`TryGet`** replay or race-loser paths). |
| **`reputation_delta`** | **`TryDeliverQuestCompletion`** (stub) | After each successful reputation row apply (NEO-138); consolidated in NEO-141. |
| **`unlock_granted`** | **`TryDeliverQuestCompletion`** (stub) | Future **`UnlockGrant`** apply from bundle rows — prototype bundles have item + skill XP only; no runtime unlock apply in Slice 2. |
**`perk_unlock`** side effects from skill XP grants use **`PerkUnlockEngine`** ([NEO-49](#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49)) — distinct from content **`UnlockGrant`** rows. Manual QA: [`docs/manual-qa/NEO-130.md`](../../docs/manual-qa/NEO-130.md); plan: [NEO-130 implementation plan](../../docs/plans/NEO-130-implementation-plan.md).