Compare commits

...

29 Commits

Author SHA1 Message Date
VinPropane 0af21e002c
Merge pull request #176 from ViPro-Technologies/NEO-137-e7m3-faction-gate-operations-tryaccept
NEO-137: FactionGateOperations + wire into TryAccept
2026-06-15 21:32:48 -04:00
VinPropane a36e7b56b5 NEO-137: Address review — quest fixture for Bruno CI and test/docs nits.
Add POST …/__dev/quest-fixture to mark quests completed without reward
delivery; wire Bruno pre-request and CI flag. Multi-rule AND unit test,
README notes, gate deny comment, review strikethroughs.
2026-06-15 21:24:21 -04:00
VinPropane 4ffc60f226 NEO-137: Add code review for faction gate operations branch. 2026-06-15 21:21:23 -04:00
VinPropane 0241cc31e3 NEO-137: Wire FactionGateOperations into quest accept with tests and docs.
Evaluate FactionGateRule rows before TryActivate; deny with faction_gate_blocked
when standing is below minStanding. Integration and Bruno deny smoke included.
2026-06-15 21:11:27 -04:00
VinPropane 1c3d075d6b NEO-137: Add implementation plan for FactionGateOperations + TryAccept wiring. 2026-06-15 21:08:26 -04:00
VinPropane 25aba88e78
Merge pull request #175 from ViPro-Technologies/NEO-136-e7m3-reputation-operations-apply-delta
NEO-136: E7M3-04 ReputationOperations auditable standing apply
2026-06-15 20:56:59 -04:00
VinPropane ddaf33ce87 NEO-136: fix rollback applied delta and normalize ids at ops boundary 2026-06-15 20:52:04 -04:00
VinPropane 2117b2f8d3 NEO-136: add code review for ReputationOperations 2026-06-15 20:50:57 -04:00
VinPropane 536f635a11 NEO-136: document ReputationOperations and reconcile plan 2026-06-15 20:48:44 -04:00
VinPropane 8aae9625e7 NEO-136: add ReputationOperations auditable standing apply 2026-06-15 20:48:42 -04:00
VinPropane e9abc8bf38 NEO-136: add implementation plan for ReputationOperations 2026-06-15 20:45:54 -04:00
VinPropane 786a5b6d68
Merge pull request #174 from ViPro-Technologies/NEO-135-e7m3-faction-standing-reputation-stores
NEO-135: E7M3-03 faction standing and reputation delta audit stores
2026-06-15 20:39:15 -04:00
VinPropane 94acf57e5b NEO-135: apply standing deltas from clamped effective value
Compute newStanding from previousStanding + deltaAmount so stale
out-of-band persisted values match TryGetStanding before mutation.
2026-06-15 20:35:24 -04:00
VinPropane 2ac804ed6c NEO-135: validate faction before player gate in in-memory store
Match PostgresFactionStandingStore check order so unknown faction ids
return unknown_faction even when the player is not writable.
2026-06-15 20:32:53 -04:00
VinPropane d5f16f4cf4 NEO-135: address code review findings
Update decomposition docs for E7M3-03, add in-memory read-clamp test via
test seed hook, fix empty factionId deny code, AAA integration test,
Postgres FK orchestration notes, and strike review suggestions Done.
2026-06-15 20:27:36 -04:00
VinPropane c066121c9d NEO-135: add code review for faction standing stores 2026-06-15 20:25:35 -04:00
VinPropane 4fca45bf7c NEO-135: add faction standing and reputation delta audit stores
Per-player IFactionStandingStore with clamped reads/writes and fail-closed
unknown-faction deny; append-only IReputationDeltaStore; in-memory plus
Postgres V009/V010 when configured. Tests, Bruno health smoke, README.
2026-06-15 20:23:57 -04:00
VinPropane 08ebfcebcf NEO-135: add E7M3-03 faction standing store implementation plan
Kickoff plan for per-player FactionStanding snapshot and append-only
ReputationDelta audit stores (in-memory + NEO-116-style Postgres).
2026-06-15 20:20:48 -04:00
VinPropane d90d325a3d
Merge pull request #173 from ViPro-Technologies/NEO-134-e7m3-server-faction-catalog-load-fail-fast
NEO-134: E7M3-02 server faction catalog load (fail-fast)
2026-06-15 20:16:53 -04:00
VinPropane aa3eb3597c NEO-134: address code review findings
Update decomposition tracking docs, add faction loader integration tests,
reorder standing-band gate before freeze, and guard success logging.
2026-06-15 20:11:55 -04:00
VinPropane 058799a595 NEO-134: add code review for E7M3-02 faction catalog load 2026-06-15 20:10:17 -04:00
VinPropane 48bca98467 NEO-134: fail-fast faction catalog load with E7M3 quest gates
Add Game/Factions loader, registry, and CI-parity prototype rules; extend
quest catalog to parse faction gates/rep grants and cross-check at startup.
2026-06-15 20:08:46 -04:00
VinPropane edb33514c5 NEO-134: add E7M3-02 faction catalog load implementation plan
Kickoff plan for fail-fast server faction catalog load with CI-parity
gates, quest faction cross-refs, and registry DI wiring.
2026-06-15 20:03:44 -04:00
VinPropane f248022c2d
Merge pull request #172 from ViPro-Technologies/NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci
NEO-133: E7M3-01 faction catalog, quest gate/rep schemas, and CI
2026-06-15 20:02:15 -04:00
VinPropane 635f3cccaf NEO-133: fix Bruno quest smokes for five-quest roster
Update quest-definitions and quest-progress default Bruno tests
after prototype_quest_grid_contract landed in the catalog.
2026-06-15 19:58:37 -04:00
VinPropane 6cb7dfbb46 NEO-133: address code review — roster docs, CI cleanup, band gate
Cross-link E7.M1 doc to E7M3 five-quest roster; remove dead
_prototype_e7m1_quest_gate; add faction standing-band CI gate;
clarify server roster error strings; grid-contract loader test asserts.
2026-06-15 19:52:42 -04:00
VinPropane 92c078b0e3 NEO-133: Add code review for E7M3-01 catalog branch. 2026-06-15 19:51:42 -04:00
VinPropane 0948d9d4a8 NEO-133: land faction catalog, quest gate/rep schemas, and CI gates
Add two prototype factions, extend quest bundles with reputationGrants
and factionGateRules, freeze five-quest roster in validate_content.py,
and sync minimal server quest roster parity for green startup tests.
2026-06-14 14:54:20 -04:00
VinPropane 718346163f NEO-133: kickoff E7M3-01 — faction catalog schemas + CI plan
Land E7.M3 Slice 3 decomposition docs and NEO-133 implementation plan
before content/schema work; blocks NEO-134 server faction load.
2026-06-14 14:51:22 -04:00
105 changed files with 6095 additions and 66 deletions

View File

@ -93,6 +93,7 @@ jobs:
ConnectionStrings__NeonSprawl: >- ConnectionStrings__NeonSprawl: >-
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
Game__EnableCombatTargetFixtureApi: "true" Game__EnableCombatTargetFixtureApi: "true"
Game__EnableQuestFixtureApi: "true"
run: | run: |
set -euo pipefail set -euo pipefail
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \ dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \

View File

@ -0,0 +1,25 @@
meta {
name: GET health (faction catalog boot NEO-134)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-134 loads content/factions/*_factions.json at startup (fail-fast). No faction HTTP API in this story — use this request to confirm the host started after catalog validation.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: faction-catalog
}

View File

@ -0,0 +1,25 @@
meta {
name: GET health (faction standing store NEO-135)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-135 registers IFactionStandingStore and IReputationDeltaStore (in-memory or Postgres when configured). No faction standing HTTP API in this story — use this request to confirm the host started after store DI wiring.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: faction-standing
}

View File

@ -17,7 +17,7 @@ tests {
const body = res.getBody(); const body = res.getBody();
expect(body.schemaVersion).to.equal(1); expect(body.schemaVersion).to.equal(1);
expect(body.quests).to.be.an("array"); expect(body.quests).to.be.an("array");
expect(body.quests.length).to.equal(4); expect(body.quests.length).to.equal(5);
}); });
test("quests are ascending by id (ordinal)", function () { test("quests are ascending by id (ordinal)", function () {
@ -54,4 +54,19 @@ tests {
quantity: 1, quantity: 1,
}); });
}); });
test("prototype_quest_grid_contract has operator-chain prereq and kit hand-in objective", function () {
const body = res.getBody();
const row = body.quests.find((x) => x.id === "prototype_quest_grid_contract");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Grid Contract");
expect(row.prerequisiteQuestIds).to.eql(["prototype_quest_operator_chain"]);
expect(row.steps).to.have.length(1);
expect(row.steps[0].objectives[0]).to.eql({
id: "grid_contract_obj_kit",
kind: "inventory_has_item",
itemId: "survey_drone_kit",
quantity: 1,
});
});
} }

View File

@ -0,0 +1,56 @@
meta {
name: Accept grid contract faction gate deny
type: http
seq: 10
}
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.
}
script:pre-request {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const chainQuestId = "prototype_quest_operator_chain";
const fixture = await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
{
schemaVersion: 1,
completedQuestIds: [chainQuestId],
},
{ ...jsonHeaders, validateStatus: () => true },
);
if (fixture.status !== 200 || fixture.data?.applied !== true) {
throw new Error(
`quest-fixture failed: HTTP ${fixture.status} ${JSON.stringify(fixture.data)}`,
);
}
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_grid_contract/accept
body: json
auth: none
}
body:json {
{
"schemaVersion": 1
}
}
tests {
test("denies accept with faction_gate_blocked when standing below minStanding", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.accepted).to.equal(false);
expect(body.reasonCode).to.equal("faction_gate_blocked");
expect(body.quest).to.equal(undefined);
});
}

View File

@ -5,7 +5,7 @@ meta {
} }
docs { docs {
NEO-119: fresh dev-local-1 row before any quest accept — all four catalog quests not_started. NEO-119: fresh dev-local-1 row before any quest accept — all five catalog quests not_started.
} }
get { get {
@ -21,7 +21,7 @@ tests {
expect(body.schemaVersion).to.equal(1); expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
expect(body.quests).to.be.an("array"); expect(body.quests).to.be.an("array");
expect(body.quests.length).to.equal(4); expect(body.quests.length).to.equal(5);
}); });
test("all prototype quests are not_started with empty counters", function () { test("all prototype quests are not_started with empty counters", function () {
@ -29,6 +29,7 @@ tests {
const expectedIds = [ const expectedIds = [
"prototype_quest_combat_intro", "prototype_quest_combat_intro",
"prototype_quest_gather_intro", "prototype_quest_gather_intro",
"prototype_quest_grid_contract",
"prototype_quest_operator_chain", "prototype_quest_operator_chain",
"prototype_quest_refine_intro", "prototype_quest_refine_intro",
]; ];

View File

@ -13,7 +13,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
| [`npc-behaviors/`](npc-behaviors/) | NPC behavior archetype catalogs; each row matches [`schemas/npc-behavior-def.schema.json`](schemas/npc-behavior-def.schema.json) — **stable `id`**, **`archetypeKind`**, aggro/leash radii, telegraph + attack timings for [E5.M2](../docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | | [`npc-behaviors/`](npc-behaviors/) | NPC behavior archetype catalogs; each row matches [`schemas/npc-behavior-def.schema.json`](schemas/npc-behavior-def.schema.json) — **stable `id`**, **`archetypeKind`**, aggro/leash radii, telegraph + attack timings for [E5.M2](../docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) |
| [`encounters/`](encounters/) | Encounter templates; each row matches [`schemas/encounter-def.schema.json`](schemas/encounter-def.schema.json) — **stable `id`**, completion criteria, **`rewardTableId`** for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) | | [`encounters/`](encounters/) | Encounter templates; each row matches [`schemas/encounter-def.schema.json`](schemas/encounter-def.schema.json) — **stable `id`**, completion criteria, **`rewardTableId`** for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) |
| [`reward-tables/`](reward-tables/) | Reward table catalogs; each row matches [`schemas/reward-table.schema.json`](schemas/reward-table.schema.json) — **stable `id`**, **`fixedGrants`** referencing frozen item ids for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) | | [`reward-tables/`](reward-tables/) | Reward table catalogs; each row matches [`schemas/reward-table.schema.json`](schemas/reward-table.schema.json) — **stable `id`**, **`fixedGrants`** referencing frozen item ids for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) |
| [`quests/`](quests/) | Quest catalogs; each row matches [`schemas/quest-def.schema.json`](schemas/quest-def.schema.json) (steps via [`quest-step-def.schema.json`](schemas/quest-step-def.schema.json), objectives via [`quest-objective-def.schema.json`](schemas/quest-objective-def.schema.json)) — **stable `id`**, **`prerequisiteQuestIds`**, step/objective graph for [E7.M1](../docs/decomposition/modules/E7_M1_QuestStateMachine.md) | | [`quests/`](quests/) | Quest catalogs; each row matches [`schemas/quest-def.schema.json`](schemas/quest-def.schema.json) (steps via [`quest-step-def.schema.json`](schemas/quest-step-def.schema.json), objectives via [`quest-objective-def.schema.json`](schemas/quest-objective-def.schema.json)) — **stable `id`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`**, step/objective graph for [E7.M1](../docs/decomposition/modules/E7_M1_QuestStateMachine.md) / [E7.M3](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md) |
| [`factions/`](factions/) | Faction catalogs; each row matches [`schemas/faction-def.schema.json`](schemas/faction-def.schema.json) — **stable `id`**, standing band for [E7.M3](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md) |
| [`resource-nodes/`](resource-nodes/) | Resource node + yield catalogs; node rows match [`schemas/resource-node-def.schema.json`](schemas/resource-node-def.schema.json), yield rows match [`schemas/resource-yield-row.schema.json`](schemas/resource-yield-row.schema.json) — **stable `nodeDefId`**, **`gatherLens`**, **`maxGathers`** for [E3.M1](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | | [`resource-nodes/`](resource-nodes/) | Resource node + yield catalogs; node rows match [`schemas/resource-node-def.schema.json`](schemas/resource-node-def.schema.json), yield rows match [`schemas/resource-yield-row.schema.json`](schemas/resource-yield-row.schema.json) — **stable `nodeDefId`**, **`gatherLens`**, **`maxGathers`** for [E3.M1](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) |
**Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring. **Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring.
@ -32,7 +33,9 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
**Prototype E7 Slice 1 — quests (NEO-112):** CI expects **exactly four** quest ids aligned to [E7.M1 quest freeze](../docs/decomposition/modules/E7_M1_QuestStateMachine.md#prototype-slice-1-freeze-e7m1-01) — **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`**, **`prototype_quest_operator_chain`**. Objective **`itemId`** / **`recipeId`** / **`encounterId`** cross-refs must resolve to frozen item, recipe, and encounter catalogs; **`prerequisiteQuestIds`** must be acyclic and reference known quest ids; operator chain terminal step is **`inventory_has_item`** **`contract_handoff_token`** ×1. **Do not rename** quest `id` after ship—change **`displayName`** only. See [E7M1-prototype-backlog.md](../docs/plans/E7M1-prototype-backlog.md) and [NEO-112 plan](../docs/plans/NEO-112-implementation-plan.md). **Prototype E7 Slice 1 — quests (NEO-112):** CI expects **exactly four** quest ids aligned to [E7.M1 quest freeze](../docs/decomposition/modules/E7_M1_QuestStateMachine.md#prototype-slice-1-freeze-e7m1-01) — **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`**, **`prototype_quest_operator_chain`**. Objective **`itemId`** / **`recipeId`** / **`encounterId`** cross-refs must resolve to frozen item, recipe, and encounter catalogs; **`prerequisiteQuestIds`** must be acyclic and reference known quest ids; operator chain terminal step is **`inventory_has_item`** **`contract_handoff_token`** ×1. **Do not rename** quest `id` after ship—change **`displayName`** only. See [E7M1-prototype-backlog.md](../docs/plans/E7M1-prototype-backlog.md) and [NEO-112 plan](../docs/plans/NEO-112-implementation-plan.md).
**Prototype E7 Slice 2 — quest completion bundles (NEO-124):** Each of the four frozen quest ids must include **`completionRewardBundle`** with **`itemGrants`** / **`skillXpGrants`** per [E7.M2 completion freeze](../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01). Bundle **`itemGrants[].itemId`** must resolve to the frozen item catalog; **`skillXpGrants[].skillId`** must resolve to a skill that lists **`mission_reward`** in **`allowedXpSourceKinds`**. Omit empty **`itemGrants`**. See [E7M2-prototype-backlog.md](../docs/plans/E7M2-prototype-backlog.md) and [NEO-124 plan](../docs/plans/NEO-124-implementation-plan.md). **Prototype E7 Slice 2 — quest completion bundles (NEO-124):** Each frozen quest id must include **`completionRewardBundle`** with **`itemGrants`** / **`skillXpGrants`** per [E7.M2 completion freeze](../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01). Bundle **`itemGrants[].itemId`** must resolve to the frozen item catalog; **`skillXpGrants[].skillId`** must resolve to a skill that lists **`mission_reward`** in **`allowedXpSourceKinds`**. Omit empty **`itemGrants`**. See [E7M2-prototype-backlog.md](../docs/plans/E7M2-prototype-backlog.md) and [NEO-124 plan](../docs/plans/NEO-124-implementation-plan.md).
**Prototype E7 Slice 3 — factions + gated quest line (NEO-133):** CI expects **exactly two** faction ids aligned to [E7.M3 faction freeze](../docs/decomposition/modules/E7_M3_FactionReputationLedger.md#prototype-slice-3-freeze-e7m3-01) — **`prototype_faction_grid_operators`**, **`prototype_faction_rust_collective`**. Quest roster expands to **five** ids (four E7.M1 onboarding + **`prototype_quest_grid_contract`**). **`factionGateRules[].factionId`** and **`completionRewardBundle.reputationGrants[].factionId`** must resolve to the frozen faction catalog; grid contract shape is frozen in CI. See [E7M3-pre-production-backlog.md](../docs/plans/E7M3-pre-production-backlog.md) and [NEO-133 plan](../docs/plans/NEO-133-implementation-plan.md).
**Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45). **Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45).

View File

@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"factions": [
{
"id": "prototype_faction_grid_operators",
"displayName": "Grid Operators",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
},
{
"id": "prototype_faction_rust_collective",
"displayName": "Rust Collective",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
}
]
}

View File

@ -76,7 +76,10 @@
], ],
"completionRewardBundle": { "completionRewardBundle": {
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }], "itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }] "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }],
"reputationGrants": [
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
]
}, },
"steps": [ "steps": [
{ {
@ -128,6 +131,36 @@
] ]
} }
] ]
},
{
"id": "prototype_quest_grid_contract",
"displayName": "Grid Contract",
"prerequisiteQuestIds": ["prototype_quest_operator_chain"],
"factionGateRules": [
{
"factionId": "prototype_faction_grid_operators",
"minStanding": 15
}
],
"completionRewardBundle": {
"reputationGrants": [
{ "factionId": "prototype_faction_rust_collective", "amount": 10 }
]
},
"steps": [
{
"id": "grid_contract_step_kit",
"displayName": "Deliver survey drone kit",
"objectives": [
{
"id": "grid_contract_obj_kit",
"kind": "inventory_has_item",
"itemId": "survey_drone_kit",
"quantity": 1
}
]
}
]
} }
] ]
} }

View File

@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/faction-def.json",
"title": "FactionDef",
"description": "Single faction row for catalogs (e.g. content/factions/*_factions.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.",
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName", "minStanding", "maxStanding", "neutralStanding"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable faction key for standing, gates, telemetry, and reputation grants."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"minStanding": {
"type": "integer",
"description": "Lowest standing value after clamp on apply."
},
"maxStanding": {
"type": "integer",
"description": "Highest standing value after clamp on apply."
},
"neutralStanding": {
"type": "integer",
"description": "Default standing for new players when no snapshot exists."
}
}
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/faction-gate-rule.json",
"title": "FactionGateRule",
"description": "Minimum faction standing required to accept gated content (e.g. on QuestDef.factionGateRules). See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.",
"type": "object",
"additionalProperties": false,
"required": ["factionId", "minStanding"],
"properties": {
"factionId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Faction catalog id that must meet minStanding."
},
"minStanding": {
"type": "integer",
"description": "Inclusive minimum standing required to pass the gate."
}
}
}

View File

@ -33,9 +33,16 @@
}, },
"description": "Ordered steps; prototype Slice 1 uses sequential advance only." "description": "Ordered steps; prototype Slice 1 uses sequential advance only."
}, },
"factionGateRules": {
"type": "array",
"items": {
"$ref": "https://neon-sprawl.local/schemas/faction-gate-rule.json"
},
"description": "Optional accept gates evaluated against faction standing (E7.M3)."
},
"completionRewardBundle": { "completionRewardBundle": {
"$ref": "https://neon-sprawl.local/schemas/quest-reward-bundle.json", "$ref": "https://neon-sprawl.local/schemas/quest-reward-bundle.json",
"description": "Optional completion payout (E7.M2); required on all four prototype quest ids in CI." "description": "Optional completion payout (E7.M2/E7.M3); required on all prototype quest ids in CI."
} }
} }
} }

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/quest-reward-bundle.json", "$id": "https://neon-sprawl.local/schemas/quest-reward-bundle.json",
"title": "QuestRewardBundle", "title": "QuestRewardBundle",
"description": "Composite completion rewards on QuestDef.completionRewardBundle. Grant kinds v1: itemGrants + skillXpGrants only. See docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md.", "description": "Composite completion rewards on QuestDef.completionRewardBundle. Grant kinds: itemGrants, skillXpGrants, reputationGrants. See docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md and E7_M3_FactionReputationLedger.md.",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@ -19,6 +19,13 @@
"$ref": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json" "$ref": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json"
}, },
"description": "Skill XP rows applied via mission_reward on first-time completion." "description": "Skill XP rows applied via mission_reward on first-time completion."
},
"reputationGrants": {
"type": "array",
"items": {
"$ref": "https://neon-sprawl.local/schemas/reputation-grant-row.json"
},
"description": "Faction reputation rows applied on first-time completion (E7.M3)."
} }
}, },
"anyOf": [ "anyOf": [
@ -37,6 +44,14 @@
"minItems": 1 "minItems": 1
} }
} }
},
{
"required": ["reputationGrants"],
"properties": {
"reputationGrants": {
"minItems": 1
}
}
} }
] ]
} }

View File

@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/reputation-grant-row.json",
"title": "ReputationGrantRow",
"description": "Faction reputation payout row on QuestRewardBundle.reputationGrants. See docs/decomposition/modules/E7_M3_FactionReputationLedger.md.",
"type": "object",
"additionalProperties": false,
"required": ["factionId", "amount"],
"properties": {
"factionId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Faction catalog id receiving the standing change."
},
"amount": {
"type": "integer",
"description": "Signed reputation delta applied on first-time completion delivery."
}
}
}

View File

@ -34,7 +34,7 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
- Responsibility: Track faction standing and gate faction-specific contracts/content. - Responsibility: Track faction standing and gate faction-specific contracts/content.
- Key contracts: `FactionStanding`, `ReputationDelta`, `FactionGateRule` - Key contracts: `FactionStanding`, `ReputationDelta`, `FactionGateRule`
- Dependencies: E7.M1 - Dependencies: E7.M1, E7.M2
- Stage target: Pre-production - Stage target: Pre-production
### E7.M4 - ContractMissionGenerator ### E7.M4 - ContractMissionGenerator
@ -68,14 +68,19 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
**Linear backlog:** [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) — [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). Verify quest completion rewards in **Godot**, not Bruno-only. Encounter loot ([E5.M3](../modules/E5_M3_EncounterAndRewardTables.md)) unchanged in Slice 2. **Linear backlog:** [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) — [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). Verify quest completion rewards in **Godot**, not Bruno-only. Encounter loot ([E5.M3](../modules/E5_M3_EncounterAndRewardTables.md)) unchanged in Slice 2.
<a id="slice-3---faction-ledger-pre-production"></a>
### Slice 3 - Faction ledger (pre-production) ### Slice 3 - Faction ledger (pre-production)
- Scope: E7.M3 gating at least one faction line and mission set. - Scope: E7.M3 gating at least one faction line and mission set.
- Dependencies: E7.M1 - Dependencies: E7.M1, E7.M2
- Acceptance criteria: - Acceptance criteria:
- Reputation deltas auditable; gates fail closed on tamper. - Reputation deltas auditable; gates fail closed on tamper.
- Player earns Grid Operators rep from operator chain; faction-gated quest accept denied below threshold.
- Telemetry hooks: `reputation_delta`, `faction_gate_blocked`. - Telemetry hooks: `reputation_delta`, `faction_gate_blocked`.
**Linear backlog:** [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) — [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Verify faction standing + gate deny in **Godot**, not Bruno-only.
### Slice 4 - Contract generator (pre-production) ### Slice 4 - Contract generator (pre-production)
- Scope: E7.M4 emitting repeatable contracts from templates by zone difficulty. - Scope: E7.M4 emitting repeatable contracts from templates by zone difficulty.
@ -97,4 +102,5 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
- Prototype quest counts and integrated chain meet master plan minimums **in the Godot client** (Slice 1 capstone [NEO-123](../../manual-qa/NEO-123.md)), not Bruno-only. - Prototype quest counts and integrated chain meet master plan minimums **in the Godot client** (Slice 1 capstone [NEO-123](../../manual-qa/NEO-123.md)), not Bruno-only.
- Slice 2 quest **completion** rewards (items + skill XP) visible in Godot with idempotent delivery (capstone [NEO-132](../../manual-qa/NEO-132.md)), not Bruno-only. - Slice 2 quest **completion** rewards (items + skill XP) visible in Godot with idempotent delivery (capstone [NEO-132](../../manual-qa/NEO-132.md)), not Bruno-only.
- Pre-production adds faction + contracts with validation. - Slice 3 faction rep + gated quest line visible in Godot (capstone [NEO-143](../../manual-qa/NEO-143.md)), not Bruno-only.
- Pre-production adds contracts (E7.M4) with validation beyond Slice 3.

View File

@ -76,7 +76,7 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
| **`prototype_quest_combat_intro`** | Intro: Clear the Pocket | *(none)* | 1× **`encounter_complete`**: **`prototype_combat_pocket`** | | **`prototype_quest_combat_intro`** | Intro: Clear the Pocket | *(none)* | 1× **`encounter_complete`**: **`prototype_combat_pocket`** |
| **`prototype_quest_operator_chain`** | Operator Chain | **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`** | 4 steps: gather **`scrap_metal_bulk` ×5** → craft **`refine_scrap_standard`** → craft **`make_field_stim_mk0`** → **`inventory_has_item`** **`contract_handoff_token` ×1** | | **`prototype_quest_operator_chain`** | Operator Chain | **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`** | 4 steps: gather **`scrap_metal_bulk` ×5** → craft **`refine_scrap_standard`** → craft **`make_field_stim_mk0`** → **`inventory_has_item`** **`contract_handoff_token` ×1** |
**CI enforcement (NEO-112):** `scripts/validate_content.py` requires **exactly** these four quest ids; objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; chain quest terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01). **CI enforcement (NEO-112):** `scripts/validate_content.py` still enforces the **four Slice 1 quest ids** in the freeze table above (prerequisite graph, objective cross-refs, chain terminal step). **Roster allowlist** expanded to **five** quests under [E7.M3](E7_M3_FactionReputationLedger.md) (`PROTOTYPE_E7M3_QUEST_IDS`, NEO-133) — adds **`prototype_quest_grid_contract`**. Shared gates: objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; operator chain terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01).
**Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113).

View File

@ -7,38 +7,82 @@
| **Module ID** | E7.M3 | | **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production | | **Stage target** | Pre-production |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) | | **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**. |
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
## Purpose ## Purpose
Faction standing, reputation deltas, and `FactionGateRule` evaluation for content and contracts. Character-wide **faction standing**, auditable **reputation deltas**, and **`FactionGateRule`** evaluation for quest accept (and future zone/travel gates via [E4.M1](E4_M1_ZoneGraphAndTravelRules.md)). Rep grants flow through [E7.M2](E7_M2_RewardAndUnlockRouter.md) **`completionRewardBundle`** idempotency.
## Responsibilities ## Responsibilities
- Persist auditable `FactionStanding`; apply `ReputationDelta`; fail closed on tamper for gates. - Persist **`FactionStanding`** per player per faction; apply **`ReputationDelta`** with clamp and audit log.
- Evaluate **`FactionGateRule`** on quest accept — fail closed on unknown faction or tampered rules (startup validation).
- Emit telemetry hook sites: **`reputation_delta`**, **`faction_gate_blocked`**.
## Key contracts ## Key contracts
| Contract | Role | | Contract | Role |
|----------|------| |----------|------|
| `FactionStanding` | Current rep per faction. | | `FactionDef` | Faction id, display name, min/max/neutral standing. |
| `ReputationDelta` | Change events with source attribution. | | `FactionStanding` | Current rep per faction for a player. |
| `FactionGateRule` | Unlock conditions for content. | | `ReputationDelta` | Signed change with source attribution (quest id, etc.). |
| `FactionGateRule` | Minimum standing required (e.g. on `QuestDef`). |
| `reputationGrants` | Rows on `QuestRewardBundle``factionId` + `amount`. |
## Module dependencies ## Module dependencies
- **E7.M1** — QuestStateMachine. - **E7.M1** — QuestStateMachine (`TryAccept` gate hook site).
- **E7.M2** — RewardAndUnlockRouter (bundle apply + idempotent delivery store).
## Dependents (by design) ## Dependents (by design)
- **E7.M4** — ContractMissionGenerator. - **E7.M4** — ContractMissionGenerator.
- **E8.M2** — GuildCorpProgressionState. - **E8.M2** — GuildCorpProgressionState.
- **E4.M1** — ZoneGraphAndTravelRules (`TravelRule` faction gates).
## Related implementation slices ## Related implementation slices
Epic 7 **Slice 3**`reputation_delta`, `faction_gate_blocked`. Epic 7 **Slice 3** — at least one faction quest line with rep grant + gated accept; auditable deltas; telemetry **`reputation_delta`**, **`faction_gate_blocked`**. Backlog: [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md).
## Linear backlog (decomposed)
| Slug | Linear |
|------|--------|
| E7M3-01 | [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) |
| E7M3-02 | [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) |
| E7M3-03 | [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) |
| E7M3-04 | [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) |
| E7M3-05 | [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) |
| E7M3-06 | [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) |
| E7M3-07 | [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) |
| E7M3-08 | [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) |
| E7M3-09 | [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) |
| E7M3-10 | [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) |
| E7M3-11 | [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) |
## Prototype Slice 3 freeze (E7M3-01)
### Faction catalog
| `FactionDef.id` | `displayName` | Standing band |
|-----------------|---------------|---------------|
| **`prototype_faction_grid_operators`** | Grid Operators | -100…100 (neutral 0) |
| **`prototype_faction_rust_collective`** | Rust Collective | -100…100 (neutral 0) |
### Quest content changes
| `QuestDef.id` | Rep / gate |
|---------------|------------|
| **`prototype_quest_operator_chain`** | **`completionRewardBundle.reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (existing item + skill XP unchanged) |
| **`prototype_quest_grid_contract`** *(new)* | **`prerequisiteQuestIds`**: operator chain; **`factionGateRules`**: Grid Operators **`minStanding` 15**; objective **`inventory_has_item`** **`survey_drone_kit` ×1**; completion rep **Rust Collective +10** |
**Roster:** five quests total (four E7.M1 onboarding + one faction line). E7.M1 four-quest freeze expands under E7M3 CI gates (`PROTOTYPE_E7M3_QUEST_IDS` in `scripts/validate_content.py`).
**CI enforcement (NEO-133):** `scripts/validate_content.py` validates `content/factions/*_factions.json`, five-quest roster, faction cross-refs on gates and `reputationGrants`, E7M3 completion bundle freeze, and grid-contract shape.
## Source anchors ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7, faction reputation.
- Game design: [skills.md — Rep / story](../../../docs/game-design/skills.md) (character-wide faction rep).
- [Module dependency register](module_dependency_register.md) - [Module dependency register](module_dependency_register.md)

File diff suppressed because one or more lines are too long

View File

@ -113,7 +113,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Ready | | E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Ready |
**E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**; label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. **E7M2-09 / NEO-132** client capstone landed — [`NEO-132` manual QA](../../manual-qa/NEO-132.md), [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132), plan [NEO-132](../../plans/NEO-132-implementation-plan.md). Epic 7 Slice 2 client capstone complete. **E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**; label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. **E7M2-09 / NEO-132** client capstone landed — [`NEO-132` manual QA](../../manual-qa/NEO-132.md), [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132), plan [NEO-132](../../plans/NEO-132-implementation-plan.md). Epic 7 Slice 2 client capstone complete.
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned | | E7.M3 | FactionReputationLedger | E7.M1, E7.M2 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | In Progress |
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. Client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143).
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
### Epic 8 — Social / Guild ### Epic 8 — Social / Guild

View File

@ -52,7 +52,7 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
| 11 | **E7M1-11** | client | E7M1-08, E7M1-09 | | 11 | **E7M1-11** | client | E7M1-08, E7M1-09 |
| 12 | **E7M1-12** | client | E7M1-11 | | 12 | **E7M1-12** | client | E7M1-11 |
**Downstream (separate modules):** [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) Slice 2 backlog [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md) ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)); [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) faction ledger (pre-production); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests. **Downstream (separate modules):** [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) Slice 3 backlog [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
--- ---

View File

@ -46,7 +46,7 @@ Working backlog for **Epic 7 — Slice 2** ([reward and unlock routing](../decom
| 8 | **E7M2-08** | client | E7M2-06 | | 8 | **E7M2-08** | client | E7M2-06 |
| 9 | **E7M2-09** | client | E7M2-08 | | 9 | **E7M2-09** | client | E7M2-08 |
**Downstream (separate modules):** [E6.M4](../decomposition/modules/E6_M4_RewardParityEnforcer.md) lint against bundles; [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) reputation grants (pre-production); E5.M3 **`EncounterCompleteEvent`** full router consumer (optional follow-on). **Downstream (separate modules):** [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) Slice 3 backlog [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
--- ---

View File

@ -0,0 +1,379 @@
# E7.M3 — Pre-production story backlog (FactionReputationLedger)
Working backlog for **Epic 7 — Slice 3** ([faction ledger](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production)). Decomposition and contracts: [E7.M3 — FactionReputationLedger](../decomposition/modules/E7_M3_FactionReputationLedger.md).
**Full-stack policy:** Epics deliver **playable game features**. Every **player-visible** story has a **`client`** Linear issue created in **this same decomposition pass** — not an undocumented follow-up. Reference: [E7M2 (paired server+client)](../plans/E7M2-prototype-backlog.md), [E7M1 (quest HUD + capstone)](../plans/E7M1-prototype-backlog.md).
**Labels (Linear):** every issue **`E7.M3`**; add **`server`** or **`client`** + **`Story`** as listed.
**Precursor (do not re-scope):** [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) **Ready** — quest accept/advance/complete ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)[NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)). [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **Ready** — idempotent **`completionRewardBundle`** delivery ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)). Slice 3 **extends** E7.M2 bundles with **`reputationGrants`**; does not migrate encounter loot or duplicate quest completion grants.
**Upstream (must be landed):** E7.M1 quest state + accept gates; E7.M2 **`RewardRouterOperations`** + **`IRewardDeliveryStore`**; [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md) solo idempotency.
**Prototype faction spine (frozen in E7M3-01):** **two** frozen `FactionDef` ids; **one** reputation grant on **`prototype_quest_operator_chain`**; **one** faction-gated follow-on quest **`prototype_quest_grid_contract`**.
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts.
| Slug | Layer | Linear |
|------|-------|--------|
| E7M3-01 | server | [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) |
| E7M3-02 | server | [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) |
| E7M3-03 | server | [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) |
| E7M3-04 | server | [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) |
| E7M3-05 | server | [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) |
| E7M3-06 | server | [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) |
| E7M3-07 | server | [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) |
| E7M3-08 | server | [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) |
| E7M3-09 | server | [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) |
| E7M3-10 | client | [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) |
| E7M3-11 | client | [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) |
**Dependency graph in Linear:** E7M3-02 blocked by E7M3-01. E7M3-03 blocked by E7M3-02. E7M3-04 blocked by E7M3-03. E7M3-05 blocked by E7M3-02 and E7M3-03. E7M3-06 blocked by E7M3-04. E7M3-07 blocked by E7M3-03. E7M3-08 blocked by E7M3-06 and E7M3-07. E7M3-09 blocked by E7M3-04 and E7M3-05. E7M3-10 blocked by E7M3-07 and E7M3-08. E7M3-11 blocked by E7M3-10.
**Board order:** estimates **111** matching slug order (E7M3-01 = 1 … E7M3-11 = 11). On the Epic 7 project board, sort by **Estimate** (ascending).
---
## Story order (recommended)
| Order | Slug | Layer | Depends on |
|-------|------|-------|------------|
| 1 | **E7M3-01** | server | E7.M1/E7.M2 content frozen |
| 2 | **E7M3-02** | server | E7M3-01 |
| 3 | **E7M3-03** | server | E7M3-02 |
| 4 | **E7M3-04** | server | E7M3-03 |
| 5 | **E7M3-05** | server | E7M3-02, E7M3-03 |
| 6 | **E7M3-06** | server | E7M3-04 |
| 7 | **E7M3-07** | server | E7M3-03 |
| 8 | **E7M3-08** | server | E7M3-06, E7M3-07 |
| 9 | **E7M3-09** | server | E7M3-04, E7M3-05 |
| 10 | **E7M3-10** | client | E7M3-07, E7M3-08 |
| 11 | **E7M3-11** | client | E7M3-10 |
**Downstream (separate modules):** [E7.M4](../decomposition/modules/E7_M4_ContractMissionGenerator.md) contract generator; [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) **`TravelRule`** faction gates; [E8.M2](../decomposition/modules/E8_M2_GuildCorpProgressionState.md) guild/corp rep coupling.
---
## Kickoff decisions (decomposition defaults)
| Topic | Decision | Rationale |
|-------|----------|-----------|
| Faction count | **Two** frozen ids (corp + street) | Readable contrast; Slice 3 AC needs **one** gated line — corp line is flagship |
| Standing range | **-100…100**, neutral **0** | Standard RPG band; clamp on apply |
| Rep grants | **`reputationGrants`** rows on **`completionRewardBundle`** | Reuse E7.M2 idempotent delivery path; audit via delivery store |
| Gate evaluation | **`TryAccept`** only (quest accept) | E4.M1 travel gates deferred; epic Slice 3 is quest/mission gating |
| Gate deny | **`faction_gate_blocked`** reason code | Epic telemetry vocabulary |
| Fail-closed | Unknown **`factionId`** in grant or gate → deny | Tamper / bad content cannot silently pass |
| Delta audit | Append-only **`ReputationDelta`** log per apply | Slice 3 AC: auditable deltas |
| Idempotency | Rep grants ride **`{playerId}:quest_complete:{questId}`** delivery key | Same as E7.M2; replay does not double-apply standing |
| Party mode | **Solo only** | E8.M1 not landed |
| Quest roster | **Five** quests (four E7.M1 + one faction line) | Operator chain grants rep; gated quest proves accept gate |
| HUD fidelity | Prototype labels / debug panels | NEO-122 / NEO-131 precedent |
### Prototype faction freeze (E7M3-01)
| `FactionDef.id` | `displayName` | `minStanding` | `maxStanding` | `neutralStanding` |
|-----------------|---------------|---------------|---------------|---------------------|
| **`prototype_faction_grid_operators`** | Grid Operators | -100 | 100 | 0 |
| **`prototype_faction_rust_collective`** | Rust Collective | -100 | 100 | 0 |
### Prototype reputation + gate freeze (E7M3-01)
| Quest id | Change | Detail |
|----------|--------|--------|
| **`prototype_quest_operator_chain`** | Extend **`completionRewardBundle`** | Add **`reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (item + skill XP rows unchanged) |
| **`prototype_quest_grid_contract`** | **New** quest | **`prerequisiteQuestIds`**: **`prototype_quest_operator_chain`**; **`factionGateRules`**: **`prototype_faction_grid_operators`** **`minStanding` 15**; 1 step **`inventory_has_item`** **`survey_drone_kit` ×1**; completion bundle: **`prototype_faction_rust_collective`** **+10** rep only |
Accepting **`prototype_quest_grid_contract`** before operator-chain completion or before **15** Grid Operators standing returns **`faction_gate_blocked`** or **`prerequisite_incomplete`** respectively.
---
### E7M3-01 — FactionDef catalog + quest gate/rep schemas + CI
**Goal:** Lock faction content shape, extend quest schemas for gates + rep grants, and CI validation before server load.
**In scope**
- `content/schemas/faction-def.schema.json`, `content/schemas/faction-gate-rule.schema.json`, `content/schemas/reputation-grant-row.schema.json`.
- Extend `content/schemas/quest-def.schema.json` with optional **`factionGateRules`** array.
- Extend `content/schemas/quest-reward-bundle.schema.json` with optional **`reputationGrants`** array.
- `content/factions/prototype_factions.json` — two frozen faction rows.
- Update `content/quests/prototype_quests.json` — operator-chain rep grant + fifth quest per freeze table.
- `scripts/validate_content.py`: faction schema validation, quest gate cross-refs, rep grant cross-refs, five-quest allowlist (four E7.M1 + **`prototype_quest_grid_contract`**), E7M2 bundle freeze updated for operator-chain rep row.
- Designer note in [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) + `content/README.md`.
**Out of scope**
- Server loader, stores, operations, HTTP, Godot.
**Acceptance criteria**
- [x] PR gate validates faction JSON and extended quest JSON.
- [x] Exactly two prototype faction ids; fifth quest id present with gate + objective cross-refs.
- [x] Invalid faction refs in gates or grants fail CI.
**Client counterpart:** none (infrastructure-only).
---
### E7M3-02 — Server faction catalog load (fail-fast)
**Goal:** Disk → host: startup load of `content/factions/*_factions.json` with CI-parity validation.
**In scope**
- Loader + registry types under `server/NeonSprawl.Server/Game/Factions/` (or `Game/Reputation/`).
- **`IFactionDefinitionRegistry`** + DI registration.
- Cross-check quest **`factionGateRules`** / bundle **`reputationGrants`** faction ids at startup (after quest catalog load).
- Unit tests (AAA) for happy path and broken faction refs.
- `server/README.md` section.
**Out of scope**
- Per-player standing store, apply logic, HTTP.
**Acceptance criteria**
- [ ] Host exits on invalid faction catalog or broken quest faction refs at startup.
- [ ] Registry resolves faction metadata by id.
**Client counterpart:** none (infrastructure-only).
---
### E7M3-03 — Faction standing store + reputation delta audit store
**Goal:** Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log.
**In scope**
- **`IFactionStandingStore`**, **`IReputationDeltaStore`** — in-memory implementations; optional Postgres migrations (NEO-116-style split).
- Default standing **0** per known faction when missing; clamp reads to faction min/max.
- Audit row: player, faction, delta amount, resulting standing, source kind (`quest_completion`), source id (quest id), timestamp/id.
- Unit tests: first apply, clamp at bounds, audit append.
- `server/README.md` store sections.
**Out of scope**
- Public apply API surface (E7M3-04), quest wiring (E7M3-06).
**Acceptance criteria**
- [ ] Standing readable per player+faction; deltas append-only and queryable for audit tests.
- [ ] Unknown faction id returns structured deny at store boundary.
**Client counterpart:** none (infrastructure-only).
---
### E7M3-04 — `ReputationOperations` (apply `ReputationDelta`)
**Goal:** Server-internal apply for standing changes with clamp, audit, and structured outcomes.
**In scope**
- **`ReputationOperations.TryApplyDelta`** in `server/NeonSprawl.Server/Game/Factions/`.
- Validates faction id via **`IFactionDefinitionRegistry`**; fail-closed on unknown faction.
- Emits audit **`ReputationDelta`** row on success.
- Unit + integration tests (AAA): happy path, clamp min/max, unknown faction deny.
**Out of scope**
- Quest bundle wiring (E7M3-06), accept gates (E7M3-05).
**Acceptance criteria**
- [ ] Delta apply updates standing once and records audit row.
- [ ] Standing never exceeds faction min/max after apply.
**Client counterpart:** none (server engine).
---
### E7M3-05 — `FactionGateOperations` + wire into `QuestStateOperations.TryAccept`
**Goal:** Quest accept evaluates **`FactionGateRule`** rows fail-closed before activation.
**In scope**
- **`FactionGateOperations.TryEvaluate`** — all rules must pass (AND); compare player standing vs **`minStanding`** per rule.
- Call from **`TryAccept`** after prerequisite check, before **`TryActivate`**.
- New reason code **`faction_gate_blocked`** in **`QuestStateReasonCodes`** + accept deny telemetry hook site.
- Integration tests: accept gated quest denied below threshold; succeeds at threshold; unknown faction in rule fails closed at startup (E7M3-02) not runtime.
- Bruno smoke for accept deny/success on **`prototype_quest_grid_contract`**.
**Out of scope**
- Zone/travel gates (E4.M1); Godot HUD (E7M3-10).
**Acceptance criteria**
- [ ] Accept returns **`faction_gate_blocked`** when standing below **`minStanding`**.
- [ ] Accept succeeds when all rules satisfied.
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD surfacing (blocked by E7M3-07/E7M3-08).
---
### E7M3-06 — Extend `RewardRouterOperations` for `reputationGrants`
**Goal:** Quest completion bundles apply faction rep grants idempotently with item/skill XP rows.
**In scope**
- Extend **`RewardRouterOperations.TryDeliverQuestCompletion`** to apply **`reputationGrants`** via **`ReputationOperations`** after validation.
- Extend **`IRewardDeliveryStore`** / **`RewardDeliveryEvent`** snapshot with rep grant lines.
- Compensating rollback policy: if rep apply fails after items/xp committed, document rollback behavior (mirror E7.M2 partial-failure policy).
- Integration tests: operator-chain completion → +15 Grid Operators once; replay idempotent.
- Extend Bruno quest-progress smoke for standing side effect (optional subfolder).
**Out of scope**
- HTTP read projection (E7M3-08), Godot.
**Acceptance criteria**
- [ ] First-time quest completion applies rep grants exactly once per delivery key.
- [ ] Unknown faction id in bundle denies delivery before store record.
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
---
### E7M3-07 — `GET /game/players/{id}/faction-standing` HTTP read
**Goal:** Client-readable standing snapshot for all frozen factions.
**In scope**
- **`GET /game/players/{id}/faction-standing`** — `FactionStandingApi` + DTOs in `Game/Factions/`.
- Response lists each catalog faction with current standing (default 0).
- Integration tests + Bruno `bruno/neon-sprawl-server/faction-standing/`.
- `server/README.md` section.
**Out of scope**
- Godot HUD (E7M3-10).
**Acceptance criteria**
- [ ] GET returns stable JSON for prototype player after quest rep grants.
- [ ] Unknown player returns **404** (position gate precedent).
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
---
### E7M3-08 — Extend quest HTTP projections for rep grants + gate rules
**Goal:** Client can see gate requirements and completion rep summary without local math.
**In scope**
- Extend **`GET /game/world/quest-definitions`** with optional **`factionGateRules`** projection per quest.
- Extend **`GET …/quest-progress`** **`completionRewardSummary`** with **`reputationGrants`** lines (mirror items/skill XP).
- Update **`QuestAcceptApi`** embedded quest rows if applicable.
- Integration tests + Bruno assertions on grid-contract quest row.
- `server/README.md` quest + faction sections.
**Out of scope**
- Godot parse/HUD (E7M3-10).
**Acceptance criteria**
- [ ] Gated quest definition includes **`factionGateRules`** in world GET.
- [ ] Completed quest rows include rep lines in **`completionRewardSummary`** when delivered.
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
---
### E7M3-09 — Faction telemetry hook sites (comment-only)
**Goal:** Anchor future E9.M1 catalog events without ingest.
**In scope**
- Comment-only **`reputation_delta`** in **`ReputationOperations`** (successful apply).
- Comment-only **`faction_gate_blocked`** in **`FactionGateOperations`** / **`TryAccept`** deny path.
- `server/README.md` hook list.
**Out of scope**
- E9.M1 ingest, dashboards.
**Acceptance criteria**
- [ ] Hook sites documented in README; no behavior change beyond comments.
**Client counterpart:** none (infrastructure-only).
---
### E7M3-10 — Client faction standing + gate feedback HUD (Godot)
**Goal:** Player sees faction standing and accept gate denials in Godot without Bruno.
**In scope**
- **`faction_standing_client.gd`**, **`FactionStandingLabel`** (or extend economy HUD section).
- Parse **`GET …/faction-standing`**; refresh after quest completion transitions (reuse **`quest_hud_controller.gd`** signals).
- Surface **`faction_gate_blocked`** on accept feedback label (NEO-122 pattern).
- Extend **`quest_progress_client.gd`** / reward label for **`reputationGrants`** lines in **`completionRewardSummary`**.
- GdUnit parse tests; `client/README.md` section.
- `docs/manual-qa/NEO-142.md` component checklist.
**Out of scope**
- Final journal UI; faction art pass.
**Acceptance criteria**
- [ ] Standing visible after operator-chain completion (+15 Grid Operators).
- [ ] Accepting grid contract below rep shows readable deny copy on HUD.
**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140).
---
### E7M3-11 — Playable faction reputation + gate capstone (Godot)
**Goal:** Prove Epic 7 Slice 3 acceptance **in Godot**: earn rep from operator chain, accept gated faction quest once; gates fail closed.
**In scope**
- **`docs/manual-qa/NEO-143.md`**: capstone session — complete operator chain → verify standing **15** → accept **`prototype_quest_grid_contract`** → complete hand-in → Rust Collective **+10**; retry accept gate before rep (denied).
- **`client/README.md`**: **End-to-end faction reputation loop** section; cross-links NEO-133NEO-142.
- Module alignment on completion: [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md), [module_dependency_register.md](../decomposition/modules/module_dependency_register.md).
**Out of scope**
- Contract generator (E7.M4); zone travel gates (E4.M1); Bruno-only proof.
**Acceptance criteria**
- [ ] Human completes **`docs/manual-qa/NEO-143.md`** with server + client.
- [ ] Epic 7 Slice 3 AC: reputation deltas auditable; gates fail closed on tamper (unknown faction denied at startup).
- [ ] Re-read [epic_07 Slice 3 AC](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production).
**Client counterpart:** this story **is** the Slice 3 client capstone.
---
## Decomposition complete checklist
- [x] Every player-visible AC has a **`client`** Linear issue (NEO-142, NEO-143)
- [x] No forbidden deferral language in backlog
- [x] Capstone client story exists (E7M3-11 / NEO-143)
- [x] Epic DoD updated for Godot verification (see epic_07 update)
- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M3 row updated (decomposition pass)
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M3 note updated
## Related docs
- [epic_07_quest_faction.md](../decomposition/epics/epic_07_quest_faction.md)
- [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md)
- [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md)
- [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md)
- [neon_sprawl_vision.plan.md — Prototype Scope](../../neon_sprawl_vision.plan.md)

View File

@ -0,0 +1,111 @@
# NEO-133 — E7M3-01: FactionDef catalog + quest gate/rep schemas + CI
**Linear:** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)
**Branch:** `NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-01**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-124-implementation-plan.md](NEO-124-implementation-plan.md) (schemas + catalog + `validate_content.py` gates)
## Goal
Land faction and quest content contracts for Epic 7 Slice 3 so **CI** validates faction JSON, extended quest fields (`factionGateRules`, `completionRewardBundle.reputationGrants`), and the frozen five-quest prototype roster **before** NEO-134 server faction catalog load.
## Kickoff clarifications
Decisions are already frozen in [E7M3-pre-production-backlog.md — Kickoff decisions](E7M3-pre-production-backlog.md#kickoff-decisions) and [E7_M3 — Prototype Slice 3 freeze](../decomposition/modules/E7_M3_FactionReputationLedger.md#prototype-slice-3-freeze-e7m3-01). No blocking `AskQuestion` items.
| Topic | Decision | Evidence |
|-------|----------|----------|
| JSON naming | camelCase: `reputationGrants`, `factionGateRules`, `factionId`, `minStanding` | E7M2 bundle pattern; backlog kickoff table |
| Rep delivery | Via `completionRewardBundle.reputationGrants` (E7.M2 bundle idempotency path) | E7_M3 module doc; NEO-138 applies rep |
| Gate site | Quest **accept** only (Slice 3) | Backlog E7M3-06; zone gates deferred to E4.M1 |
| Faction roster | Exactly two ids: `prototype_faction_grid_operators`, `prototype_faction_rust_collective` | E7_M3 freeze table |
| Quest roster | Five quests: four E7.M1 onboarding + `prototype_quest_grid_contract` | E7_M3 freeze; expands `PROTOTYPE_E7M1_QUEST_IDS``PROTOTYPE_E7M3_QUEST_IDS` |
| Operator chain | Add Grid Operators **+15** `reputationGrants`; keep existing item + skill XP | E7_M3 freeze |
| Grid contract | Prereq operator chain; Grid Operators `minStanding` **15**; objective `inventory_has_item` `survey_drone_kit` ×1; completion Rust Collective **+10** | E7_M3 freeze |
| Server loader / stores / HTTP / Godot | **Out of scope** (NEO-134+) | Backlog E7M3-01 scope |
| `dotnet test` green | **Minimal roster parity** on existing E7.M1/E7.M2 catalog rules only (see below) | Content JSON change breaks four-quest server freeze without constant sync |
### Minimal server parity (CI / test green only)
Adding the fifth quest and updated `prototype_quests.json` will fail server startup tests until `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` includes five ids. This is **not** NEO-134 loader work — only constant + freeze-table sync mirroring NEO-124 → NEO-125:
- Extend `ExpectedQuestIds` + add `GridContractQuestId` / objective constants.
- Add `prototype_quest_grid_contract` to `PrototypeE7M2QuestCatalogRules.ExpectedCompletionBundles` with **empty** `itemGrants` / `skillXpGrants` (rep-only bundle; server `ParseCompletionRewardBundle` ignores `reputationGrants` until NEO-138).
- Operator chain JSON may include `reputationGrants`; server E7M2 freeze still compares item + skill XP only (unchanged normalized bundle).
No new faction loader, no `IFactionDefinitionRegistry`, no gate evaluation.
## Files to add
| Path | Purpose |
|------|---------|
| `content/schemas/faction-def.schema.json` | `FactionDef`: `id`, `displayName`, `minStanding`, `maxStanding`, `neutralStanding` |
| `content/schemas/faction-gate-rule.schema.json` | `factionId`, `minStanding` |
| `content/schemas/reputation-grant-row.schema.json` | `factionId`, `amount` (signed int) |
| `content/factions/prototype_factions.json` | Two frozen factions per E7_M3 freeze |
## Files to modify
| Path | Change |
|------|--------|
| `content/schemas/quest-def.schema.json` | Optional `factionGateRules` array (`$ref` faction-gate-rule) |
| `content/schemas/quest-reward-bundle.schema.json` | Optional `reputationGrants` array (`$ref` reputation-grant-row) |
| `content/quests/prototype_quests.json` | Operator chain `reputationGrants`; new `prototype_quest_grid_contract` |
| `scripts/validate_content.py` | Faction load/validate; `PROTOTYPE_E7M3_QUEST_IDS` (5); faction cross-ref gates; `PROTOTYPE_E7M3_COMPLETION_BUNDLES` freeze (item + skill + rep); rename/supersede E7M1 four-quest id gate |
| `content/README.md` | Document `content/factions/`, new schemas, five-quest roster |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Mark catalog landed when done |
| `server/.../PrototypeE7M1QuestCatalogRules.cs` | Five-quest roster + grid-contract constants |
| `server/.../PrototypeE7M2QuestCatalogRules.cs` | Grid-contract empty item/skill bundle in freeze |
## CI gates (`validate_content.py`)
1. **Faction catalog** — schema validate `content/factions/*_factions.json`; freeze exactly two faction ids + display names + standing band (-100…100, neutral 0).
2. **Quest roster**`PROTOTYPE_E7M3_QUEST_IDS` frozen set of five ids (includes all four E7M1 ids + `prototype_quest_grid_contract`).
3. **E7M1 gates retained** — acyclic prerequisites, objective cross-refs, chain terminal token (updated for fifth quest).
4. **Faction cross-refs** — every `factionGateRules[].factionId` and `reputationGrants[].factionId` must exist in faction catalog.
5. **E7M3 completion bundle freeze** — per-quest expected `itemGrants`, `skillXpGrants`, `reputationGrants` for all five quests (supersedes E7M2 freeze for operator chain + grid contract rep rows; first three gather/craft quests unchanged).
6. **Grid contract shape** — prereq operator chain; `factionGateRules` Grid Operators minStanding 15; objective `inventory_has_item` / `survey_drone_kit`.
## Tests
| Layer | What |
|-------|------|
| CI | `python3 scripts/validate_content.py` (existing PR gate) |
| Server | Existing quest catalog loader/registry tests — should pass after roster parity constants; no new `[Fact]` unless a regression needs explicit grid-contract coverage |
Manual QA: **none** (content-only; Godot capstone is NEO-143).
## Implementation order
1. Schemas (`faction-def`, `faction-gate-rule`, `reputation-grant-row`; extend quest-def + quest-reward-bundle).
2. `prototype_factions.json` + quest JSON edits.
3. `validate_content.py` gates + bundle freeze tables.
4. Minimal server roster/freeze constant sync.
5. `content/README.md` + E7_M3 module doc status line.
6. Run `python3 scripts/validate_content.py` and `dotnet test` on server project.
## Out of scope (follow-on tickets)
| Ticket | Work |
|--------|------|
| NEO-134 | Server faction catalog fail-fast load |
| NEO-135137 | Faction standing store, delta audit, gate evaluation on accept |
| NEO-138 | `RewardRouterOperations` reputation apply + idempotent delivery |
| NEO-139143 | HTTP read, telemetry, Godot HUD, capstone |
## Client counterpart
None for E7M3-01. Client faction HUD and capstone: **NEO-142** / **NEO-143**.
## Blocks
**NEO-134** (server faction catalog load) is blocked until this catalog + CI lands.
## Implementation reconciliation (shipped)
- **Schemas:** `faction-def`, `faction-gate-rule`, `reputation-grant-row`; extended `quest-def` + `quest-reward-bundle`.
- **Catalog:** `prototype_factions.json` (two factions); `prototype_quests.json` operator-chain rep + `prototype_quest_grid_contract`.
- **CI:** `PROTOTYPE_E7M3_*` gates in `validate_content.py` (roster, faction freeze, bundle freeze, cross-refs, grid-contract shape).
- **Server parity:** five-quest `ExpectedQuestIds`, grid-contract E7M2 bundle freeze, schema registry for new `$ref`s.
- **Docs:** `content/README.md` E7 Slice 3 paragraph; E7_M3 module CI line.

View File

@ -0,0 +1,207 @@
# NEO-134 — E7M3-02: Server faction catalog load (fail-fast)
**Linear:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)
**Branch:** `NEO-134-e7m3-server-faction-catalog-load-fail-fast`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-02**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-125-implementation-plan.md](NEO-125-implementation-plan.md) (catalog loader + CI-parity gates); [NEO-113](NEO-113-implementation-plan.md) (skill/item catalog DI shape)
**Precursor:** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) **`Done`** — faction schemas, `prototype_factions.json`, quest `factionGateRules` / `reputationGrants`, five-quest CI gates (**landed on `main`**)
## Goal
Fail-fast startup load of `content/factions/*_factions.json` with **CI-parity** validation; cross-check quest **`factionGateRules`** and **`completionRewardBundle.reputationGrants`** against the loaded faction catalog. Host refuses to listen when faction content or quest faction refs are invalid.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-02 backlog scope](E7M3-pre-production-backlog.md#e7m3-02--server-faction-catalog-load-fail-fast), kickoff decisions table, and NEO-125/NEO-133 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Module folder | `server/NeonSprawl.Server/Game/Factions/` | Backlog lists this path first; contracts are `FactionDef` / `IFactionDefinitionRegistry` |
| CI parity | Full E7M3 faction + quest faction gates on server | Backlog “CI-parity validation”; NEO-125 full E7M2 gate precedent |
| Quest DTO parse | Parse `factionGateRules` + `reputationGrants` onto typed rows now | NEO-125 parsed `completionRewardBundle` at load; NEO-137 gate eval needs rows |
| Load order | Faction catalog **before** quest catalog | Quest cross-ref gate needs `knownFactionIds` from faction registry |
| E7M2 bundle freeze | Keep existing item/skill freeze; **add** E7M3 freeze including rep | CI runs both `_prototype_e7m2_*` and `_prototype_e7m3_completion_bundle_freeze_gate` |
| `PrototypeE7M1QuestCatalogRules` rename | **Defer** full type rename | NEO-133 partial fix (error strings); rename is churn across many call sites — not in E7M3-02 AC |
| Standing store / apply / HTTP / Godot | **Out of scope** | Backlog E7M3-02; NEO-135+ |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Faction catalog loader, catalog type, `IFactionDefinitionRegistry`, DI registration.
- `PrototypeE7M3FactionCatalogRules` — roster, freeze table, standing-band ordering (mirrors `scripts/validate_content.py` `PROTOTYPE_E7M3_FACTION_*` + `_prototype_e7m3_faction_*` gates).
- Quest loader extensions: parse `FactionGateRuleRow`, `ReputationGrantRow`; E7M3 quest gates — faction cross-ref, E7M3 completion bundle freeze (item + skill + rep), grid-contract shape.
- `ContentPathsOptions` + path resolution for `content/factions/`.
- Unit tests (AAA) for faction loader happy path + broken refs; quest loader faction cross-ref negatives; host boot resolves faction + quest catalogs.
- `server/README.md` faction catalog section.
**Out of scope:**
- Per-player standing store, reputation apply, gate evaluation on accept, HTTP projection (NEO-135NEO-139).
- Godot client (**client counterpart:** none — infrastructure-only).
## Acceptance criteria checklist
- [x] Host exits on invalid faction catalog or broken quest faction refs at startup.
- [x] Registry resolves faction metadata by id.
- [x] `dotnet test` covers loader gates.
## Implementation reconciliation (shipped)
- **Faction catalog:** `Game/Factions/` loader, catalog, `IFactionDefinitionRegistry`, `PrototypeE7M3FactionCatalogRules`, DI + `Program.cs` load order (after skills, before quests).
- **Quest extensions:** `FactionGateRuleRow`, `ReputationGrantRow`, `PrototypeE7M3QuestFactionRules` (cross-ref, E7M3 bundle freeze, grid-contract shape); quest loader parses faction fields and runs E7M3 gates.
- **Tests:** `FactionDefinitionCatalogLoaderTests`, `FactionDefinitionRegistryTests`, four new quest-loader negative cases; host boot resolves faction + quest catalogs (`746` tests green).
- **Docs:** `server/README.md` faction catalog section; quest catalog paragraph updated for E7M3 gates.
## Technical approach
### 1. Faction DTOs and catalog (`Game/Factions/`)
| Type | Role |
|------|------|
| `FactionDefRow` | `Id`, `DisplayName`, `MinStanding`, `MaxStanding`, `NeutralStanding` |
| `FactionGateRuleRow` | `FactionId`, `MinStanding` (quest-side; lives in `Game/Quests/` or shared — prefer `Game/Quests/` next to quest rows) |
| `ReputationGrantRow` | `FactionId`, `Amount` (quest bundle row; `Game/Quests/`) |
| `FactionDefinitionCatalog` | Immutable `ById` map + directory metadata (mirror `SkillDefinitionCatalog`) |
| `IFactionDefinitionRegistry` / `FactionDefinitionRegistry` | `TryGetDefinition`, `GetDefinitionsInIdOrder` (mirror item registry) |
### 2. `FactionDefinitionCatalogLoader`
Mirror `SkillDefinitionCatalogLoader`:
- Enumerate `*_factions.json` under factions directory; require `schemaVersion` 1 and top-level `factions` array.
- JSON Schema validate each row against `faction-def.schema.json`.
- Reject duplicate faction ids across files.
- Run `PrototypeE7M3FactionCatalogRules`: roster (exactly two ids), freeze table (display names + standing band), standing-band ordering (`minStanding <= neutralStanding <= maxStanding`).
- Throw `InvalidOperationException` with sorted `error:` lines on failure.
### 3. `PrototypeE7M3FactionCatalogRules`
Sync to `scripts/validate_content.py`:
| Constant / helper | Python source |
|-------------------|---------------|
| `ExpectedFactionIds` | `PROTOTYPE_E7M3_FACTION_IDS` |
| `ExpectedFactionFreeze` | `PROTOTYPE_E7M3_FACTION_FREEZE` |
| `TryGetRosterGateError` | `_prototype_e7m3_faction_roster_gate` |
| `TryGetFreezeGateError` | `_prototype_e7m3_faction_freeze_gate` |
| `TryGetStandingBandGateError` | `_prototype_e7m3_faction_standing_band_gate` |
### 4. `PrototypeE7M3QuestFactionRules` (`Game/Quests/`)
Sync quest-side E7M3 gates:
| Helper | Python source |
|--------|---------------|
| `TryGetFactionCrossRefError(rows, knownFactionIds)` | `_prototype_e7m3_faction_cross_ref_gate` |
| `TryGetCompletionBundleFreezeError(rows)` | `_prototype_e7m3_completion_bundle_freeze_gate` (uses `PROTOTYPE_E7M3_COMPLETION_BUNDLES` incl. rep) |
| `TryGetGridContractShapeError(rows)` | `_prototype_e7m3_grid_contract_shape_gate` |
Extend **`QuestDefRow`** with optional `FactionGateRules`. Extend **`QuestRewardBundleRow`** with `ReputationGrants`. Update **`ParseRow`** / **`ParseCompletionRewardBundle`** in `QuestDefinitionCatalogLoader`.
Run E7M3 quest gates **after** E7M2 bundle gates, passing `knownFactionIds` from faction catalog into cross-ref.
### 5. Path resolution and DI
- Add `FactionsDirectory`, `FactionDefSchemaPath` to `ContentPathsOptions`.
- `FactionCatalogPathResolution` — discover `content/factions`, resolve `faction-def.schema.json` sibling under `content/schemas/`.
- `FactionCatalogServiceCollectionExtensions.AddFactionDefinitionCatalog` — singleton catalog + registry.
- **`Program.cs`:** register faction catalog **after** skill catalog, **before** quest catalog; eager-resolve `FactionDefinitionCatalog` at boot.
- **`QuestCatalogServiceCollectionExtensions`:** inject `FactionDefinitionCatalog`; pass `factionCatalog.ById.Keys` into quest loader.
### 6. Tests
**Faction loader (`FactionDefinitionCatalogLoaderTests`):**
- Happy path — repo `prototype_factions.json`.
- Missing factions directory / schema.
- Duplicate faction id.
- Wrong roster count or unknown id.
- Freeze table mismatch (display name or standing).
- Invalid standing band ordering.
**Quest loader extensions (`QuestDefinitionCatalogLoaderTests`):**
- Unknown `factionId` in `factionGateRules`.
- Unknown `factionId` in `reputationGrants`.
- E7M3 bundle freeze mismatch (operator-chain rep row).
- Grid-contract shape violation (wrong `minStanding` or objective).
- Existing repo prototype load + `Host_ShouldResolveQuestCatalogFromDi` still pass.
**Registry (`FactionDefinitionRegistryTests`):** `TryGetDefinition` hit/miss; id-order listing.
### 7. Docs
- **`server/README.md`** — new faction catalog paragraph (config keys, gates, load order before quests).
- Optional one-line E7_M3 module status when implementation lands.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs` | Load-time faction row |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs` | In-memory catalog |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs` | Fail-fast disk load |
| `server/NeonSprawl.Server/Game/Factions/FactionCatalogPathResolution.cs` | Directory + schema path discovery |
| `server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs` | DI registration |
| `server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs` | Lookup contract |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs` | Registry adapter |
| `server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs` | CI-parity faction gates |
| `server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs` | Quest gate row |
| `server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs` | Bundle rep grant row |
| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs` | Quest-side E7M3 faction gates |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs` | Loader AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs` | Registry AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs` | Repo path discovery for tests |
## Files to modify
| Path | Change |
|------|--------|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `FactionsDirectory`, `FactionDefSchemaPath` |
| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Optional `FactionGateRules` property |
| `server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs` | Add `ReputationGrants` list |
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Parse faction fields; accept `knownFactionIds`; run E7M3 quest faction gates |
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | Inject faction catalog into quest loader |
| `server/NeonSprawl.Server/Program.cs` | Register + eager-resolve faction catalog before quests |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | E7M3 negative cases; extend `LoadCatalog` helper with faction ids |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Faction directory/schema discovery helpers |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Ensure host boot resolves faction catalog if needed |
| `server/README.md` | Faction catalog section |
## Tests
| Layer | What |
|-------|------|
| `FactionDefinitionCatalogLoaderTests` | Happy path, missing paths, duplicate id, roster/freeze/standing-band gates |
| `FactionDefinitionRegistryTests` | Lookup by id, ordered listing |
| `QuestDefinitionCatalogLoaderTests` | Faction cross-ref, E7M3 bundle freeze, grid-contract shape; repo prototype + host boot |
| CI | Existing `dotnet test` + `validate_content.py` (no Python changes expected) |
Manual QA: **none** (server infrastructure; Godot capstone is NEO-143).
## Implementation order
1. Faction DTOs, rules, path resolution, loader, catalog, registry, DI.
2. Quest DTO extensions + parse + `PrototypeE7M3QuestFactionRules` + wire into quest loader.
3. `Program.cs` load order + `ContentPathsOptions`.
4. Tests (faction loader → quest negatives → host boot).
5. `server/README.md`.
6. Run `dotnet test` on server project.
## Open questions / risks
| Item | Agent recommendation | Status |
|------|---------------------|--------|
| Rename `PrototypeE7M1QuestCatalogRules``PrototypeE7M3QuestCatalogRules` | Defer — not required for AC; large rename across tests and call sites | `deferred` |
| E7M2 vs E7M3 bundle freeze overlap | Keep both gates (CI does); E7M2 item/skill only, E7M3 adds rep rows | `adopted` |
| Quest loader already registers `faction-gate-rule` / `reputation-grant-row` schemas (NEO-133) | Reuse; no schema registration changes needed | `adopted` |
## Client counterpart
None for E7M3-02. Client faction HUD: **NEO-142** / capstone **NEO-143**.
## Blocks
**NEO-135** (faction standing store) is blocked until faction catalog load lands.

View File

@ -0,0 +1,220 @@
# NEO-135 — E7M3-03: Faction standing store + reputation delta audit store
**Linear:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)
**Branch:** `NEO-135-e7m3-faction-standing-reputation-stores`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-03**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-116-implementation-plan.md](NEO-116-implementation-plan.md) (in-memory + Postgres when configured); [NEO-126](NEO-126-implementation-plan.md) (append-only audit/event store shape)
**Precursor:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) **`Done`** — `IFactionDefinitionRegistry`, fail-fast faction catalog (**landed on `main`**)
**Blocks:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) (ReputationOperations), [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) (gate eval reads standing), [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) (HTTP read)
## Goal
Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log. Stores validate faction ids fail-closed at the boundary; missing standing reads as **0** (neutral) clamped to faction min/max.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-03 backlog scope](E7M3-pre-production-backlog.md#e7m3-03--faction-standing-store--reputation-delta-audit-store), kickoff decisions table, and NEO-116/NEO-134 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Postgres persistence | **Include when configured** — in-memory fallback otherwise | Backlog “NEO-116-style split”; NEO-116 adopted same |
| Store split | **`IFactionStandingStore`** + **`IReputationDeltaStore`** | Backlog in-scope list; audit append-only separate from snapshot |
| Public apply API | **Out of scope** — store-level mutation primitives only | Backlog defers **`ReputationOperations`** to E7M3-04 / NEO-136 |
| Unknown faction deny | **Structured outcome** with stable reason code at store boundary | Linear AC; kickoff “Fail-closed \| Unknown factionId → deny” |
| Default standing | **0** when row missing; **clamp on read** to registry min/max | Backlog + kickoff standing band **-100…100**, neutral **0** |
| Clamp on write | **Store primitive clamps** resulting standing before persist | Backlog unit tests “clamp at bounds”; NEO-136 AC inherits same bounds |
| Player write gate | **`CanWritePlayer`** on standing store (dev bucket / Postgres `player_position`) | NEO-116 quest store precedent |
| Faction validation | Stores inject **`IFactionDefinitionRegistry`** — not raw string trust | Startup catalog + AC “unknown faction id” at store boundary |
| Audit row shape | player, faction, delta, resulting standing, source kind, source id, timestamp, row id | Backlog E7M3-03 in-scope bullet |
| Source kind (prototype) | **`quest_completion`** string constant only | Backlog example; E7M3-04/06 wire quest id as source id |
| DI registration | **`AddFactionStandingStores`** extension separate from catalog | NEO-116 `AddPlayerQuestStateStore` precedent |
| HTTP / Godot / gate eval | **Out of scope** | Backlog E7M3-03; NEO-137+ |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Snapshot + audit types, id normalization, reason codes.
- **`IFactionStandingStore`** + in-memory + Postgres implementations.
- **`IReputationDeltaStore`** + in-memory + Postgres implementations.
- **`V009__player_faction_standing.sql`**, **`V010__reputation_delta_audit.sql`** when `ConnectionStrings:NeonSprawl` is set.
- **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores`** wired from **`Program.cs`**.
- Unit tests (AAA): default read **0**, read clamp, first standing delta apply, clamp at min/max bounds, unknown faction deny, audit append + query, append-only semantics.
- Postgres persistence integration tests (`RequirePostgresFact`) for both stores.
- `server/README.md` faction standing + reputation delta store sections.
**Out of scope:**
- **`ReputationOperations.TryApplyDelta`** public orchestration (NEO-136 / E7M3-04).
- Quest accept gate evaluation (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot (NEO-142+).
- Telemetry hook comments (NEO-141).
**Client counterpart:** none (infrastructure-only).
## Acceptance criteria checklist
- [x] Standing readable per player+faction; deltas append-only and queryable for audit tests.
- [x] Unknown faction id returns structured deny at store boundary.
- [x] `dotnet test` covers store gates.
## Implementation reconciliation (shipped)
- **Types:** `FactionStandingIds`, reason codes, read/mutation outcomes, `ReputationDeltaRow`, `ReputationDeltaSourceKinds`.
- **Stores:** `IFactionStandingStore` / `IReputationDeltaStore`; in-memory + Postgres (`V009`, `V010`) when connection string set.
- **DI:** `AddFactionStandingStores` in `Program.cs`; in-memory override in `InMemoryWebApplicationFactory`.
- **Tests:** `InMemoryFactionStandingStoreTests`, `InMemoryReputationDeltaStoreTests`, Postgres persistence integration tests; host DI smoke (`768` tests green).
- **Docs:** `server/README.md` faction standing + reputation delta store sections.
## Technical approach
### 1. Types (`Game/Factions/`)
| Type | Role |
|------|------|
| `FactionStandingIds` | Normalize `playerId` / `factionId`; composite key helper (mirror `QuestProgressIds`) |
| `FactionStandingReasonCodes` | `unknown_faction`, `player_not_writable` (`invalid_delta` deferred to NEO-136 `ReputationOperations`) |
| `FactionStandingReadOutcome` | `Success`, optional `ReasonCode`, `Standing` (clamped) |
| `FactionStandingMutationOutcome` | `Success`, optional `ReasonCode`, `PreviousStanding`, `NewStanding` |
| `ReputationDeltaSourceKinds` | `QuestCompletion = "quest_completion"` (prototype constant) |
| `ReputationDeltaRow` | Immutable audit row: `Id`, `PlayerId`, `FactionId`, `DeltaAmount`, `ResultingStanding`, `SourceKind`, `SourceId`, `RecordedAt` |
Standing snapshot is keyed **`(playerId, factionId)` → int`**; no separate row type beyond normalized ids + value.
### 2. `IFactionStandingStore`
Inject **`IFactionDefinitionRegistry`** in implementations (not on interface methods).
- **`bool CanWritePlayer(string playerId)`** — dev bucket or Postgres player row exists.
- **`FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)`** — missing row ⇒ **0**; unknown faction ⇒ deny with `unknown_faction`; returned value clamped to faction min/max from registry.
- **`FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)`** — validates faction + writable player; reads current (default 0), adds delta, **clamps** to min/max, persists; first write returns success with before/after. Denies unknown faction / non-writable player / empty ids. **Does not append audit** (NEO-136 orchestrates both stores).
XML remarks: consumed by **`ReputationOperations`** (NEO-136) and gate eval (NEO-137); not HTTP directly.
### 3. `IReputationDeltaStore`
- **`bool TryAppend(ReputationDeltaRow row)`** — append-only; returns `false` on duplicate `Id` or invalid row (empty player/faction/id).
- **`IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)`** — test/audit read; ordered by `RecordedAt` then `Id` ascending.
No update/delete APIs in prototype.
### 4. In-memory implementations
Mirror NEO-116 / NEO-38 patterns:
- **`InMemoryFactionStandingStore`** — `ConcurrentDictionary` keyed by composite key; seeds dev player from `GamePositionOptions`; per-key locks.
- **`InMemoryReputationDeltaStore`** — append-only list/dictionary by row id; secondary index by player for queries; thread-safe.
### 5. Postgres implementations
Mirror NEO-116 quest store:
- **`PostgresPlayerFactionStandingBootstrap`** + **`V009__player_faction_standing.sql`**
- Table `player_faction_standing (player_id FK → player_position, faction_id, standing INT, updated_at)`
- PK `(player_id, faction_id)`
- **`PostgresReputationDeltaBootstrap`** + **`V010__reputation_delta_audit.sql`**
- Table `reputation_delta_audit (id UUID/TEXT PK, player_id FK, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)`
- Index on `(player_id, recorded_at)` for audit queries
- **`PostgresFactionStandingStore`**, **`PostgresReputationDeltaStore`** — `EnsureSchema` on first use; standing store checks `player_position` exists before write.
### 6. DI and `Program.cs`
- **`FactionStandingServiceCollectionExtensions.AddFactionStandingStores(configuration)`** — Postgres pair when connection string set; else in-memory pair.
- Register **after** `AddFactionDefinitionCatalog` (stores depend on registry at runtime, not catalog singleton).
- Place near other player persistence (`AddPlayerQuestStateStore`, `AddRewardDeliveryStore`).
- **`InMemoryWebApplicationFactory`**: ensure stores resolve (override only if host tests need isolation — follow existing factory pattern).
### 7. Tests
**In-memory (`InMemoryFactionStandingStoreTests`):**
- Missing row reads **0** for known faction.
- Read clamp when stored value exceeds max (direct internal seed or apply overflow scenario).
- First `TryApplyStandingDelta` +15 from 0 → 15.
- Apply delta that would exceed max clamps to **100**; below min clamps to **-100**.
- Unknown faction id → `unknown_faction` deny, standing unchanged.
- Non-writable player → deny.
**In-memory (`InMemoryReputationDeltaStoreTests`):**
- `TryAppend` success; `GetDeltasForPlayer` returns row.
- Second append with new id succeeds; duplicate id returns `false`.
- Empty player/faction/id → append deny.
**Postgres (`FactionStandingPersistenceIntegrationTests`, `ReputationDeltaPersistenceIntegrationTests`):**
- Write on factory A, read on factory B (`RequirePostgresFact` + harness).
**Host DI smoke:** resolve both stores from `WebApplicationFactory` (optional one-liner in existing host test file).
Manual QA: **none** (server infrastructure).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Factions/FactionStandingIds.cs` | Player/faction id normalization |
| `server/NeonSprawl.Server/Game/Factions/FactionStandingReasonCodes.cs` | Stable deny codes |
| `server/NeonSprawl.Server/Game/Factions/FactionStandingReadOutcome.cs` | Read result record |
| `server/NeonSprawl.Server/Game/Factions/FactionStandingMutationOutcome.cs` | Mutation result record |
| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs` | Source kind constants |
| `server/NeonSprawl.Server/Game/Factions/ReputationDeltaRow.cs` | Audit row type |
| `server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs` | Standing store contract |
| `server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs` | Audit store contract |
| `server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs` | Dev in-memory standing |
| `server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs` | Dev in-memory audit |
| `server/NeonSprawl.Server/Game/Factions/PostgresPlayerFactionStandingBootstrap.cs` | Migration bootstrap |
| `server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs` | Postgres standing |
| `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaBootstrap.cs` | Audit migration bootstrap |
| `server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs` | Postgres audit |
| `server/NeonSprawl.Server/Game/Factions/FactionStandingServiceCollectionExtensions.cs` | DI registration |
| `server/db/migrations/V009__player_faction_standing.sql` | Standing table |
| `server/db/migrations/V010__reputation_delta_audit.sql` | Audit table |
| `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs` | Standing AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs` | Audit AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionStandingPersistenceIntegrationTests.cs` | Postgres standing |
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationDeltaPersistenceIntegrationTests.cs` | Postgres audit |
## Files to modify
| Path | Change |
|------|--------|
| `server/NeonSprawl.Server/Program.cs` | Register `AddFactionStandingStores` after faction catalog |
| `server/README.md` | Faction standing + reputation delta store sections |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Register/override faction stores if host tests require it |
## Tests
| Layer | What |
|-------|------|
| `InMemoryFactionStandingStoreTests` | Default 0, read clamp, apply delta, clamp min/max, unknown faction, player gate |
| `InMemoryReputationDeltaStoreTests` | Append, query, duplicate-id deny, validation |
| `FactionStandingPersistenceIntegrationTests` | Postgres write/read round-trip (`RequirePostgresFact`) |
| `ReputationDeltaPersistenceIntegrationTests` | Postgres append/query round-trip (`RequirePostgresFact`) |
| CI | Existing `dotnet test` + no content pipeline changes |
## Implementation order
1. Types, reason codes, ids, store interfaces.
2. In-memory standing + audit stores.
3. Postgres migrations + bootstrap + Postgres stores.
4. DI extension + `Program.cs`.
5. Unit tests → Postgres integration tests → `dotnet test`.
6. `server/README.md`.
## Open questions / risks
| Item | Agent recommendation | Status |
|------|---------------------|--------|
| Single vs dual migration files | **Two files (V009 standing, V010 audit)** — matches one-table-per-migration precedent (V007 gig, V008 quest) | `adopted` |
| Audit row id format | **UUID string** generated by caller (NEO-136); store rejects empty/duplicate | `adopted` |
| Standing store exposes `TryApplyStandingDelta` vs absolute set | **Delta primitive only** — matches backlog test wording and NEO-136 apply flow | `adopted` |
| Registry on read for unknown faction | **Deny unknown on read and write** — AC “store boundary”; gate eval (NEO-137) always uses catalog factions | `adopted` |
## Client counterpart
None for E7M3-03. Client faction HUD: **NEO-142** / capstone **NEO-143**.
## Blocks
**NEO-136** (`ReputationOperations`) and **NEO-137** (gate eval) depend on these stores landing first.

View File

@ -0,0 +1,159 @@
# NEO-136 — E7M3-04: ReputationOperations (apply ReputationDelta)
**Linear:** [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)
**Branch:** `NEO-136-e7m3-reputation-operations-apply-delta`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-04**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-105](NEO-105-implementation-plan.md) / `EncounterCompletionOperations` — static ops + reason codes + compensating rollback; [NEO-127](NEO-127-implementation-plan.md) — orchestrate store writes in order
**Precursor:** [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` (**landed on `main`**)
**Blocks:** [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) (reward bundle rep wiring), [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) (telemetry hook sites)
## Goal
Server-internal **`ReputationOperations.TryApplyDelta`** orchestrates standing mutation + append-only audit so game code never calls **`IFactionStandingStore.TryApplyStandingDelta`** alone. Apply clamps to faction min/max, records one **`ReputationDelta`** row on success, and returns structured outcomes.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-04 backlog scope](E7M3-pre-production-backlog.md#e7m3-04--reputationoperations-apply-reputationdelta), NEO-135 handoff notes, and NEO-62/NEO-105/NEO-127 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Orchestration shape | **Static `ReputationOperations`** with injected stores + `TimeProvider` | `CraftOperations`, `RewardRouterOperations`, `EncounterCompletionOperations` precedent |
| Faction validation | **Delegate to `IFactionStandingStore`** (already fail-closed via `IFactionDefinitionRegistry`) | Backlog “validates faction id via registry”; store implements that at boundary — no duplicate registry inject |
| `invalid_delta` | **Deny `deltaAmount == 0`** with reason code **`invalid_delta`** at ops layer before store | NEO-135 deferred constant to NEO-136; `SkillProgressionGrantOperations` denies `amount <= 0` |
| Audit row id | **Caller supplies non-empty `deltaId`** (UUID string) | NEO-135 adopted decision table: “UUID string generated by caller (NEO-136)” |
| Source attribution | **Require non-empty `sourceKind` + `sourceId`**; prototype constant `quest_completion` from `ReputationDeltaSourceKinds` | NEO-135 audit row shape; NEO-138 passes quest id as `sourceId` |
| Commit order | **Standing apply first, audit append second** | `IReputationDeltaStore` XML + `server/README.md` Postgres FK remark |
| Audit append failure | **Compensating standing rollback** — inverse delta via `TryApplyStandingDelta` when `TryAppend` returns `false` | NEO-62/NEO-69/NEO-105 compensating rollback precedent; avoid standing without audit |
| Result type | **New `ReputationApplyOutcome`** (success, reason, before/after standing, optional persisted audit row) | Store outcomes lack ops-level codes (`invalid_delta`, `audit_append_failed`) |
| HTTP / quest wiring / Godot | **Out of scope** | Backlog E7M3-04; NEO-138 wires `RewardRouterOperations` |
| Bruno / manual QA | **None** — server engine only | Backlog client counterpart: none |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`ReputationOperations.TryApplyDelta`** in `server/NeonSprawl.Server/Game/Factions/`.
- **`ReputationApplyOutcome`**, **`ReputationApplyReasonCodes`** (`invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`; passthrough `unknown_faction`, `player_not_writable` from standing store).
- Add **`invalid_delta`** to **`FactionStandingReasonCodes`** only if store layer needs it later — **prefer ops-only `ReputationApplyReasonCodes`** to keep store contract unchanged.
- Unit tests (AAA): happy path (+15 from 0, audit row queryable), clamp at min/max via ops, unknown faction deny, `deltaAmount == 0` deny, duplicate `deltaId` audit deny with standing unchanged (rollback), empty `deltaId` / source deny.
- Host DI smoke: resolve stores + one ops apply through factory services (extend existing faction store host test or dedicated ops test).
- `server/README.md` **`ReputationOperations`** section (callers must use ops, not standing store alone).
**Out of scope:**
- Quest bundle wiring via **`RewardRouterOperations`** (NEO-138 / E7M3-06).
- Quest accept gate evaluation (NEO-137).
- HTTP GET standing (NEO-139), telemetry hook comments (NEO-141), Godot (NEO-142+).
- Postgres-specific integration test for ops (store persistence already covered by NEO-135; ops tests use in-memory stores).
**Client counterpart:** none (server engine).
## Acceptance criteria checklist
- [x] Delta apply updates standing once and records audit row.
- [x] Standing never exceeds faction min/max after apply.
- [x] `dotnet test` covers ops happy path, clamp, unknown faction deny, and invalid-delta deny.
## Implementation reconciliation (shipped)
- **Operations:** `ReputationOperations.TryApplyDelta` — pre-flight validation, standing apply, audit append, compensating rollback on append failure.
- **Types:** `ReputationApplyOutcome`, `ReputationApplyReasonCodes` (`invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`).
- **Tests:** `ReputationOperationsTests` — 11 AAA cases including host DI smoke and clamped audit-append rollback (`781` tests green).
- **Docs:** `server/README.md` ReputationOperations section; E7.M3 module + alignment register updated.
## Technical approach
### 1. Types (`Game/Factions/`)
| Type | Role |
|------|------|
| `ReputationApplyReasonCodes` | `invalid_delta`, `invalid_delta_id`, `invalid_source`, `audit_append_failed`; passthrough store codes |
| `ReputationApplyOutcome` | `Success`, `ReasonCode`, `PreviousStanding`, `NewStanding`, optional `AuditRow` (`ReputationDeltaRow`) |
### 2. `ReputationOperations.TryApplyDelta`
Signature (static, mirror other ops):
```csharp
public static ReputationApplyOutcome TryApplyDelta(
string playerId,
string factionId,
int deltaAmount,
string deltaId,
string sourceKind,
string sourceId,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
```
**Flow:**
1. Normalize ids via **`FactionStandingIds`**; use normalized values for store calls and audit row.
2. **Pre-flight denies** (no store mutation):
- `deltaAmount == 0``invalid_delta`
- empty `deltaId``invalid_delta_id`
- empty `sourceKind` or `sourceId``invalid_source`
3. **`standingStore.TryApplyStandingDelta(playerId, factionId, deltaAmount)`** — on deny, return outcome with store **`ReasonCode`** and zeroed standings.
4. Build **`ReputationDeltaRow`** with caller `deltaId`, **applied** delta (`newStanding - previousStanding`), `NewStanding` from mutation outcome, normalized ids, **`timeProvider.GetUtcNow()`**.
5. **`auditStore.TryAppend(row)`** — on `true`, return success with audit row.
6. On append `false`: **compensating rollback**`standingStore.TryApplyStandingDelta` with **`-appliedDelta`** where `appliedDelta = mutation.NewStanding - mutation.PreviousStanding` (skip when zero); return deny with **`audit_append_failed`**.
XML remarks: consumed by **`RewardRouterOperations`** (NEO-138) and future admin tools; not HTTP directly. NEO-141 telemetry hook site on successful apply (comment-only in NEO-141).
### 3. Tests
**`ReputationOperationsTests`** (in-memory stores + prototype faction registry helper mirroring `InMemoryFactionStandingStoreTests`):
| Test | Covers |
|------|--------|
| `TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath` | +15 from 0; audit row fields; `GetDeltasForPlayer` |
| `TryApplyDelta_ShouldClampToMax_WhenDeltaExceedsBand` | Standing 95 + 50 → 100; audit `ResultingStanding` 100 |
| `TryApplyDelta_ShouldClampToMin_WhenDeltaBelowBand` | Standing -95 + (-50) → -100 |
| `TryApplyDelta_ShouldDenyUnknownFaction_WithStructuredReason` | No standing change; no audit rows |
| `TryApplyDelta_ShouldDenyInvalidDelta_WhenAmountZero` | `invalid_delta` |
| `TryApplyDelta_ShouldDenyInvalidDeltaId_WhenEmpty` | `invalid_delta_id` |
| `TryApplyDelta_ShouldDenyInvalidSource_WhenEmpty` | `invalid_source` |
| `TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails` | Pre-seeded duplicate id at standing 95; +50 clamped apply rolls back to 95 |
| `TryApplyDelta_ShouldDenyNonWritablePlayer` | `player_not_writable` passthrough |
**Host DI smoke:** one test resolving stores from `InMemoryWebApplicationFactory` and applying via `ReputationOperations` (can live in same test file).
Manual QA: **none** (infrastructure-only).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Factions/ReputationApplyReasonCodes.cs` | Ops-level stable deny codes including `invalid_delta` |
| `server/NeonSprawl.Server/Game/Factions/ReputationApplyOutcome.cs` | Structured apply result with optional audit row |
| `server/NeonSprawl.Server/Game/Factions/ReputationOperations.cs` | `TryApplyDelta` orchestration + compensating rollback |
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs` | AAA unit + host DI smoke tests |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/README.md` | Document `ReputationOperations.TryApplyDelta`, commit order, rollback policy, and “do not call standing store alone for game apply” |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status line: E7M3-04 in progress / landed when shipped |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-136 plan + shipped ops when complete |
## Tests
| File | Action | Coverage |
|------|--------|----------|
| `server/NeonSprawl.Server.Tests/Game/Factions/ReputationOperationsTests.cs` | **Add** | Happy path, clamp min/max, unknown faction, invalid delta/id/source, audit-append rollback, non-writable player, host DI smoke |
No new Postgres integration tests — NEO-135 already covers store persistence; ops layer is store-agnostic.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Standing rollback fails after audit append deny | Document as prototype invariant violation (same as NEO-62 depletion edge); no cross-store transaction | `deferred` |
| Negative `deltaAmount` for future rep loss | **Allow** signed delta at ops layer; content grants are positive only | `adopted` |
| Caller-generated `deltaId` vs auto UUID | **Caller supplies id** per NEO-135; NEO-138 can derive from delivery key + faction | `adopted` |
| Duplicate `deltaId` on retry after rollback | Append fails → rollback → caller may retry with **new** id; quest idempotency at delivery store prevents double call in NEO-138 | `adopted` |
**NEO-138** (`RewardRouterOperations` rep grants) and **NEO-141** (telemetry comments) depend on this orchestration landing first.

View File

@ -0,0 +1,210 @@
# NEO-137 — E7M3-05: FactionGateOperations + wire into TryAccept
**Linear:** [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)
**Branch:** `NEO-137-e7m3-faction-gate-operations-tryaccept`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-05**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-136](NEO-136-implementation-plan.md) — static ops + structured outcome + reason codes; [NEO-117](NEO-117-implementation-plan.md) — `QuestStateOperations.TryAccept` deny path + `QuestStateReasonCodes`
**Precursors:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) **`Done`** — `FactionGateRuleRow` on `QuestDefRow`, catalog cross-ref gates; [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) **`Done`** — `IFactionStandingStore.TryGetStanding` (**landed on `main`**)
**Blocks:** [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) (telemetry hook sites — comment-only consolidation)
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD gate feedback (blocked by E7M3-07/E7M3-08)
## Goal
Quest accept evaluates **`FactionGateRule`** rows fail-closed before activation. Standing below **`minStanding`** denies with **`faction_gate_blocked`**; all rules satisfied (AND) allows accept to proceed to **`TryActivate`**.
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|----------------------|--------|
| Bruno smoke scope | Backlog lists deny + success Bruno on **`prototype_quest_grid_contract`**, but rep grants (+15 standing) are not wired until **NEO-138** and there is no HTTP standing seed until **NEO-139**. | **Deny-only Bruno** in NEO-137; **success at threshold** via integration tests seeding standing through **`ReputationOperations`** / store; defer **success Bruno** to **NEO-138** when operator-chain completion grants +15 organically. | **Adopted** — deny-only Bruno now |
Other decisions settled by Linear AC, backlog, and repo precedent (no additional questions):
| Topic | Decision | Evidence |
|-------|----------|----------|
| Gate semantics | **AND** — all rules must pass; empty rule list ⇒ pass | Backlog E7M3-05 |
| Comparison | **`standing >= minStanding`** succeeds; strictly below denies | Linear AC “below minStanding” / “succeeds when all rules satisfied”; grid contract **`minStanding` 15** |
| Unknown faction at runtime | **Fail-closed via standing store** if ever reached; startup catalog cross-ref (NEO-134) prevents bad content | Backlog “unknown faction … fails closed at startup (E7M3-02) not runtime” + defense-in-depth |
| Ops shape | **Static `FactionGateOperations.TryEvaluate`** + **`FactionGateEvaluateOutcome`** | `ReputationOperations`, `EncounterCompletionOperations` precedent |
| Wire site | After prerequisite check, **before `TryActivate`** | Backlog E7M3-05 |
| Reason code | **`faction_gate_blocked`** on **`QuestStateReasonCodes`** | Backlog + epic telemetry vocabulary |
| Telemetry | **Comment-only `faction_gate_blocked` hook** in deny path (NEO-141 README consolidation later) | NEO-136 added `reputation_delta` hook in same slice; backlog E7M3-05 lists hook site |
| HTTP / Godot / travel gates | **Out of scope** | Backlog E7M3-05; E4.M1 deferred |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`FactionGateOperations.TryEvaluate`** — AND all rules; read standing via **`IFactionStandingStore.TryGetStanding`** per rule.
- **`FactionGateEvaluateOutcome`** (+ optional **`FactionGateEvaluateReasonCodes`** if needed beyond quest-level code).
- Wire into **`QuestStateOperations.TryAccept`** after prerequisites, before activation.
- **`QuestStateReasonCodes.FactionGateBlocked`** = **`faction_gate_blocked`**.
- Pass **`IFactionStandingStore`** through **`TryAccept`**, **`QuestAcceptApi`**, and test helpers.
- Integration tests: grid contract denied at standing **14** (prereq complete); succeeds at **15**; quests without gates unaffected.
- **Deny-only** Bruno: accept **`prototype_quest_grid_contract`** on fresh dev player ⇒ **`faction_gate_blocked`** (prerequisite completed via prior Bruno steps or documented seq).
- Comment-only telemetry hook on gate deny path.
- `server/README.md` **`FactionGateOperations`** section.
**Out of scope:**
- **`reputationGrants`** delivery (NEO-138); success Bruno when standing comes from operator-chain completion (NEO-138).
- HTTP GET standing (NEO-139), Godot HUD (NEO-142), zone/travel gates (E4.M1).
- E9.M1 telemetry ingest (NEO-141 documents hooks only).
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — surfaces **`faction_gate_blocked`** on accept feedback (requires NEO-139/NEO-140 HTTP).
## Acceptance criteria checklist
- [x] Accept returns **`faction_gate_blocked`** when standing below **`minStanding`**.
- [x] Accept succeeds when all rules satisfied.
- [x] `dotnet test` covers gate deny, threshold success, and no-gate quests.
## Implementation reconciliation (shipped)
- **Operations:** `FactionGateOperations.TryEvaluate` — AND gate eval via standing store reads.
- **Types:** `FactionGateEvaluateOutcome`, `FactionGateEvaluateReasonCodes`.
- **Wire:** `QuestStateOperations.TryAccept` + `QuestAcceptApi` inject `IFactionStandingStore`; `QuestStateReasonCodes.FactionGateBlocked`.
- **Tests:** `FactionGateOperationsTests` (5 cases), grid contract integration in `QuestStateOperationsTests` + `QuestAcceptApiTests` (790 tests green).
- **Bruno:** deny-only `Accept grid contract faction gate deny.bru` with `POST …/__dev/quest-fixture` pre-request; success deferred to NEO-138.
- **Dev fixture:** `QuestFixtureApi` marks quests completed without reward delivery (Bruno CI prerequisite).
- **Docs:** `server/README.md` FactionGateOperations section; E7.M3 module + alignment register updated.
## Technical approach
### 1. Types (`Game/Factions/`)
| Type | Role |
|------|------|
| `FactionGateEvaluateOutcome` | `Success`, optional `ReasonCode`, optional failing rule context (`FactionId`, `MinStanding`, `CurrentStanding`) for telemetry/HUD |
| `FactionGateEvaluateReasonCodes` | `gate_blocked` (internal ops code if distinct from quest code); passthrough **`unknown_faction`** from standing store |
Quest-level deny uses **`QuestStateReasonCodes.FactionGateBlocked`** (`faction_gate_blocked`) — same string epic telemetry expects.
### 2. `FactionGateOperations.TryEvaluate`
```csharp
public static FactionGateEvaluateOutcome TryEvaluate(
string playerId,
IReadOnlyList<FactionGateRuleRow> rules,
IFactionStandingStore standingStore)
```
**Flow:**
1. Normalize **`playerId`** via **`FactionStandingIds`**.
2. **Empty rules** ⇒ success immediately (vacuous AND).
3. For each rule in order:
- Normalize **`rule.FactionId`**.
- **`standingStore.TryGetStanding(playerId, factionId)`** — on store deny, return outcome with store **`ReasonCode`** (defense-in-depth **`unknown_faction`**).
- If **`standing < rule.MinStanding`**, return deny with failing rule context and ops reason **`gate_blocked`**.
4. All rules pass ⇒ success.
XML remarks: called from **`QuestStateOperations.TryAccept`** only in prototype; future E4.M1 travel gates may reuse. NEO-141 telemetry hook site on deny return.
### 3. Wire `QuestStateOperations.TryAccept`
After prerequisite loop, before **`TryActivate`**:
```csharp
var gate = FactionGateOperations.TryEvaluate(playerId, definition.FactionGateRules, standingStore);
if (!gate.Success)
{
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked);
}
```
Add **`IFactionStandingStore standingStore`** parameter to **`TryAccept`** (and all internal/test call sites). **`QuestAcceptApi`** resolves store from DI.
**DenyAccept** already threads **`quest_accept_denied`** telemetry hook (NEO-121); gate deny inherits that path with **`reasonCode: faction_gate_blocked`**.
### 4. Prototype test scenarios
| Scenario | Setup | Expected |
|----------|-------|----------|
| Below threshold | Complete **`prototype_quest_operator_chain`** prerequisite only; standing **0** (no rep wiring) | **`faction_gate_blocked`**, no activation |
| At threshold | Same prereq + seed **+15** Grid Operators via **`ReputationOperations.TryApplyDelta`** | Accept succeeds, quest active |
| No gates | Accept **`prototype_quest_gather_intro`** | Unchanged behavior |
| Standing 14 | Seed **+14** via ops | **`faction_gate_blocked`** |
Constants: **`PrototypeE7M3QuestFactionRules.GridContractQuestId`**, **`GridContractGateFactionId`**, **`GridContractMinStanding`** (15), **`ChainQuestId`** prerequisite.
### 5. Bruno (deny-only)
New request under **`bruno/neon-sprawl-server/quest-progress/`** (or subfolder):
- Complete operator-chain prerequisite path is heavy for Bruno; **minimal deny smoke**: accept grid contract on default dev player with **operator chain completed** but **standing 0** — requires Bruno sequence or docs referencing manual prereq completion.
- **Practical seq:** reuse existing accept/complete Bruno steps for operator chain where possible, then accept grid contract ⇒ **`faction_gate_blocked`**. If operator-chain complete Bruno is too heavy, document seq number after gather/chain smokes and add a focused deny request assuming fresh DB with chain already completed via prior collection run.
Defer **success accept Bruno** to **NEO-138** (operator-chain completion applies +15 rep).
### 6. Tests
**`FactionGateOperationsTests`** — unit AAA on in-memory standing store:
| Test | Covers |
|------|--------|
| `TryEvaluate_ShouldPass_WhenRulesEmpty` | Vacuous AND |
| `TryEvaluate_ShouldPass_WhenStandingAtMin` | standing **15**, min **15** |
| `TryEvaluate_ShouldDeny_WhenStandingBelowMin` | standing **14**, min **15**; failing context |
| `TryEvaluate_ShouldDenyUnknownFaction_WhenStoreDenies` | Invalid faction id (if injectable) |
**Extend `QuestStateOperationsTests`** (or add **`QuestFactionGateIntegrationTests`**):
| Test | Covers |
|------|--------|
| `TryAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold` | Prereq complete, standing 0 |
| `TryAccept_ShouldAcceptGridContract_WhenStandingAtThreshold` | Seed +15, accept succeeds |
| `TryAccept_ShouldAcceptGatherIntro_WhenNoGateRules` | Regression |
**Extend `QuestAcceptApiTests`**: HTTP POST grid contract deny ⇒ **`faction_gate_blocked`** body.
**Host DI smoke:** resolve **`IFactionStandingStore`** + one gate eval through factory (can live in **`FactionGateOperationsTests`**).
Manual QA: **none** (server engine; Bruno deny smoke replaces player-visible checklist until NEO-142).
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateOutcome.cs` | Structured gate eval result with optional failing rule context |
| `server/NeonSprawl.Server/Game/Factions/FactionGateEvaluateReasonCodes.cs` | Ops-level codes (`gate_blocked`; store passthrough) |
| `server/NeonSprawl.Server/Game/Factions/FactionGateOperations.cs` | `TryEvaluate` AND logic over standing reads |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs` | AAA unit + host DI smoke |
| `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` | Deny-only Bruno smoke for **`faction_gate_blocked`** |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs` | Add **`FactionGateBlocked`** constant |
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Inject standing store; call gate eval before activate; telemetry hook comment on gate deny |
| `server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs` | Resolve **`IFactionStandingStore`**; pass to **`TryAccept`** |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Gate integration tests; update **`TryAccept`** helper signature |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | HTTP deny test for grid contract |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Update **`TryAccept`** helper if shared |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Update **`TryAccept`** call sites |
| `server/README.md` | Document **`FactionGateOperations`**, accept gate order, **`faction_gate_blocked`**, Bruno note |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Status: E7M3-05 in progress / landed |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Register NEO-137 plan |
## Tests
| File | Action | Coverage |
|------|--------|----------|
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs` | **Add** | Empty rules, at/below threshold, store deny passthrough, host DI |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | **Change** | Grid contract deny/success; no-gate regression |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | **Change** | HTTP **`faction_gate_blocked`** on grid contract accept |
No new Postgres tests — gate eval is read-only against existing standing store.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Bruno prereq setup weight | Start with deny request + docs seq; extend collection with chain-complete steps only if CI/manual run needs automation | `adopted` |
| Success Bruno timing | **Defer to NEO-138** when rep grants make +15 standing reachable via HTTP quest flow | `adopted` |
| Failing rule in HTTP response | **Not in scope** — accept response keeps **`reasonCode` only**; NEO-142/NEO-140 may enrich later | `adopted` |
| Gate eval order vs prerequisites | **Prerequisites first** per backlog; player could hit **`prerequisite_incomplete`** before **`faction_gate_blocked`** | `adopted` |
**NEO-138** (rep grants) and **NEO-141** (telemetry README) follow this gate landing.

View File

@ -0,0 +1,61 @@
# Code review — NEO-133 (E7M3-01)
**Date:** 2026-06-15
**Scope:** Branch `NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci` — commits `7183461``0948d9d` vs `main`
**Base:** `main` (merge-base at `33cfc62`)
## Verdict
**Approve with nits**
## Summary
This branch lands Epic 7 Slice 3 **content contracts** ahead of server faction load: three new JSON schemas (`FactionDef`, `FactionGateRule`, `ReputationGrantRow`), `prototype_factions.json`, quest JSON extensions (`factionGateRules`, `reputationGrants`), a fifth quest (`prototype_quest_grid_contract`), and matching CI gates in `scripts/validate_content.py`. Server changes are intentionally minimal—five-quest roster constants, rep-only grid-contract E7M2 bundle freeze entry, and schema `$ref` registration so startup validation accepts the new quest JSON. Documentation and decomposition tracking (E7.M3 backlog, module register, alignment table) are updated coherently. Risk is low: no runtime faction/rep behavior yet; `validate_content.py` and `dotnet test` both pass locally.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-133-implementation-plan.md` | **Matches** — shipped items reconcile with plan (schemas, catalog, CI, server parity, docs). |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-01 AC checked; client capstone NEO-142/143 documented. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Slice 3 freeze table and CI line updated. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — Slice 1 freeze table preserved; CI paragraph cross-links E7.M3 five-quest roster. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M3 status In Progress, E7.M2 dependency added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row added. |
| `docs/decomposition/epics/epic_07_quest_faction.md` | **Matches** — Slice 3 backlog link and Godot capstone AC. |
| `content/README.md` | **Matches** — factions folder, Slice 3 paragraph. |
| `server/README.md` | **N/A for NEO-133** — no faction section yet (planned NEO-134). |
Register/tracking updates for E7.M3 are appropriately landed in this PR.
## Blocking issues
None.
## Suggestions
1. ~~**E7.M1 module doc vs five-quest CI** — `E7_M1_QuestStateMachine.md` still states CI requires exactly the four Slice 1 quest ids. Add a one-line cross-link to E7.M3 (`PROTOTYPE_E7M3_QUEST_IDS`) so readers do not assume the four-quest gate is still the active roster check.~~ **Done.** CI paragraph cross-links E7.M3 roster expansion.
2. ~~**Server constant naming** — `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` and `TryGetE7M1GateError` now enforce **five** ids while labels/messages still say “E7M1”. When NEO-134 lands, consider `PrototypeE7M3QuestCatalogRules` (or rename + update error strings) to avoid onboarding confusion.~~ **Done (partial).** Error string now says “prototype quest roster”; class/XML docs note NEO-134 rename. Full type rename deferred to NEO-134.
3. ~~**Remove dead CI helper** — `_prototype_e7m1_quest_gate` in `scripts/validate_content.py` is no longer called (superseded by `_prototype_e7m3_quest_roster_gate`). Delete or mark deprecated to prevent accidental reintroduction of the four-quest gate.~~ **Done.** Dead helper removed.
4. ~~**Faction standing band schema** — `faction-def.schema.json` does not constrain `minStanding <= neutralStanding <= maxStanding`. Optional JSON Schema `minimum`/`maximum` cross-field checks (or a CI gate mirroring faction freeze) would catch designer mistakes before NEO-134 server load.~~ **Done.** `_prototype_e7m3_faction_standing_band_gate` enforces ordering on all faction rows.
## Nits
- ~~Nit: `PROTOTYPE_E7M1_QUEST_IDS` (four ids) remains beside `PROTOTYPE_E7M3_QUEST_IDS` (five ids); fine for historical Slice 1 reference, but a short comment on E7M1 constant (“Slice 1 roster subset; CI roster gate is E7M3”) would clarify intent.~~ **Done.**
- ~~Nit: `QuestDefinitionCatalogLoaderTests.Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract` asserts gather/chain rows but does not assert grid-contract gate/bundle fields; acceptable per plan (“no new Fact unless regression needs it”), but a single assert on grid-contract prereq/gate would cheaply lock the fifth quest shape server-side.~~ **Done.** Asserts grid-contract prereq and rep-only bundle (item/skill empty at server parse layer).
## Verification
Commands run during review (passed):
```bash
python3 scripts/validate_content.py
dotnet test server/NeonSprawl.Server.Tests
```
Author should confirm PR CI content gate green before merge.
**Known intentional gap (not a blocker for E7M3-01):** Server `QuestDefRow` / `QuestRewardBundleRow` do not model `factionGateRules` or `reputationGrants`; JSON is schema-validated at load but ignored until NEO-134NEO-138. Grid contract can be accepted without standing check until NEO-137/NEO-138.

View File

@ -0,0 +1,55 @@
# Code review — NEO-134 (E7M3-02)
**Date:** 2026-06-15
**Scope:** Branch `NEO-134-e7m3-server-faction-catalog-load-fail-fast` — commits `edb3351``48bca98` vs `main`
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-02**: fail-fast startup load of `content/factions/*_factions.json` with CI-parity roster/freeze/standing-band gates, `IFactionDefinitionRegistry`, and quest-loader extensions that parse `factionGateRules` / `reputationGrants` and run E7M3 cross-ref, completion-bundle freeze (including rep rows), and grid-contract shape gates. Load order is correct (skills → factions → quests via DI dependency). The implementation mirrors established catalog patterns (`SkillDefinitionCatalogLoader`, NEO-125 quest gates) and keeps Python/C# gate constants in sync. Test coverage is strong (faction loader negatives, registry + host boot, four new quest-loader negatives); `746` server tests and `validate_content.py` pass locally. Risk is low — infrastructure-only, no player-facing runtime behavior yet. Minor gaps: decomposition tracking docs not updated for E7M3-02, and two plan-listed loader negative cases are only partially covered.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-134-implementation-plan.md` | **Matches** — shipped items reconcile with plan (faction catalog, quest extensions, DI/load order, tests, README). AC checklist marked complete. |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-02 scope (server load, CI parity, no client counterpart) aligns with implementation. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-02 server load landed (NEO-134). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-02 / NEO-134. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — no register update required for this slice. |
| `server/README.md` | **Matches** — new faction catalog section; quest catalog paragraph updated for E7M3 gates and faction dependency. |
| `content/README.md` | **N/A** — no content changes in this branch. |
## Blocking issues
None.
## Suggestions
1. ~~**Decomposition status** — Update `E7_M3_FactionReputationLedger.md` **Status** (one line) and `documentation_and_implementation_alignment.md` E7.M3 row to record **E7M3-02 server faction catalog load** landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)). Plan §7 lists this as optional but it keeps module tracking honest for NEO-135 consumers.~~ **Done.** Module status + alignment table updated for E7M3-02 / NEO-134.
2. ~~**Standing-band loader integration test** — Plan §6 lists “Invalid standing band ordering” under `FactionDefinitionCatalogLoaderTests`. `TryGetStandingBandGateError_ShouldReturnError_WhenOrderingFails` covers the rule in isolation; add a `Load_ShouldThrow_WhenStandingBandOrderingFails` that writes invalid `minStanding`/`neutralStanding` JSON through the full loader path (cheap regression lock).~~ **Done.** Added `Load_ShouldThrow_WhenStandingBandOrderingFails`; loader runs standing-band gate before freeze so the integration path reports the band error.
3. ~~**Missing factions directory test** — Plan §6 also lists “Missing factions directory / schema.” Schema-missing is covered; add `Load_ShouldThrow_WhenFactionsDirectoryMissing` for parity with other catalog loader test suites.~~ **Done.**
## Nits
- ~~Nit: `FactionDefinitionCatalogLoaderTests.cs` imports `Microsoft.Extensions.DependencyInjection` but does not use it.~~ **Done.**
- ~~Nit: `FactionDefinitionCatalogLoader` logs unconditionally on success while `QuestDefinitionCatalogLoader` guards with `logger.IsEnabled(LogLevel.Information)` — harmless, but inconsistent with sibling loaders.~~ **Done.**
## Verification
Commands run during review (passed):
```bash
dotnet test server/NeonSprawl.Server.Tests
python3 scripts/validate_content.py
```
Author should confirm PR CI green before merge. Bruno smoke (`bruno/neon-sprawl-server/faction-catalog/`) is appropriate — no manual QA doc required per plan (infrastructure-only).
**Intentional follow-on (not blockers):** Standing store, gate evaluation on accept, HTTP projection, and Godot HUD remain NEO-135NEO-143. `PrototypeE7M1QuestCatalogRules` full rename remains deferred per plan.

View File

@ -0,0 +1,56 @@
# Code review — NEO-135 (E7M3-03)
**Date:** 2026-06-15
**Scope:** Branch `NEO-135-e7m3-faction-standing-reputation-stores` — commits `08ebfce``4fca45b` vs `main`
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-03**: durable per-player **`IFactionStandingStore`** snapshots and append-only **`IReputationDeltaStore`** audit rows, with in-memory fallbacks and Postgres implementations (`V009`, `V010`) when `ConnectionStrings:NeonSprawl` is set. Stores inject **`IFactionDefinitionRegistry`** for fail-closed faction validation, default missing standing to **0** with read/write clamping, and gate mutations (and reads) on the dev player bucket / `player_position` row — mirroring NEO-116 quest-store patterns. Test coverage is solid: in-memory AAA for apply/clamp/deny paths, audit ordering/limit/duplicate-id semantics, Postgres persistence round-trips, and host DI smoke. **`766`** server tests pass locally. Risk is low — infrastructure-only, no HTTP or player-facing behavior. Minor gaps: decomposition tracking docs not updated for E7M3-03, and one plan-listed in-memory read-clamp case is only covered via Postgres integration.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-135-implementation-plan.md` | **Matches** — shipped types, stores, migrations, DI, tests, and README reconcile with plan; AC checklist marked complete. Plan types table lists `invalid_delta` reason code not present in code (reserved for NEO-136 — see nits). |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-03 scope (dual stores, clamp, audit append, no public apply API, no client counterpart) aligns with implementation. Backlog AC checkboxes still unchecked (plan doc is authoritative for this branch). |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-03 stores landed (NEO-135). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-03 / NEO-135. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionStanding` / `ReputationDelta` contracts. |
| `server/README.md` | **Matches** — new faction standing + reputation delta store sections; correctly defers auditable apply to NEO-136. |
| `bruno/neon-sprawl-server/faction-standing/` | **N/A plan item** — health smoke folder added (reasonable host-start verification; not listed in plan). |
## Blocking issues
None.
## Suggestions
1. ~~**Decomposition status** — Update `E7_M3_FactionReputationLedger.md` **Status** (one line) and `documentation_and_implementation_alignment.md` E7.M3 row to record **E7M3-03 faction standing + reputation delta stores** landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)). Same pattern as NEO-134 review follow-up.~~ **Done.** Module status + alignment table updated for E7M3-03 / NEO-135.
2. ~~**In-memory read clamp test** — Plan §7 lists “Read clamp when stored value exceeds max” under `InMemoryFactionStandingStoreTests`. `TryGetStanding_ShouldClampRead_WhenStoredValueExceedsMax` exists only in Postgres integration tests. Add an in-memory case (e.g. apply to max then manually seed out-of-band via a second apply + direct dictionary seed is not possible — use apply overflow then verify read clamp, or test via Postgres-only path is insufficient for unit layer). Simplest: after clamp-to-max apply, verify read returns **100**; for true out-of-band raw, mirror Postgres test with reflection/internal test hook **or** document that read-clamp defense is integration-tested only. Prefer a dedicated in-memory test that applies deltas to max then asserts read clamp on subsequent reads at boundary (partial coverage) plus keeping Postgres test for stale DB values.~~ **Done.** Added `TryGetStanding_ShouldClampRead_WhenRawStoredValueExceedsMax` using internal `SeedRawStandingForTests`; Postgres integration test retained for stale DB values.
3. ~~**Plan types table drift** — `NEO-135-implementation-plan.md` §1 lists `FactionStandingReasonCodes.invalid_delta`; shipped code only has `unknown_faction` and `player_not_writable`. Add a one-line note in the plan that `invalid_delta` is deferred to NEO-136 `ReputationOperations`, or add the constant now without wiring (avoid dead code — note-only is fine).~~ **Done.** Plan types table notes `invalid_delta` deferred to NEO-136.
## Nits
- ~~Nit: `PostgresReputationDeltaStore.TryAppend` relies on FK to `player_position`; a row for a non-existent player throws rather than returning `false`. Acceptable if NEO-136 always applies standing first; worth a one-line remark in README or interface XML for orchestrators.~~ **Done.** Remark added to `IReputationDeltaStore.TryAppend` XML and `server/README.md`.
- Nit: Host DI smoke (`Host_ShouldResolveFactionStandingStoresFromDi_WhenStartupSucceeds`) lives in `InMemoryFactionStandingStoreTests.cs` rather than a host-focused test file — fine per plan “optional one-liner.”
- ~~Nit: `FactionStandingPersistenceIntegrationTests` places `Assert.True(outcome.Success)` inside the Act block for the first factory write — minor AAA deviation; assertions could move entirely to Assert phase.~~ **Done.**
- ~~Nit: Empty `factionId` on read/write returns `player_not_writable` rather than `unknown_faction` — consistent with empty-id handling elsewhere, but slightly imprecise reason code.~~ **Done.** Empty faction id now returns `unknown_faction`.
## Verification
Commands run during review (passed):
```bash
dotnet test server/NeonSprawl.Server.Tests
```
Author should confirm PR CI green before merge. With Postgres configured locally, `RequirePostgresFact` integration tests should also pass (`ConnectionStrings__NeonSprawl`). Bruno smoke (`bruno/neon-sprawl-server/faction-standing/`) is appropriate — no manual QA doc required per plan (infrastructure-only).
**Intentional follow-on (not blockers):** `ReputationOperations` orchestration (NEO-136), gate eval reads (NEO-137), reward bundle rep wiring (NEO-138), HTTP GET (NEO-139), Godot HUD (NEO-142+).

View File

@ -0,0 +1,53 @@
# Code review — NEO-136 (E7M3-04)
**Date:** 2026-06-15
**Scope:** Branch `NEO-136-e7m3-reputation-operations-apply-delta` — commits `e9abc8b``536f635` vs `main`
**Follow-up:** Review findings addressed in follow-up commit (applied-delta rollback, ops id normalization, AAA nit).
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
This branch lands **E7M3-04**: server-internal **`ReputationOperations.TryApplyDelta`** orchestrates standing mutation via **`IFactionStandingStore`**, append-only audit via **`IReputationDeltaStore`**, and compensating standing rollback when audit append fails. Supporting types **`ReputationApplyOutcome`** and **`ReputationApplyReasonCodes`** cover ops-level pre-flight denies and store passthrough. Ten AAA unit tests plus host DI smoke exercise happy path, clamp, unknown faction, invalid delta/id/source, audit-append rollback, non-writable player, and factory resolution. **`780`** server tests pass locally. Risk is low — infrastructure-only, no HTTP or Godot wiring (NEO-138+). One non-blocking correctness gap: compensating rollback uses `-deltaAmount` rather than the **actual applied delta** when clamping occurred, which can leave wrong standing on rare audit-append failure after a clamped apply.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-136-implementation-plan.md` | **Matches** — shipped ops, types, tests, README, module/alignment updates reconcile with plan; AC checklist marked complete. Plan flow step 1 cites explicit `FactionStandingIds` normalization at ops layer; implementation delegates player/faction normalization to stores (see Suggestions). |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-04 scope (auditable apply orchestration, no client counterpart, no HTTP) aligns with implementation. |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-04 `ReputationOperations` landed (NEO-136). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-04 / NEO-136. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionStanding` / `ReputationDelta` contracts. |
| `server/README.md` | **Matches** — new ReputationOperations section documents commit order, rollback policy, pre-flight denies, and “do not call standing store alone.” |
## Blocking issues
None.
## Suggestions
1. ~~**Compensating rollback delta when clamping** — On audit append failure, rollback uses `-deltaAmount`:~~ **Done.** Rollback now uses `-appliedDelta` where `appliedDelta = mutation.NewStanding - mutation.PreviousStanding`; audit row records applied delta. Test `TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails` added.
2. ~~**Ops-layer id normalization (plan alignment)** — Plan §2 step 1 says normalize via **`FactionStandingIds`** before store calls.~~ **Done.** `TryApplyDelta` normalizes player/faction ids at the ops boundary; audit row built from normalized values.
## Nits
- ~~Nit: **`TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath`** — `auditStore.GetDeltasForPlayer(PlayerId)` runs in the **Act** phase; per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), verification reads belong in **Assert**.~~ **Done.** `GetDeltasForPlayer` moved to Assert.
- Nit: **`ReputationApplyOutcome`** on store-boundary deny returns standings from the mutation outcome (good); pre-flight **`Deny()`** zeroes standings — callers must not treat `0` as “neutral read” on `invalid_delta` / `invalid_source` denies (documented implicitly by reason codes).
- Nit: Telemetry hook comment for NEO-141 is correctly placed on the success path only — no action needed.
## Verification
Commands run during review (passed):
```bash
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ReputationOperationsTests"
dotnet test NeonSprawl.sln
```
Author should confirm PR CI green before merge. No manual QA doc required (server engine only per plan). **Intentional follow-on:** reward bundle rep wiring (NEO-138), quest accept gate eval (NEO-137), HTTP GET standing (NEO-139), Godot HUD (NEO-142+).

View File

@ -0,0 +1,56 @@
# Code review — NEO-137 (E7M3-05)
**Date:** 2026-06-15
**Scope:** Branch `NEO-137-e7m3-faction-gate-operations-tryaccept` — commits `1c3d075``0241cc3` vs `main`
**Base:** `main`
**Follow-up:** Review findings addressed in follow-up commits (Bruno CI fix via quest fixture API, multi-rule test, README, nits).
## Verdict
~~**Request changes**~~ **Approved after follow-up**
## Summary
This branch lands **E7M3-05**: **`FactionGateOperations.TryEvaluate`** evaluates quest **`FactionGateRule`** rows with vacuous-AND semantics (`standing >= minStanding`), wired into **`QuestStateOperations.TryAccept`** after prerequisite checks and before activation. Quest-level denies surface **`faction_gate_blocked`** via **`QuestStateReasonCodes.FactionGateBlocked`**; ops-level **`FactionGateEvaluateOutcome`** retains failing-rule context for future telemetry/HUD. **`IFactionStandingStore`** threads through **`QuestAcceptApi`** and test helpers. Coverage is solid: five unit tests, three integration tests on grid contract (deny at 0/14, success at 15), HTTP deny test, and host DI smoke. **`790`** server tests pass locally. Core server logic matches the plan and NEO-136 ops precedent. ~~One **blocking** gap: the new Bruno deny smoke runs in CI without completing **`prototype_quest_operator_chain`**, so the collection will assert the wrong **`reasonCode`** (`prerequisite_incomplete` instead of **`faction_gate_blocked`**).~~ **Done.** Bruno pre-request calls **`POST …/__dev/quest-fixture`** to mark operator chain completed without rep delivery; CI enables **`Game:EnableQuestFixtureApi`**.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-137-implementation-plan.md` | **Matches** — ops, types, wire site, reason code, tests, README, module/alignment updates; AC checklist marked complete; success Bruno deferred to NEO-138 per kickoff. |
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-05 scope (quest accept gate eval, no HTTP standing read, no client counterpart). |
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Status line notes E7M3-05 `FactionGateOperations` landed (NEO-137). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row updated for E7M3-05 / NEO-137. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A change** — register row already lists `FactionGateRule` contract. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches**`TryAccept` gate hook site behavior aligns with module responsibilities. |
| `server/README.md` | **Matches** — new FactionGateOperations section + quest accept POST table includes `faction_gate_blocked`. |
## Blocking issues
1. ~~**Bruno CI will fail on faction-gate deny smoke.** `bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru` (seq **10**) asserts `reasonCode === "faction_gate_blocked"`, but the quest-progress collection never completes **`prototype_quest_operator_chain`** before that request (prior steps only accept/deny gather/refine through seq **8**). **`TryAccept`** checks prerequisites before gate eval, so a fresh dev player receives **`prerequisite_incomplete`**, not **`faction_gate_blocked`**. `.github/workflows/dotnet.yml` runs the full Bruno collection with **`--bail`** on every push touching `bruno/neon-sprawl-server/**`. Fix options (pick one): add prerequisite Bruno steps (accept + complete operator chain, or a dev fixture seed if/when available), add a **`script:pre-request`** that marks the chain completed the way integration tests do, or defer/remove this request from the CI collection until NEO-138 makes the spine reachable. The `.bru` **docs** block acknowledges the prerequisite but the collection does not implement it.~~ **Done.** Added **`QuestFixtureApi`** (`POST …/__dev/quest-fixture`) + Bruno pre-request; CI **`Game:EnableQuestFixtureApi`**.
## Suggestions
1. ~~**Multi-rule AND unit test.** Prototype grid contract has one gate today; add a **`TryEvaluate`** test with two rules (one pass, one fail) to lock AND semantics if content adds multi-faction gates before E4.M1 travel gates reuse the ops.~~ **Done.** `TryEvaluate_ShouldDeny_WhenSecondRuleFailsInAndList` in `FactionGateOperationsTests.cs`.
2. ~~**Bruno seq documentation in README.** `server/README.md` notes success Bruno is deferred to NEO-138; add one line that deny Bruno requires operator-chain-complete setup (or list the intended seq after prerequisite steps land) so manual runs do not surprise operators.~~ **Done.** FactionGateOperations README section documents seq **10** + quest-fixture pre-request.
## Nits
- ~~Nit: **`TryAccept`** maps all gate failures to **`faction_gate_blocked`**, discarding ops-level **`unknown_faction`** from the standing store — intentional per plan (quest-level code only in HTTP), but worth a one-line XML remark on the gate deny branch if NEO-141 telemetry needs to distinguish store vs threshold denies later.~~ **Done.** Comment on gate deny branch in `QuestStateOperations.cs`.
- ~~Nit: **`FactionGateOperationsTests`** host DI smoke asserts **`gate_blocked`** at standing 0 without seeding standing — correct behavior, but a brief comment that missing standing reads as **0** would aid future readers.~~ **Done.** Arrange comment in host DI smoke test.
## Verification
```bash
# Full server suite (author reported 790 green)
dotnet test NeonSprawl.sln
# Faction-gate-focused filter
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~FactionGate|FullyQualifiedName~GridContract"
# Bruno (will fail on seq 10 until prerequisite setup is added)
cd bruno/neon-sprawl-server
npx --yes @usebruno/cli@3.3.0 run quest-progress --env Local --sandbox=developer --bail
```
Manual: after fixing Bruno prerequisites, run **`Accept grid contract faction gate deny.bru`** only when **`prototype_quest_operator_chain`** is **completed** and Grid Operators standing is **&lt; 15**.

View File

@ -15,7 +15,9 @@ Validates:
- reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100) - reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100)
- encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100) - encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100)
- quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112); - quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112);
optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124) optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124);
optional factionGateRules + reputationGrants (NEO-133)
- faction catalogs: content/factions/*_factions.json rows vs content/schemas/faction-def.schema.json (NEO-133)
""" """
from __future__ import annotations from __future__ import annotations
@ -46,6 +48,9 @@ QUEST_STEP_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-step-def.schema.json"
QUEST_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-def.schema.json" QUEST_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-def.schema.json"
QUEST_REWARD_BUNDLE_SCHEMA = REPO_ROOT / "content/schemas/quest-reward-bundle.schema.json" QUEST_REWARD_BUNDLE_SCHEMA = REPO_ROOT / "content/schemas/quest-reward-bundle.schema.json"
QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant.schema.json" QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant.schema.json"
FACTION_DEF_SCHEMA = REPO_ROOT / "content/schemas/faction-def.schema.json"
FACTION_GATE_RULE_SCHEMA = REPO_ROOT / "content/schemas/faction-gate-rule.schema.json"
REPUTATION_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reputation-grant-row.schema.json"
SKILLS_DIR = REPO_ROOT / "content/skills" SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery" MASTERY_DIR = REPO_ROOT / "content/mastery"
ITEMS_DIR = REPO_ROOT / "content/items" ITEMS_DIR = REPO_ROOT / "content/items"
@ -56,6 +61,7 @@ NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"
REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables" REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters" ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
QUESTS_DIR = REPO_ROOT / "content/quests" QUESTS_DIR = REPO_ROOT / "content/quests"
FACTIONS_DIR = REPO_ROOT / "content/factions"
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes. # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
@ -160,8 +166,9 @@ PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
"contract_handoff_token": 1, "contract_handoff_token": 1,
} }
# Epic 7 Slice 1 prototype lock (NEO-112): exact quest ids after schema passes. # Epic 7 Slice 1 prototype lock (NEO-112): Slice 1 onboarding quest ids (historical subset).
# Keep in sync with E7.M1 freeze table and future NEO-113 server loader. # Active CI roster gate is PROTOTYPE_E7M3_QUEST_IDS (five quests, NEO-133).
# Keep in sync with E7.M1 freeze table; server ExpectedQuestIds expanded for E7M3.
PROTOTYPE_E7M1_QUEST_IDS = frozenset( PROTOTYPE_E7M1_QUEST_IDS = frozenset(
{ {
"prototype_quest_gather_intro", "prototype_quest_gather_intro",
@ -195,6 +202,74 @@ PROTOTYPE_E7M2_COMPLETION_BUNDLES: dict[str, dict] = {
}, },
} }
# Epic 7 Slice 3 prototype lock (NEO-133): five-quest roster + faction catalog.
# Keep in sync with E7.M3 freeze table and future NEO-134 server loader.
PROTOTYPE_E7M3_FACTION_IDS = frozenset(
{
"prototype_faction_grid_operators",
"prototype_faction_rust_collective",
}
)
PROTOTYPE_E7M3_FACTION_FREEZE: dict[str, dict] = {
"prototype_faction_grid_operators": {
"displayName": "Grid Operators",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0,
},
"prototype_faction_rust_collective": {
"displayName": "Rust Collective",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0,
},
}
PROTOTYPE_E7M3_QUEST_IDS = frozenset(
{
"prototype_quest_gather_intro",
"prototype_quest_refine_intro",
"prototype_quest_combat_intro",
"prototype_quest_operator_chain",
"prototype_quest_grid_contract",
}
)
PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID = "prototype_quest_grid_contract"
PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID = "prototype_quest_operator_chain"
PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID = "prototype_faction_grid_operators"
PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING = 15
PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID = "survey_drone_kit"
PROTOTYPE_E7M3_COMPLETION_BUNDLES: dict[str, dict] = {
"prototype_quest_gather_intro": {
"itemGrants": [],
"skillXpGrants": [{"skillId": "salvage", "amount": 25}],
"reputationGrants": [],
},
"prototype_quest_refine_intro": {
"itemGrants": [],
"skillXpGrants": [{"skillId": "refine", "amount": 25}],
"reputationGrants": [],
},
"prototype_quest_combat_intro": {
"itemGrants": [],
"skillXpGrants": [{"skillId": "salvage", "amount": 25}],
"reputationGrants": [],
},
"prototype_quest_operator_chain": {
"itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}],
"skillXpGrants": [{"skillId": "salvage", "amount": 50}],
"reputationGrants": [
{"factionId": "prototype_faction_grid_operators", "amount": 15}
],
},
"prototype_quest_grid_contract": {
"itemGrants": [],
"skillXpGrants": [],
"reputationGrants": [
{"factionId": "prototype_faction_rust_collective", "amount": 10}
],
},
}
def _normalize_completion_bundle(bundle: dict) -> dict: def _normalize_completion_bundle(bundle: dict) -> dict:
"""Normalize grant rows for stable freeze-table comparison.""" """Normalize grant rows for stable freeze-table comparison."""
@ -224,6 +299,23 @@ def _normalize_completion_bundle(bundle: dict) -> dict:
} }
def _normalize_e7m3_completion_bundle(bundle: dict) -> dict:
"""Normalize item, skill XP, and reputation grant rows for E7M3 freeze-table comparison."""
normalized = _normalize_completion_bundle(bundle)
reputation_grants = bundle.get("reputationGrants")
normalized["reputationGrants"] = sorted(
[
{"factionId": grant["factionId"], "amount": grant["amount"]}
for grant in (reputation_grants if isinstance(reputation_grants, list) else [])
if isinstance(grant, dict)
and isinstance(grant.get("factionId"), str)
and isinstance(grant.get("amount"), int)
],
key=lambda grant: (grant["factionId"], grant["amount"]),
)
return normalized
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None: def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
"""Return a human-readable error if Slice 1 contract fails, else None.""" """Return a human-readable error if Slice 1 contract fails, else None."""
ids = frozenset(seen_ids.keys()) ids = frozenset(seen_ids.keys())
@ -895,13 +987,15 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str
def _quest_def_validator() -> Draft202012Validator: def _quest_def_validator() -> Draft202012Validator:
"""Build a validator that resolves quest-def $ref to step, objective, and bundle schemas.""" """Build a validator that resolves quest-def $ref to step, objective, bundle, and gate schemas."""
objective_schema = json.loads(QUEST_OBJECTIVE_DEF_SCHEMA.read_text(encoding="utf-8")) objective_schema = json.loads(QUEST_OBJECTIVE_DEF_SCHEMA.read_text(encoding="utf-8"))
step_schema = json.loads(QUEST_STEP_DEF_SCHEMA.read_text(encoding="utf-8")) step_schema = json.loads(QUEST_STEP_DEF_SCHEMA.read_text(encoding="utf-8"))
def_schema = json.loads(QUEST_DEF_SCHEMA.read_text(encoding="utf-8")) def_schema = json.loads(QUEST_DEF_SCHEMA.read_text(encoding="utf-8"))
bundle_schema = json.loads(QUEST_REWARD_BUNDLE_SCHEMA.read_text(encoding="utf-8")) bundle_schema = json.loads(QUEST_REWARD_BUNDLE_SCHEMA.read_text(encoding="utf-8"))
skill_xp_grant_schema = json.loads(QUEST_SKILL_XP_GRANT_SCHEMA.read_text(encoding="utf-8")) skill_xp_grant_schema = json.loads(QUEST_SKILL_XP_GRANT_SCHEMA.read_text(encoding="utf-8"))
grant_row_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8")) grant_row_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
faction_gate_rule_schema = json.loads(FACTION_GATE_RULE_SCHEMA.read_text(encoding="utf-8"))
reputation_grant_row_schema = json.loads(REPUTATION_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
registry = Registry().with_resources( registry = Registry().with_resources(
[ [
(def_schema["$id"], Resource.from_contents(def_schema)), (def_schema["$id"], Resource.from_contents(def_schema)),
@ -910,11 +1004,62 @@ def _quest_def_validator() -> Draft202012Validator:
(bundle_schema["$id"], Resource.from_contents(bundle_schema)), (bundle_schema["$id"], Resource.from_contents(bundle_schema)),
(skill_xp_grant_schema["$id"], Resource.from_contents(skill_xp_grant_schema)), (skill_xp_grant_schema["$id"], Resource.from_contents(skill_xp_grant_schema)),
(grant_row_schema["$id"], Resource.from_contents(grant_row_schema)), (grant_row_schema["$id"], Resource.from_contents(grant_row_schema)),
(faction_gate_rule_schema["$id"], Resource.from_contents(faction_gate_rule_schema)),
(reputation_grant_row_schema["$id"], Resource.from_contents(reputation_grant_row_schema)),
] ]
) )
return Draft202012Validator(def_schema, registry=registry) return Draft202012Validator(def_schema, registry=registry)
def _validate_faction_catalogs(
*,
faction_files: list[Path],
faction_validator: Draft202012Validator,
) -> tuple[int, dict[str, str], dict[str, dict]]:
"""Validate faction JSON files. Returns (error_count, seen_ids, id_to_row)."""
errors = 0
seen_ids: dict[str, str] = {}
id_to_row: dict[str, dict] = {}
for path in faction_files:
rel = str(path.relative_to(REPO_ROOT))
data = json.loads(path.read_text(encoding="utf-8"))
schema_version = data.get("schemaVersion")
if schema_version != 1:
print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
errors += 1
continue
factions = data.get("factions")
if not isinstance(factions, list):
print(f"error: {rel}: expected top-level 'factions' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(factions):
if not isinstance(row, dict):
print(f"error: {rel}: factions[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(faction_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} factions[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
fid = row.get("id")
if isinstance(fid, str) and row_schema_errors == 0:
prev = seen_ids.get(fid)
if prev:
print(f"error: duplicate faction id {fid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
seen_ids[fid] = rel
id_to_row[fid] = row
return errors, seen_ids, id_to_row
def _validate_quest_catalogs( def _validate_quest_catalogs(
*, *,
quest_files: list[Path], quest_files: list[Path],
@ -998,13 +1143,13 @@ def _validate_quest_catalogs(
return errors, seen_ids, id_to_row return errors, seen_ids, id_to_row
def _prototype_e7m1_quest_gate(seen_ids: dict[str, str]) -> str | None: def _prototype_e7m3_quest_roster_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E7M1 quest contract fails, else None.""" """Return a human-readable error if E7M3 five-quest roster fails, else None."""
ids = frozenset(seen_ids.keys()) ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E7M1_QUEST_IDS: if ids != PROTOTYPE_E7M3_QUEST_IDS:
return ( return (
"error: prototype E7M1 expects exactly quest ids " "error: prototype E7M3 expects exactly quest ids "
f"{sorted(PROTOTYPE_E7M1_QUEST_IDS)!r}, got {sorted(ids)!r}" f"{sorted(PROTOTYPE_E7M3_QUEST_IDS)!r}, got {sorted(ids)!r}"
) )
return None return None
@ -1132,7 +1277,7 @@ def _prototype_e7m1_chain_terminal_gate(id_to_row: dict[str, dict]) -> str | Non
def _prototype_e7m2_completion_bundle_presence_gate(id_to_row: dict[str, dict]) -> str | None: def _prototype_e7m2_completion_bundle_presence_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if a frozen quest lacks completionRewardBundle, else None.""" """Return a human-readable error if a frozen quest lacks completionRewardBundle, else None."""
for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS): for qid in sorted(PROTOTYPE_E7M3_QUEST_IDS):
row = id_to_row.get(qid) row = id_to_row.get(qid)
if not isinstance(row, dict): if not isinstance(row, dict):
return f"error: missing quest {qid!r}" return f"error: missing quest {qid!r}"
@ -1166,7 +1311,7 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate(
skill_id_to_allowed_kinds: dict[str, list[str]], skill_id_to_allowed_kinds: dict[str, list[str]],
) -> str | None: ) -> str | None:
"""Return a human-readable error if bundle refs fail cross-checks, else None.""" """Return a human-readable error if bundle refs fail cross-checks, else None."""
for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS): for qid in sorted(PROTOTYPE_E7M3_QUEST_IDS):
row = id_to_row.get(qid) row = id_to_row.get(qid)
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
@ -1206,6 +1351,165 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate(
return None return None
def _prototype_e7m3_faction_roster_gate(faction_seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E7M3 faction roster fails, else None."""
ids = frozenset(faction_seen_ids.keys())
if ids != PROTOTYPE_E7M3_FACTION_IDS:
return (
"error: prototype E7M3 expects exactly faction ids "
f"{sorted(PROTOTYPE_E7M3_FACTION_IDS)!r}, got {sorted(ids)!r}"
)
return None
def _prototype_e7m3_faction_freeze_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if faction rows diverge from E7M3 freeze table, else None."""
for fid, expected in PROTOTYPE_E7M3_FACTION_FREEZE.items():
row = id_to_row.get(fid)
if not isinstance(row, dict):
return f"error: missing faction {fid!r}"
for key, expected_value in expected.items():
if row.get(key) != expected_value:
return (
f"error: faction {fid!r} must match E7M3 freeze table "
f"(expected {key}={expected_value!r}, got {row.get(key)!r})"
)
return None
def _prototype_e7m3_faction_standing_band_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if faction standing band ordering fails, else None."""
for fid, row in id_to_row.items():
if not isinstance(row, dict):
continue
min_standing = row.get("minStanding")
max_standing = row.get("maxStanding")
neutral_standing = row.get("neutralStanding")
if not all(isinstance(value, int) for value in (min_standing, max_standing, neutral_standing)):
continue
if min_standing > neutral_standing or neutral_standing > max_standing:
return (
f"error: faction {fid!r} requires minStanding <= neutralStanding <= maxStanding "
f"(got min={min_standing!r}, neutral={neutral_standing!r}, max={max_standing!r})"
)
return None
def _prototype_e7m3_faction_cross_ref_gate(
id_to_row: dict[str, dict],
faction_seen_ids: dict[str, str],
) -> str | None:
"""Return a human-readable error if quest faction refs fail, else None."""
known_factions = frozenset(faction_seen_ids.keys())
for qid, row in id_to_row.items():
gate_rules = row.get("factionGateRules")
if isinstance(gate_rules, list):
for gi, rule in enumerate(gate_rules):
if not isinstance(rule, dict):
continue
faction_id = rule.get("factionId")
if isinstance(faction_id, str) and faction_id not in known_factions:
return (
f"error: quest {qid!r} factionGateRules[{gi}]: factionId {faction_id!r} "
"is not in the frozen prototype faction catalog"
)
bundle = row.get("completionRewardBundle")
if isinstance(bundle, dict):
reputation_grants = bundle.get("reputationGrants")
if isinstance(reputation_grants, list):
for gi, grant in enumerate(reputation_grants):
if not isinstance(grant, dict):
continue
faction_id = grant.get("factionId")
if isinstance(faction_id, str) and faction_id not in known_factions:
return (
f"error: quest {qid!r} completionRewardBundle.reputationGrants[{gi}]: "
f"factionId {faction_id!r} is not in the frozen prototype faction catalog"
)
return None
def _prototype_e7m3_completion_bundle_freeze_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if bundle contents diverge from E7M3 freeze table, else None."""
for qid, expected in PROTOTYPE_E7M3_COMPLETION_BUNDLES.items():
row = id_to_row.get(qid)
if not isinstance(row, dict):
return f"error: missing quest {qid!r}"
bundle = row.get("completionRewardBundle")
if not isinstance(bundle, dict):
return f"error: quest {qid!r} must include completionRewardBundle object"
actual = _normalize_e7m3_completion_bundle(bundle)
expected_normalized = _normalize_e7m3_completion_bundle(expected)
if actual != expected_normalized:
return (
f"error: quest {qid!r} completionRewardBundle must match E7M3 freeze table "
f"(expected {expected_normalized!r}, got {actual!r})"
)
return None
def _prototype_e7m3_grid_contract_shape_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if grid contract quest shape fails, else None."""
row = id_to_row.get(PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID)
if not isinstance(row, dict):
return f"error: missing quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r}"
prereqs = row.get("prerequisiteQuestIds")
if prereqs != [PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID]:
return (
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} prerequisiteQuestIds must be "
f"{[PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID]!r}, got {prereqs!r}"
)
gate_rules = row.get("factionGateRules")
expected_gate = [
{
"factionId": PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID,
"minStanding": PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING,
}
]
if gate_rules != expected_gate:
return (
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} factionGateRules must be "
f"{expected_gate!r}, got {gate_rules!r}"
)
steps = row.get("steps")
if not isinstance(steps, list) or len(steps) != 1:
return (
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} must have exactly one step"
)
step = steps[0]
if not isinstance(step, dict):
return f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} step must be an object"
objectives = step.get("objectives")
if not isinstance(objectives, list) or len(objectives) != 1:
return (
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} must have exactly one objective"
)
obj = objectives[0]
if not isinstance(obj, dict):
return (
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective must be an object"
)
if obj.get("kind") != "inventory_has_item":
return (
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective kind must be "
"'inventory_has_item'"
)
if obj.get("itemId") != PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID:
return (
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective itemId must be "
f"{PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID!r}, got {obj.get('itemId')!r}"
)
if obj.get("quantity") != 1:
return (
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective quantity must be 1, "
f"got {obj.get('quantity')!r}"
)
return None
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None: def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
"""Return a human-readable error if Slice 4 contract fails, else None.""" """Return a human-readable error if Slice 4 contract fails, else None."""
if len(track_skill_ids) != 1: if len(track_skill_ids) != 1:
@ -1556,6 +1860,15 @@ def main() -> int:
if not QUEST_SKILL_XP_GRANT_SCHEMA.is_file(): if not QUEST_SKILL_XP_GRANT_SCHEMA.is_file():
print(f"error: missing schema {QUEST_SKILL_XP_GRANT_SCHEMA}", file=sys.stderr) print(f"error: missing schema {QUEST_SKILL_XP_GRANT_SCHEMA}", file=sys.stderr)
return 1 return 1
if not FACTION_DEF_SCHEMA.is_file():
print(f"error: missing schema {FACTION_DEF_SCHEMA}", file=sys.stderr)
return 1
if not FACTION_GATE_RULE_SCHEMA.is_file():
print(f"error: missing schema {FACTION_GATE_RULE_SCHEMA}", file=sys.stderr)
return 1
if not REPUTATION_GRANT_ROW_SCHEMA.is_file():
print(f"error: missing schema {REPUTATION_GRANT_ROW_SCHEMA}", file=sys.stderr)
return 1
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema) skill_validator = Draft202012Validator(skill_schema)
@ -1578,6 +1891,8 @@ def main() -> int:
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8")) encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
encounter_validator = Draft202012Validator(encounter_schema) encounter_validator = Draft202012Validator(encounter_schema)
quest_validator = _quest_def_validator() quest_validator = _quest_def_validator()
faction_schema = json.loads(FACTION_DEF_SCHEMA.read_text(encoding="utf-8"))
faction_validator = Draft202012Validator(faction_schema)
if not SKILLS_DIR.is_dir(): if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
@ -1679,6 +1994,15 @@ def main() -> int:
print(f"error: no *_quests.json files under {QUESTS_DIR}", file=sys.stderr) print(f"error: no *_quests.json files under {QUESTS_DIR}", file=sys.stderr)
return 1 return 1
if not FACTIONS_DIR.is_dir():
print(f"error: missing directory {FACTIONS_DIR}", file=sys.stderr)
return 1
faction_files = sorted(FACTIONS_DIR.glob("*_factions.json"))
if not faction_files:
print(f"error: no *_factions.json files under {FACTIONS_DIR}", file=sys.stderr)
return 1
seen_ids: dict[str, str] = {} seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {} id_to_category: dict[str, str] = {}
skill_id_to_allowed_kinds: dict[str, list[str]] = {} skill_id_to_allowed_kinds: dict[str, list[str]] = {}
@ -1950,6 +2274,29 @@ def main() -> int:
print(e5m3_encounter_cross_ref_err, file=sys.stderr) print(e5m3_encounter_cross_ref_err, file=sys.stderr)
return 1 return 1
faction_errors, faction_seen_ids, faction_id_to_row = _validate_faction_catalogs(
faction_files=faction_files,
faction_validator=faction_validator,
)
if faction_errors:
print(f"content validation failed with {faction_errors} error(s)", file=sys.stderr)
return 1
e7m3_faction_roster_err = _prototype_e7m3_faction_roster_gate(faction_seen_ids)
if e7m3_faction_roster_err:
print(e7m3_faction_roster_err, file=sys.stderr)
return 1
e7m3_faction_freeze_err = _prototype_e7m3_faction_freeze_gate(faction_id_to_row)
if e7m3_faction_freeze_err:
print(e7m3_faction_freeze_err, file=sys.stderr)
return 1
e7m3_faction_band_err = _prototype_e7m3_faction_standing_band_gate(faction_id_to_row)
if e7m3_faction_band_err:
print(e7m3_faction_band_err, file=sys.stderr)
return 1
quest_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs( quest_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs(
quest_files=quest_files, quest_files=quest_files,
quest_validator=quest_validator, quest_validator=quest_validator,
@ -1958,9 +2305,9 @@ def main() -> int:
print(f"content validation failed with {quest_errors} error(s)", file=sys.stderr) print(f"content validation failed with {quest_errors} error(s)", file=sys.stderr)
return 1 return 1
e7m1_quest_err = _prototype_e7m1_quest_gate(quest_seen_ids) e7m3_quest_roster_err = _prototype_e7m3_quest_roster_gate(quest_seen_ids)
if e7m1_quest_err: if e7m3_quest_roster_err:
print(e7m1_quest_err, file=sys.stderr) print(e7m3_quest_roster_err, file=sys.stderr)
return 1 return 1
e7m1_prereq_err = _prototype_e7m1_quest_prerequisite_gate(quest_id_to_row) e7m1_prereq_err = _prototype_e7m1_quest_prerequisite_gate(quest_id_to_row)
@ -1996,6 +2343,24 @@ def main() -> int:
print(e7m2_bundle_cross_ref_err, file=sys.stderr) print(e7m2_bundle_cross_ref_err, file=sys.stderr)
return 1 return 1
e7m3_faction_cross_ref_err = _prototype_e7m3_faction_cross_ref_gate(
quest_id_to_row,
faction_seen_ids,
)
if e7m3_faction_cross_ref_err:
print(e7m3_faction_cross_ref_err, file=sys.stderr)
return 1
e7m3_bundle_freeze_err = _prototype_e7m3_completion_bundle_freeze_gate(quest_id_to_row)
if e7m3_bundle_freeze_err:
print(e7m3_bundle_freeze_err, file=sys.stderr)
return 1
e7m3_grid_contract_err = _prototype_e7m3_grid_contract_shape_gate(quest_id_to_row)
if e7m3_grid_contract_err:
print(e7m3_grid_contract_err, file=sys.stderr)
return 1
print( print(
"content OK: " "content OK: "
f"{len(skill_files)} skill catalog file(s), " f"{len(skill_files)} skill catalog file(s), "
@ -2009,6 +2374,7 @@ def main() -> int:
f"{len(npc_behavior_files)} npc behavior catalog file(s), " f"{len(npc_behavior_files)} npc behavior catalog file(s), "
f"{len(reward_table_files)} reward table catalog file(s), " f"{len(reward_table_files)} reward table catalog file(s), "
f"{len(encounter_files)} encounter catalog file(s), " f"{len(encounter_files)} encounter catalog file(s), "
f"{len(faction_files)} faction catalog file(s), "
f"{len(quest_files)} quest catalog file(s), " f"{len(quest_files)} quest catalog file(s), "
f"{len(seen_ids)} unique skill id(s), " f"{len(seen_ids)} unique skill id(s), "
f"{len(item_seen_ids)} unique item id(s), " f"{len(item_seen_ids)} unique item id(s), "
@ -2018,6 +2384,7 @@ def main() -> int:
f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), " f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), "
f"{len(reward_table_seen_ids)} unique reward table id(s), " f"{len(reward_table_seen_ids)} unique reward table id(s), "
f"{len(encounter_seen_ids)} unique encounter id(s), " f"{len(encounter_seen_ids)} unique encounter id(s), "
f"{len(faction_seen_ids)} unique faction id(s), "
f"{len(quest_seen_ids)} unique quest id(s), " f"{len(quest_seen_ids)} unique quest id(s), "
f"{len(track_skill_ids)} mastery track(s)" f"{len(track_skill_ids)} mastery track(s)"
) )

View File

@ -0,0 +1,17 @@
using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Tests.Game.Factions;
internal static class FactionCatalogTestPaths
{
internal static string DiscoverRepoFactionsDirectory() =>
FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoFactionDefSchemaPath() =>
FactionCatalogPathResolution.ResolveFactionDefSchemaPath(
DiscoverRepoFactionsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
}

View File

@ -0,0 +1,217 @@
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Factions;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public class FactionDefinitionCatalogLoaderTests
{
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"factions": [
{
"id": "prototype_faction_grid_operators",
"displayName": "Grid Operators",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
},
{
"id": "prototype_faction_rust_collective",
"displayName": "Rust Collective",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
}
]
}
""";
private static (string Root, string FactionsDir, string SchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-factioncat-");
var factionsDir = Path.Combine(root.FullName, "content", "factions");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(factionsDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "faction-def.schema.json");
File.Copy(FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath(), schemaPath, overwrite: true);
return (root.FullName, factionsDir, schemaPath);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile()
{
// Arrange
var factionsDir = FactionCatalogTestPaths.DiscoverRepoFactionsDirectory();
var schemaPath = FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath();
// Act
var catalog = FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance);
// Assert
Assert.Equal(PrototypeE7M3FactionCatalogRules.ExpectedFactionIds.Count, catalog.DistinctFactionCount);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
// Act
var catalog = FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance);
// Assert
Assert.Equal(2, catalog.DistinctFactionCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.ById.TryGetValue("prototype_faction_grid_operators", out var grid));
Assert.NotNull(grid);
Assert.Equal("Grid Operators", grid!.DisplayName);
Assert.Equal(0, grid.NeutralStanding);
}
[Fact]
public void Load_ShouldThrow_WhenFactionsIsNotArray()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(factionsDir, "bad_factions.json"), """{"schemaVersion": 1, "factions": "nope"}""", Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_factions.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'factions' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var singleFaction = """
{
"schemaVersion": 1,
"factions": [
{
"id": "prototype_faction_grid_operators",
"displayName": "Grid Operators",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
}
]
}
""";
File.WriteAllText(Path.Combine(factionsDir, "a_factions.json"), singleFaction, Encoding.UTF8);
File.WriteAllText(Path.Combine(factionsDir, "b_factions.json"), singleFaction, Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate faction id", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRosterGateFails()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var oneFaction = """
{
"schemaVersion": 1,
"factions": [
{
"id": "prototype_faction_grid_operators",
"displayName": "Grid Operators",
"minStanding": -100,
"maxStanding": 100,
"neutralStanding": 0
}
]
}
""";
File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), oneFaction, Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E7M3 expects exactly faction ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenFreezeTableDiverges()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var badDisplay = ValidPrototypeCatalogJson.Replace("Grid Operators", "Wrong Name", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), badDisplay, Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("must match E7M3 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void TryGetStandingBandGateError_ShouldReturnError_WhenOrderingFails()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
10,
100,
0),
};
// Act
var error = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows);
// Assert
Assert.NotNull(error);
Assert.Contains("requires minStanding <= neutralStanding <= maxStanding", error, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenStandingBandOrderingFails()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
var badBand = ValidPrototypeCatalogJson.Replace("\"minStanding\": -100", "\"minStanding\": 10", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(factionsDir, "prototype_factions.json"), badBand, Encoding.UTF8);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("requires minStanding <= neutralStanding <= maxStanding", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenFactionsDirectoryMissing()
{
// Arrange
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-factions-" + Guid.NewGuid().ToString("n"));
var schemaPath = FactionCatalogTestPaths.DiscoverRepoFactionDefSchemaPath();
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaFileMissing()
{
// Arrange
var (_, factionsDir, schemaPath) = CreateTempContentLayout();
File.Delete(schemaPath);
// Act
var ex = Record.Exception(() => FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
}
}

View File

@ -0,0 +1,126 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public class FactionDefinitionRegistryTests
{
private static FactionDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, FactionDefRow> byId)
{
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new FactionDefinitionRegistry(catalog);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrue_WhenIdExists()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal("Grid Operators", def!.DisplayName);
Assert.Equal(-100, def.MinStanding);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenFactionIdIsNull()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(null, out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_faction", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleFactions()
{
// Arrange
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_rust_collective"] = new(
"prototype_faction_rust_collective",
"Rust Collective",
-100,
100,
0),
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(2, list.Count);
Assert.Equal("prototype_faction_grid_operators", list[0].Id);
Assert.Equal("prototype_faction_rust_collective", list[1].Id);
}
[Fact]
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var registry = factory.Services.GetRequiredService<IFactionDefinitionRegistry>();
var found = registry.TryGetDefinition("prototype_faction_grid_operators", out var grid);
// Assert
Assert.True(found);
Assert.NotNull(grid);
Assert.Equal("Grid Operators", grid!.DisplayName);
}
}

View File

@ -0,0 +1,133 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public sealed class FactionGateOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
private const int GridMinStanding = PrototypeE7M3QuestFactionRules.GridContractMinStanding;
[Fact]
public void TryEvaluate_ShouldPass_WhenRulesEmpty()
{
// Arrange
var standingStore = CreateStandingStore();
// Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, [], standingStore);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
Assert.Null(outcome.FailingFactionId);
}
[Fact]
public void TryEvaluate_ShouldPass_WhenStandingAtMin()
{
// Arrange
var standingStore = CreateStandingStore();
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success);
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
// Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
}
[Fact]
public void TryEvaluate_ShouldDeny_WhenStandingBelowMin()
{
// Arrange
var standingStore = CreateStandingStore();
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding - 1).Success);
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
// Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
Assert.Equal(GridFactionId, outcome.FailingFactionId);
Assert.Equal(GridMinStanding, outcome.RequiredMinStanding);
Assert.Equal(GridMinStanding - 1, outcome.CurrentStanding);
}
[Fact]
public void TryEvaluate_ShouldDeny_WhenSecondRuleFailsInAndList()
{
// Arrange
var standingStore = CreateStandingStore();
const string rustFactionId = "prototype_faction_rust_collective";
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success);
var rules = new[]
{
new FactionGateRuleRow(GridFactionId, GridMinStanding),
new FactionGateRuleRow(rustFactionId, 1),
};
// Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
Assert.Equal(rustFactionId, outcome.FailingFactionId);
Assert.Equal(1, outcome.RequiredMinStanding);
Assert.Equal(0, outcome.CurrentStanding);
}
[Fact]
public void TryEvaluate_ShouldDenyUnknownFaction_WhenStoreDenies()
{
// Arrange
var standingStore = CreateStandingStore();
const string unknownFactionId = "prototype_faction_missing";
var rules = new[] { new FactionGateRuleRow(unknownFactionId, 0) };
// Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, outcome.ReasonCode);
Assert.Equal(unknownFactionId, outcome.FailingFactionId);
}
[Fact]
public async Task Host_ShouldResolveStandingStoreForGateEval_WhenStartupSucceeds()
{
// Arrange — missing standing row reads as 0 (neutral), below grid contract minStanding 15.
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding) };
// Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
}
private static InMemoryFactionStandingStore CreateStandingStore()
{
var registry = CreatePrototypeRegistry();
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryFactionStandingStore(options, registry);
}
private static FactionDefinitionRegistry CreatePrototypeRegistry()
{
var gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
var rustFactionId = "prototype_faction_rust_collective";
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId],
[rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId],
};
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new FactionDefinitionRegistry(catalog);
}
}

View File

@ -0,0 +1,122 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
[Collection("Postgres integration")]
public sealed class FactionStandingPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string GridFactionId = "prototype_faction_grid_operators";
[RequirePostgresFact]
public async Task ApplyDeltaThenGetAcrossNewFactory_ShouldPersistStanding()
{
// Arrange
await ResetFactionStandingTableAsync();
// Act — write through first host
FactionStandingMutationOutcome writeOutcome;
using (var firstScope = Factory.Services.CreateScope())
{
var store = firstScope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
writeOutcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15);
}
FactionStandingReadOutcome readBack;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var secondScope = secondFactory.Services.CreateScope();
var store = secondScope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
readBack = store.TryGetStanding(PlayerId, GridFactionId);
}
// Assert
Assert.True(writeOutcome.Success);
Assert.Equal(15, writeOutcome.NewStanding);
Assert.True(readBack.Success);
Assert.Equal(15, readBack.Standing);
}
[RequirePostgresFact]
public async Task TryGetStanding_ShouldClampRead_WhenStoredValueExceedsMax()
{
// Arrange
await ResetFactionStandingTableAsync();
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl")
?? throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
await using (var conn = new NpgsqlConnection(cs))
{
await conn.OpenAsync();
await using var insert = new NpgsqlCommand(
"""
INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at)
VALUES (@pid, @fid, @standing, now());
""",
conn);
insert.Parameters.AddWithValue("pid", PlayerId);
insert.Parameters.AddWithValue("fid", GridFactionId);
insert.Parameters.AddWithValue("standing", 999);
await insert.ExecuteNonQueryAsync();
}
// Act
FactionStandingReadOutcome readBack;
using (var scope = Factory.Services.CreateScope())
{
var store = scope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
readBack = store.TryGetStanding(PlayerId, GridFactionId);
}
// Assert
Assert.True(readBack.Success);
Assert.Equal(100, readBack.Standing);
}
private async Task ResetFactionStandingTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var standingDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V009__player_faction_standing.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(standingDdlPath))
{
throw new FileNotFoundException("Test DDL for faction standing persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var standingDdl = await File.ReadAllTextAsync(standingDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyStanding = new NpgsqlCommand(standingDdl, conn))
{
await applyStanding.ExecuteNonQueryAsync();
}
}
}

View File

@ -0,0 +1,223 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public sealed class InMemoryFactionStandingStoreTests
{
private const string PlayerId = "dev-local-1";
private const string UnknownPlayerId = "unknown-player-xyz";
private const string GridFactionId = "prototype_faction_grid_operators";
private const string RustFactionId = "prototype_faction_rust_collective";
[Fact]
public void TryGetStanding_ShouldReturnZero_WhenRowMissing()
{
// Arrange
var store = CreateStore();
// Act
var outcome = store.TryGetStanding(PlayerId, GridFactionId);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
Assert.Equal(0, outcome.Standing);
}
[Fact]
public void TryApplyStandingDelta_ShouldReturnFifteen_WhenFirstApplyFromZero()
{
// Arrange
var store = CreateStore();
// Act
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 15);
// Assert
Assert.True(outcome.Success);
Assert.Equal(0, outcome.PreviousStanding);
Assert.Equal(15, outcome.NewStanding);
var readBack = store.TryGetStanding(PlayerId, GridFactionId);
Assert.True(readBack.Success);
Assert.Equal(15, readBack.Standing);
}
[Fact]
public void TryApplyStandingDelta_ShouldClampToMax_WhenDeltaExceedsBand()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
// Act
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, 50);
// Assert
Assert.True(outcome.Success);
Assert.Equal(95, outcome.PreviousStanding);
Assert.Equal(100, outcome.NewStanding);
Assert.Equal(100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryApplyStandingDelta_ShouldClampToMin_WhenDeltaBelowBand()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, -95).Success);
// Act
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, -50);
// Assert
Assert.True(outcome.Success);
Assert.Equal(-95, outcome.PreviousStanding);
Assert.Equal(-100, outcome.NewStanding);
Assert.Equal(-100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryGetStanding_ShouldClampRead_WhenRawStoredValueExceedsMax()
{
// Arrange
var store = CreateStore();
store.SeedRawStandingForTests(PlayerId, GridFactionId, 999);
// Act
var outcome = store.TryGetStanding(PlayerId, GridFactionId);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
Assert.Equal(100, outcome.Standing);
}
[Fact]
public void TryGetStanding_ShouldReturnUnknownFaction_WhenFactionIdEmpty()
{
// Arrange
var store = CreateStore();
// Act
var read = store.TryGetStanding(PlayerId, " ");
var write = store.TryApplyStandingDelta(PlayerId, "", 5);
// Assert
Assert.False(read.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
Assert.False(write.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
}
[Fact]
public void TryGetStanding_ShouldDenyUnknownFaction_WithStructuredReason()
{
// Arrange
var store = CreateStore();
// Act
var read = store.TryGetStanding(PlayerId, "not_a_real_faction");
var write = store.TryApplyStandingDelta(PlayerId, "not_a_real_faction", 5);
// Assert
Assert.False(read.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
Assert.Equal(0, read.Standing);
Assert.False(write.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
Assert.Equal(0, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryApplyStandingDelta_ShouldApplyToClampedEffectiveStanding_WhenRawStoredValueExceedsMax()
{
// Arrange
var store = CreateStore();
store.SeedRawStandingForTests(PlayerId, GridFactionId, 999);
Assert.Equal(100, store.TryGetStanding(PlayerId, GridFactionId).Standing);
// Act
var outcome = store.TryApplyStandingDelta(PlayerId, GridFactionId, -15);
// Assert
Assert.True(outcome.Success);
Assert.Equal(100, outcome.PreviousStanding);
Assert.Equal(85, outcome.NewStanding);
Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown()
{
// Arrange
var store = CreateStore();
// Act
var read = store.TryGetStanding(UnknownPlayerId, "not_a_real_faction");
var write = store.TryApplyStandingDelta(UnknownPlayerId, "not_a_real_faction", 5);
// Assert
Assert.False(read.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, read.ReasonCode);
Assert.False(write.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, write.ReasonCode);
}
[Fact]
public void TryApplyStandingDelta_ShouldDenyNonWritablePlayer()
{
// Arrange
var store = CreateStore();
// Act
var outcome = store.TryApplyStandingDelta(UnknownPlayerId, GridFactionId, 10);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
}
[Fact]
public void TryGetStanding_ShouldDenyNonWritablePlayer()
{
// Arrange
var store = CreateStore();
// Act
var outcome = store.TryGetStanding(UnknownPlayerId, GridFactionId);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
}
[Fact]
public void TryApplyStandingDelta_ShouldTrackSeparateFactionsPerPlayer()
{
// Arrange
var store = CreateStore();
// Act
Assert.True(store.TryApplyStandingDelta(PlayerId, GridFactionId, 15).Success);
Assert.True(store.TryApplyStandingDelta(PlayerId, RustFactionId, 10).Success);
// Assert
Assert.Equal(15, store.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Equal(10, store.TryGetStanding(PlayerId, RustFactionId).Standing);
}
[Fact]
public async Task Host_ShouldResolveFactionStandingStoresFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
var read = standingStore.TryGetStanding(PlayerId, GridFactionId);
// Assert
Assert.NotNull(auditStore);
Assert.True(read.Success);
Assert.Equal(0, read.Standing);
}
private static InMemoryFactionStandingStore CreateStore()
{
var registry = CreatePrototypeRegistry();
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryFactionStandingStore(options, registry);
}
private static FactionDefinitionRegistry CreatePrototypeRegistry()
{
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId],
[RustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustFactionId],
};
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new FactionDefinitionRegistry(catalog);
}
}

View File

@ -0,0 +1,117 @@
using NeonSprawl.Server.Game.Factions;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public sealed class InMemoryReputationDeltaStoreTests
{
private const string PlayerId = "dev-local-1";
private const string GridFactionId = "prototype_faction_grid_operators";
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void TryAppend_ShouldReturnTrueAndQueryRow_WhenFirstAppend()
{
// Arrange
var store = CreateStore();
var row = CreateRow("delta-001", 15, 15);
// Act
var appended = store.TryAppend(row);
var rows = store.GetDeltasForPlayer(PlayerId);
// Assert
Assert.True(appended);
var read = Assert.Single(rows);
Assert.Equal(row.Id, read.Id);
Assert.Equal(15, read.DeltaAmount);
Assert.Equal(15, read.ResultingStanding);
}
[Fact]
public void TryAppend_ShouldReturnFalse_WhenDuplicateId()
{
// Arrange
var store = CreateStore();
var row = CreateRow("delta-dup", 10, 10);
Assert.True(store.TryAppend(row));
// Act
var duplicate = store.TryAppend(row with { DeltaAmount = 99, ResultingStanding = 99 });
// Assert
Assert.False(duplicate);
var read = Assert.Single(store.GetDeltasForPlayer(PlayerId));
Assert.Equal(10, read.DeltaAmount);
}
[Fact]
public void TryAppend_ShouldReturnTrueTwice_WhenDistinctIds()
{
// Arrange
var store = CreateStore();
// Act
var first = store.TryAppend(CreateRow("delta-a", 15, 15));
var second = store.TryAppend(CreateRow("delta-b", 10, 25));
// Assert
Assert.True(first);
Assert.True(second);
Assert.Equal(2, store.GetDeltasForPlayer(PlayerId).Count);
}
[Fact]
public void TryAppend_ShouldReturnFalse_WhenRowInvalid()
{
// Arrange
var store = CreateStore();
var emptyId = CreateRow("", 5, 5);
var emptyPlayer = CreateRow("delta-x", 5, 5) with { PlayerId = " " };
var emptyFaction = CreateRow("delta-y", 5, 5) with { FactionId = "" };
// Act
var noId = store.TryAppend(emptyId);
var noPlayer = store.TryAppend(emptyPlayer);
var noFaction = store.TryAppend(emptyFaction);
// Assert
Assert.False(noId);
Assert.False(noPlayer);
Assert.False(noFaction);
Assert.Empty(store.GetDeltasForPlayer(PlayerId));
}
[Fact]
public void GetDeltasForPlayer_ShouldOrderByRecordedAtThenId()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryAppend(CreateRow("delta-z", 1, 1) with { RecordedAt = RecordedAt.AddMinutes(2) }));
Assert.True(store.TryAppend(CreateRow("delta-a", 2, 3) with { RecordedAt = RecordedAt }));
Assert.True(store.TryAppend(CreateRow("delta-m", 3, 6) with { RecordedAt = RecordedAt }));
// Act
var rows = store.GetDeltasForPlayer(PlayerId);
// Assert
Assert.Equal(["delta-a", "delta-m", "delta-z"], rows.Select(static r => r.Id).ToArray());
}
[Fact]
public void GetDeltasForPlayer_ShouldRespectLimit_WhenProvided()
{
// Arrange
var store = CreateStore();
Assert.True(store.TryAppend(CreateRow("delta-1", 1, 1)));
Assert.True(store.TryAppend(CreateRow("delta-2", 1, 2)));
// Act
var rows = store.GetDeltasForPlayer(PlayerId, limit: 1);
// Assert
Assert.Single(rows);
Assert.Equal("delta-1", rows[0].Id);
}
private static InMemoryReputationDeltaStore CreateStore() => new();
private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) =>
new(
id,
PlayerId,
GridFactionId,
delta,
resulting,
ReputationDeltaSourceKinds.QuestCompletion,
"prototype_quest_operator_chain",
RecordedAt);
}

View File

@ -0,0 +1,97 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
[Collection("Postgres integration")]
public sealed class ReputationDeltaPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string GridFactionId = "prototype_faction_grid_operators";
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero);
[RequirePostgresFact]
public async Task AppendThenGetAcrossNewFactory_ShouldPersistAuditRow()
{
// Arrange
await ResetReputationDeltaTableAsync();
var row = new ReputationDeltaRow(
"delta-persist-001",
PlayerId,
GridFactionId,
15,
15,
ReputationDeltaSourceKinds.QuestCompletion,
"prototype_quest_operator_chain",
RecordedAt);
// Act — write through first host
using (var firstScope = Factory.Services.CreateScope())
{
var store = firstScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
Assert.True(store.TryAppend(row));
}
IReadOnlyList<ReputationDeltaRow> readBack;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var secondScope = secondFactory.Services.CreateScope();
var store = secondScope.ServiceProvider.GetRequiredService<IReputationDeltaStore>();
readBack = store.GetDeltasForPlayer(PlayerId);
}
// Assert
var persisted = Assert.Single(readBack);
Assert.Equal(row.Id, persisted.Id);
Assert.Equal(15, persisted.DeltaAmount);
Assert.Equal(15, persisted.ResultingStanding);
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, persisted.SourceKind);
}
private async Task ResetReputationDeltaTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var auditDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V010__reputation_delta_audit.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(auditDdlPath))
{
throw new FileNotFoundException("Test DDL for reputation delta persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var auditDdl = await File.ReadAllTextAsync(auditDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyAudit = new NpgsqlCommand(auditDdl, conn))
{
await applyAudit.ExecuteNonQueryAsync();
}
}
}

View File

@ -0,0 +1,359 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions;
public sealed class ReputationOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string UnknownPlayerId = "unknown-player-xyz";
private const string GridFactionId = "prototype_faction_grid_operators";
private const string RustFactionId = "prototype_faction_rust_collective";
private const string QuestSourceId = "prototype_quest_operator_chain";
private static readonly DateTimeOffset RecordedAt = new(2026, 6, 15, 14, 0, 0, TimeSpan.Zero);
[Fact]
public void TryApplyDelta_ShouldApplyStandingAndAppendAudit_WhenHappyPath()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
var timeProvider = new FakeTimeProvider(RecordedAt);
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
15,
"delta-001",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
timeProvider);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
Assert.Equal(0, outcome.PreviousStanding);
Assert.Equal(15, outcome.NewStanding);
Assert.NotNull(outcome.AuditRow);
Assert.Equal("delta-001", outcome.AuditRow!.Id);
Assert.Equal(15, outcome.AuditRow.DeltaAmount);
Assert.Equal(15, outcome.AuditRow.ResultingStanding);
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, outcome.AuditRow.SourceKind);
Assert.Equal(QuestSourceId, outcome.AuditRow.SourceId);
Assert.Equal(RecordedAt, outcome.AuditRow.RecordedAt);
var rows = auditStore.GetDeltasForPlayer(PlayerId);
var read = Assert.Single(rows);
Assert.Equal(outcome.AuditRow, read);
Assert.Equal(15, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryApplyDelta_ShouldClampToMax_WhenDeltaExceedsBand()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
50,
"delta-max",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.True(outcome.Success);
Assert.Equal(95, outcome.PreviousStanding);
Assert.Equal(100, outcome.NewStanding);
Assert.Equal(5, outcome.AuditRow!.DeltaAmount);
Assert.Equal(100, outcome.AuditRow.ResultingStanding);
Assert.Equal(100, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryApplyDelta_ShouldClampToMin_WhenDeltaBelowBand()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, -95).Success);
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
-50,
"delta-min",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.True(outcome.Success);
Assert.Equal(-95, outcome.PreviousStanding);
Assert.Equal(-100, outcome.NewStanding);
Assert.Equal(-5, outcome.AuditRow!.DeltaAmount);
Assert.Equal(-100, outcome.AuditRow.ResultingStanding);
Assert.Equal(-100, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryApplyDelta_ShouldDenyUnknownFaction_WithStructuredReason()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
"not_a_real_faction",
10,
"delta-unknown",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionStandingReasonCodes.UnknownFaction, outcome.ReasonCode);
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
}
[Fact]
public void TryApplyDelta_ShouldDenyInvalidDelta_WhenAmountZero()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
0,
"delta-zero",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(outcome.Success);
Assert.Equal(ReputationApplyReasonCodes.InvalidDelta, outcome.ReasonCode);
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
}
[Fact]
public void TryApplyDelta_ShouldDenyInvalidDeltaId_WhenEmpty()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
10,
" ",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(outcome.Success);
Assert.Equal(ReputationApplyReasonCodes.InvalidDeltaId, outcome.ReasonCode);
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
}
[Fact]
public void TryApplyDelta_ShouldDenyInvalidSource_WhenEmpty()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
// Act
var emptyKind = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
10,
"delta-source-kind",
" ",
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
var emptyId = ReputationOperations.TryApplyDelta(
PlayerId,
RustFactionId,
10,
"delta-source-id",
ReputationDeltaSourceKinds.QuestCompletion,
"",
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(emptyKind.Success);
Assert.Equal(ReputationApplyReasonCodes.InvalidSource, emptyKind.ReasonCode);
Assert.False(emptyId.Success);
Assert.Equal(ReputationApplyReasonCodes.InvalidSource, emptyId.ReasonCode);
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, RustFactionId).Standing);
Assert.Empty(auditStore.GetDeltasForPlayer(PlayerId));
}
[Fact]
public void TryApplyDelta_ShouldRollbackStanding_WhenAuditAppendFails()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
Assert.True(auditStore.TryAppend(CreateAuditRow("delta-conflict", 1, 1)));
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
15,
"delta-conflict",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(outcome.Success);
Assert.Equal(ReputationApplyReasonCodes.AuditAppendFailed, outcome.ReasonCode);
Assert.Equal(0, outcome.PreviousStanding);
Assert.Equal(0, outcome.NewStanding);
Assert.Null(outcome.AuditRow);
Assert.Equal(0, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
}
[Fact]
public void TryApplyDelta_ShouldRestoreClampedStanding_WhenAuditAppendFails()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, 95).Success);
Assert.True(auditStore.TryAppend(CreateAuditRow("delta-clamp-conflict", 1, 1)));
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
50,
"delta-clamp-conflict",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(outcome.Success);
Assert.Equal(ReputationApplyReasonCodes.AuditAppendFailed, outcome.ReasonCode);
Assert.Equal(95, outcome.PreviousStanding);
Assert.Equal(95, outcome.NewStanding);
Assert.Null(outcome.AuditRow);
Assert.Equal(95, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
}
[Fact]
public void TryApplyDelta_ShouldDenyNonWritablePlayer()
{
// Arrange
var standingStore = CreateStandingStore();
var auditStore = CreateAuditStore();
// Act
var outcome = ReputationOperations.TryApplyDelta(
UnknownPlayerId,
GridFactionId,
10,
"delta-player",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.False(outcome.Success);
Assert.Equal(FactionStandingReasonCodes.PlayerNotWritable, outcome.ReasonCode);
Assert.Empty(auditStore.GetDeltasForPlayer(UnknownPlayerId));
}
[Fact]
public async Task Host_ShouldApplyReputationDeltaViaOps_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
// Act
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
15,
"delta-host",
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
standingStore,
auditStore,
TimeProvider.System);
// Assert
Assert.True(outcome.Success);
Assert.Equal(15, standingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
Assert.Single(auditStore.GetDeltasForPlayer(PlayerId));
}
private static InMemoryFactionStandingStore CreateStandingStore()
{
var registry = CreatePrototypeRegistry();
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryFactionStandingStore(options, registry);
}
private static InMemoryReputationDeltaStore CreateAuditStore() => new();
private static ReputationDeltaRow CreateAuditRow(string id, int delta, int resulting) =>
new(
id,
PlayerId,
GridFactionId,
delta,
resulting,
ReputationDeltaSourceKinds.QuestCompletion,
QuestSourceId,
RecordedAt);
private static FactionDefinitionRegistry CreatePrototypeRegistry()
{
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId],
[RustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustFactionId],
};
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new FactionDefinitionRegistry(catalog);
}
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
}

View File

@ -3,6 +3,7 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
@ -18,6 +19,8 @@ public sealed class QuestAcceptApiTests
private const string PlayerId = "dev-local-1"; private const string PlayerId = "dev-local-1";
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
[Fact] [Fact]
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown() public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
@ -217,6 +220,7 @@ public sealed class QuestAcceptApiTests
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>(); var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>(); var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>(); var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
var standingStore = services.GetRequiredService<IFactionStandingStore>();
var timeProvider = TimeProvider.System; var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept( Assert.True(QuestStateOperations.TryAccept(
PlayerId, PlayerId,
@ -231,6 +235,7 @@ public sealed class QuestAcceptApiTests
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
timeProvider).Success); timeProvider).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId, PlayerId,
@ -289,4 +294,28 @@ public sealed class QuestAcceptApiTests
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode); Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
Assert.Null(body.Quest); Assert.Null(body.Quest);
} }
[Fact]
public async Task PostQuestAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
Assert.True(progressStore.TryActivate(PlayerId, ChainQuestId, out _));
Assert.True(progressStore.TryMarkComplete(PlayerId, ChainQuestId, DateTimeOffset.UtcNow, out _));
// Act
var response = await client.PostAsync(
$"/game/players/{PlayerId}/quests/{GridContractQuestId}/accept",
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
Assert.NotNull(body);
Assert.False(body!.Accepted);
Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, body.ReasonCode);
Assert.Null(body.Quest);
}
} }

View File

@ -35,4 +35,15 @@ internal static class QuestCatalogTestPaths
internal static string DiscoverRepoQuestRewardBundleSchemaPath() => internal static string DiscoverRepoQuestRewardBundleSchemaPath() =>
QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoQuestsDirectory()); QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoQuestsDirectory());
internal static string DiscoverRepoFactionGateRuleSchemaPath() =>
Path.GetFullPath(
Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "faction-gate-rule.schema.json"));
internal static string DiscoverRepoReputationGrantRowSchemaPath() =>
Path.GetFullPath(
Path.Combine(DiscoverRepoQuestsDirectory(), "..", "schemas", "reputation-grant-row.schema.json"));
internal static string DiscoverRepoFactionsDirectory() =>
Path.GetFullPath(Path.Combine(DiscoverRepoQuestsDirectory(), "..", "factions"));
} }

View File

@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
@ -18,6 +19,8 @@ namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionCatalogLoaderTests public class QuestDefinitionCatalogLoaderTests
{ {
private static readonly FrozenSet<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds; private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds; private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
@ -115,7 +118,10 @@ public class QuestDefinitionCatalogLoaderTests
], ],
"completionRewardBundle": { "completionRewardBundle": {
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }], "itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }] "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }],
"reputationGrants": [
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
]
}, },
"steps": [ "steps": [
{ {
@ -167,6 +173,36 @@ public class QuestDefinitionCatalogLoaderTests
] ]
} }
] ]
},
{
"id": "prototype_quest_grid_contract",
"displayName": "Grid Contract",
"prerequisiteQuestIds": ["prototype_quest_operator_chain"],
"factionGateRules": [
{
"factionId": "prototype_faction_grid_operators",
"minStanding": 15
}
],
"completionRewardBundle": {
"reputationGrants": [
{ "factionId": "prototype_faction_rust_collective", "amount": 10 }
]
},
"steps": [
{
"id": "grid_contract_step_kit",
"displayName": "Deliver survey drone kit",
"objectives": [
{
"id": "grid_contract_obj_kit",
"kind": "inventory_has_item",
"itemId": "survey_drone_kit",
"quantity": 1
}
]
}
]
} }
] ]
} }
@ -188,6 +224,8 @@ public class QuestDefinitionCatalogLoaderTests
File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
} }
@ -212,7 +250,8 @@ public class QuestDefinitionCatalogLoaderTests
string defSchemaPath, string defSchemaPath,
string stepSchemaPath, string stepSchemaPath,
string objectiveSchemaPath, string objectiveSchemaPath,
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null) => IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null,
IReadOnlySet<string>? knownFactionIds = null) =>
QuestDefinitionCatalogLoader.Load( QuestDefinitionCatalogLoader.Load(
questsDir, questsDir,
defSchemaPath, defSchemaPath,
@ -224,6 +263,7 @@ public class QuestDefinitionCatalogLoaderTests
KnownItemIds, KnownItemIds,
KnownRecipeIds, KnownRecipeIds,
KnownEncounterIds, KnownEncounterIds,
knownFactionIds ?? KnownFactionIds,
skillDefsById ?? RepoSkillDefs, skillDefsById ?? RepoSkillDefs,
NullLogger.Instance); NullLogger.Instance);
@ -238,7 +278,7 @@ public class QuestDefinitionCatalogLoaderTests
// Act // Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert // Assert
Assert.Equal(4, catalog.DistinctQuestCount); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
} }
[Fact] [Fact]
@ -250,7 +290,7 @@ public class QuestDefinitionCatalogLoaderTests
// Act // Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert // Assert
Assert.Equal(4, catalog.DistinctQuestCount); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather); Assert.NotNull(gather);
@ -267,6 +307,14 @@ public class QuestDefinitionCatalogLoaderTests
Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants); Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants);
Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId); Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId);
Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount); Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount);
Assert.True(catalog.TryGetQuest(PrototypeE7M1QuestCatalogRules.GridContractQuestId, out var gridContract));
Assert.NotNull(gridContract);
Assert.Equal(
[PrototypeE7M1QuestCatalogRules.ChainQuestId],
gridContract!.PrerequisiteQuestIds);
Assert.NotNull(gridContract.CompletionRewardBundle);
Assert.Empty(gridContract.CompletionRewardBundle!.ItemGrants);
Assert.Empty(gridContract.CompletionRewardBundle.SkillXpGrants);
} }
[Fact] [Fact]
@ -436,7 +484,7 @@ public class QuestDefinitionCatalogLoaderTests
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E7M1 expects exactly quest ids", ioe.Message, StringComparison.Ordinal); Assert.Contains("prototype quest roster expects exactly quest ids", ioe.Message, StringComparison.Ordinal);
} }
[Fact] [Fact]
@ -709,6 +757,88 @@ public class QuestDefinitionCatalogLoaderTests
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal); Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal);
} }
[Fact]
public void Load_ShouldThrow_WhenFactionGateReferencesUnknownFaction()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var grid = GetQuestRow(root, "prototype_quest_grid_contract");
var gateRules = grid["factionGateRules"] as JsonArray
?? throw new InvalidOperationException("expected factionGateRules");
(gateRules[0] as JsonObject)!["factionId"] = "unknown_faction_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("factionGateRules[0]", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenReputationGrantReferencesUnknownFaction()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var chain = GetQuestRow(root, "prototype_quest_operator_chain");
var bundle = chain["completionRewardBundle"] as JsonObject
?? throw new InvalidOperationException("expected completionRewardBundle");
var grants = bundle["reputationGrants"] as JsonArray
?? throw new InvalidOperationException("expected reputationGrants");
(grants[0] as JsonObject)!["factionId"] = "unknown_faction_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("reputationGrants[0]", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in the frozen prototype faction catalog", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenE7M3CompletionBundleFreezeDiverges()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var chain = GetQuestRow(root, "prototype_quest_operator_chain");
var bundle = chain["completionRewardBundle"] as JsonObject
?? throw new InvalidOperationException("expected completionRewardBundle");
var grants = bundle["reputationGrants"] as JsonArray
?? throw new InvalidOperationException("expected reputationGrants");
(grants[0] as JsonObject)!["amount"] = 99;
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("completionRewardBundle must match E7M3 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenGridContractGateMinStandingDiverges()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var grid = GetQuestRow(root, "prototype_quest_grid_contract");
var gateRules = grid["factionGateRules"] as JsonArray
?? throw new InvalidOperationException("expected factionGateRules");
(gateRules[0] as JsonObject)!["minStanding"] = 10;
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("factionGateRules must be", ioe.Message, StringComparison.Ordinal);
}
[Fact] [Fact]
public void Load_ShouldThrow_WhenMissingSchemaFile() public void Load_ShouldThrow_WhenMissingSchemaFile()
{ {
@ -734,7 +864,7 @@ public class QuestDefinitionCatalogLoaderTests
// Assert // Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>(); var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>();
Assert.Equal(4, questCatalog.DistinctQuestCount); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, questCatalog.DistinctQuestCount);
Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather); Assert.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName); Assert.Equal("Intro: Salvage Run", gather!.DisplayName);

View File

@ -260,7 +260,7 @@ public class QuestDefinitionRegistryTests
Assert.Equal(GatherIntroId, questId); Assert.Equal(GatherIntroId, questId);
Assert.False(unknownNormalized); Assert.False(unknownNormalized);
Assert.Equal("prototype_unknown", unknownQuestId); Assert.Equal("prototype_unknown", unknownQuestId);
Assert.Equal(4, list.Count); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, list.Count);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal), list.Select(q => q.Id)); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal), list.Select(q => q.Id));
foreach (var expectedId in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds) foreach (var expectedId in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds)
{ {

View File

@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionsWorldApiTests public class QuestDefinitionsWorldApiTests
{ {
[Fact] [Fact]
public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFourFrozenQuestsInIdOrder() public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFrozenQuestsInIdOrder()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
@ -21,7 +21,7 @@ public class QuestDefinitionsWorldApiTests
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Quests); Assert.NotNull(body.Quests);
Assert.Equal(4, body.Quests.Count); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, body.Quests.Count);
Assert.Equal( Assert.Equal(
PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal).ToArray(), PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal).ToArray(),
body.Quests.Select(q => q.Id).ToArray()); body.Quests.Select(q => q.Id).ToArray());

View File

@ -0,0 +1,130 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public sealed class QuestFixtureApiTests
{
private const string DevPlayer = "dev-local-1";
private const string FixturePath = $"/game/players/{DevPlayer}/__dev/quest-fixture";
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private static QuestFixtureRequest ValidFixture(params string[] completedQuestIds) =>
new()
{
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
CompletedQuestIds = completedQuestIds,
};
[Fact]
public async Task PostQuestFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.WithWebHostBuilder(b =>
{
b.UseEnvironment("Production");
b.UseSetting("Game:EnableQuestFixtureApi", "false");
}).CreateClient();
// Act
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostQuestFixture_ShouldReturnBadRequest_WhenBodyNull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
FixturePath,
new StringContent(string.Empty, Encoding.UTF8, "application/json"));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostQuestFixture_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = new QuestFixtureRequest
{
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion + 99,
CompletedQuestIds = [ChainQuestId],
};
// Act
var response = await client.PostAsJsonAsync(FixturePath, request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostQuestFixture_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/__dev/quest-fixture",
ValidFixture(ChainQuestId));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostQuestFixture_ShouldMarkOperatorChainCompleted_WhenNotStarted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
// Act
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestFixtureResponse>();
Assert.NotNull(body);
Assert.True(body!.Applied);
Assert.True(progressStore.TryGetProgress(DevPlayer, ChainQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
Assert.NotNull(progress.CompletedAt);
}
[Fact]
public async Task PostQuestFixture_ShouldBeIdempotent_WhenQuestAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var first = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
// Act
var second = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
// Assert
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -302,6 +303,7 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) => private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
@ -442,6 +444,7 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IEncounterCompleteEventStore>(), services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(), services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -464,5 +467,6 @@ public sealed class QuestObjectiveWiringTests
IEncounterCompleteEventStore CompleteEventStore, IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore, IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -3,6 +3,7 @@ using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -400,6 +401,7 @@ public sealed class QuestProgressApiTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) => private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
@ -493,6 +495,7 @@ public sealed class QuestProgressApiTests
services.GetRequiredService<IEncounterCompleteEventStore>(), services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(), services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -515,5 +518,6 @@ public sealed class QuestProgressApiTests
IEncounterCompleteEventStore CompleteEventStore, IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore, IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
@ -19,6 +20,62 @@ public sealed class QuestStateOperationsTests
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId; private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
[Fact]
public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
CompleteChainPrerequisite(deps);
// Act
var result = TryAccept(deps, GridContractQuestId);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, GridContractQuestId, out _));
}
[Fact]
public async Task TryAccept_ShouldAcceptGridContract_WhenStandingAtThreshold()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
CompleteChainPrerequisite(deps);
SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding);
// Act
var result = TryAccept(deps, GridContractQuestId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status);
}
[Fact]
public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenStandingOneBelowThreshold()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
CompleteChainPrerequisite(deps);
SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding - 1);
// Act
var result = TryAccept(deps, GridContractQuestId);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode);
}
[Fact] [Fact]
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted() public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
@ -438,6 +495,38 @@ public sealed class QuestStateOperationsTests
Assert.True(canWrite); Assert.True(canWrite);
} }
private static void CompleteChainPrerequisite(QuestTestDependencies deps)
{
Assert.True(deps.ProgressStore.TryActivate(PlayerId, ChainQuestId, out _));
Assert.True(deps.ProgressStore.TryMarkComplete(
PlayerId,
ChainQuestId,
DateTimeOffset.UtcNow,
out _));
}
private static void SeedGridStanding(QuestTestDependencies deps, int standing)
{
if (standing == 0)
{
return;
}
var auditStore = deps.ReputationDeltaStore;
var outcome = ReputationOperations.TryApplyDelta(
PlayerId,
GridFactionId,
standing,
$"seed-{standing}",
ReputationDeltaSourceKinds.QuestCompletion,
ChainQuestId,
deps.StandingStore,
auditStore,
deps.TimeProvider);
Assert.True(outcome.Success);
Assert.Equal(standing, deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing);
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{ {
var total = 0; var total = 0;
@ -505,6 +594,7 @@ public sealed class QuestStateOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.PerkStore, deps.PerkStore,
deps.DeliveryStore, deps.DeliveryStore,
deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) => private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
@ -538,6 +628,8 @@ public sealed class QuestStateOperationsTests
services.GetRequiredService<PerkUnlockEngine>(), services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(), services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(), services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -552,5 +644,7 @@ public sealed class QuestStateOperationsTests
PerkUnlockEngine PerkUnlockEngine, PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore, IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore, IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore ReputationDeltaStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -8,6 +8,7 @@ using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
@ -62,6 +63,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory) var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var factionsDir = FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir); builder.UseSetting("Content:ItemsDirectory", itemsDir);
@ -71,8 +75,10 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir); builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
builder.UseSetting("Content:EncountersDirectory", encountersDir); builder.UseSetting("Content:EncountersDirectory", encountersDir);
builder.UseSetting("Content:FactionsDirectory", factionsDir);
builder.UseSetting("Content:QuestsDirectory", questsDir); builder.UseSetting("Content:QuestsDirectory", questsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
@ -84,6 +90,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
d.ServiceType == typeof(IPlayerSkillProgressionStore) || d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerGigProgressionStore) || d.ServiceType == typeof(IPlayerGigProgressionStore) ||
d.ServiceType == typeof(IPlayerQuestStateStore) || d.ServiceType == typeof(IPlayerQuestStateStore) ||
d.ServiceType == typeof(IFactionStandingStore) ||
d.ServiceType == typeof(IReputationDeltaStore) ||
d.ServiceType == typeof(IPlayerInventoryStore) || d.ServiceType == typeof(IPlayerInventoryStore) ||
d.ServiceType == typeof(IResourceNodeInstanceStore) || d.ServiceType == typeof(IResourceNodeInstanceStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) || d.ServiceType == typeof(IPlayerPerkStateStore) ||
@ -106,6 +114,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>(); services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>(); services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>(); services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>(); services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>(); services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>(); services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Resolves faction catalog paths for local dev, tests, and container layouts (NEO-134).</summary>
public static class FactionCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/factions</c> directory.</summary>
public static string? TryDiscoverFactionsDirectory(string startDirectory)
{
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "content", "factions");
if (Directory.Exists(candidate))
return Path.GetFullPath(candidate);
}
return null;
}
/// <summary>
/// Resolves the factions catalog directory.
/// Empty <paramref name="configuredFactionsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
public static string ResolveFactionsDirectory(string? configuredFactionsDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredFactionsDirectory))
{
var discovered = TryDiscoverFactionsDirectory(AppContext.BaseDirectory);
if (discovered is not null)
return discovered;
throw new InvalidOperationException(
"Content:FactionsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/factions'). " +
"Set Content:FactionsDirectory in configuration or environment (e.g. Content__FactionsDirectory).");
}
var trimmed = configuredFactionsDirectory.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
/// <summary>Resolves JSON Schema path for a single faction row (Draft 2020-12).</summary>
public static string ResolveFactionDefSchemaPath(
string factionsDirectory,
string? configuredSchemaPath,
string contentRootPath)
{
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
{
var trimmed = configuredSchemaPath.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
return Path.GetFullPath(Path.Combine(factionsDirectory, "..", "schemas", "faction-def.schema.json"));
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>DI registration for the fail-fast faction catalog (NEO-134).</summary>
public static class FactionCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="FactionDefinitionCatalog"/> and <see cref="IFactionDefinitionRegistry"/> as singletons.</summary>
public static IServiceCollection AddFactionDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<FactionDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Factions.FactionCatalog");
var factionsDir = FactionCatalogPathResolution.ResolveFactionsDirectory(
opts.FactionsDirectory,
hostEnv.ContentRootPath);
var schemaPath = FactionCatalogPathResolution.ResolveFactionDefSchemaPath(
factionsDir,
opts.FactionDefSchemaPath,
hostEnv.ContentRootPath);
return FactionDefinitionCatalogLoader.Load(factionsDir, schemaPath, logger);
});
services.AddSingleton<IFactionDefinitionRegistry>(sp =>
new FactionDefinitionRegistry(sp.GetRequiredService<FactionDefinitionCatalog>()));
return services;
}
}

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Load-time faction row (NEO-134).</summary>
public sealed record FactionDefRow(
string Id,
string DisplayName,
int MinStanding,
int MaxStanding,
int NeutralStanding);

View File

@ -0,0 +1,27 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>In-memory faction catalog loaded at startup (NEO-134). Game code should prefer <see cref="IFactionDefinitionRegistry"/> for lookups.</summary>
public sealed class FactionDefinitionCatalog
{
public FactionDefinitionCatalog(
string factionsDirectory,
IReadOnlyDictionary<string, FactionDefRow> byId,
int catalogJsonFileCount)
{
FactionsDirectory = factionsDirectory;
ById = new ReadOnlyDictionary<string, FactionDefRow>(new Dictionary<string, FactionDefRow>(byId, StringComparer.Ordinal));
CatalogJsonFileCount = catalogJsonFileCount;
}
/// <summary>Absolute path to the directory that was enumerated for <c>*_factions.json</c> catalogs.</summary>
public string FactionsDirectory { get; }
public IReadOnlyDictionary<string, FactionDefRow> ById { get; }
public int DistinctFactionCount => ById.Count;
/// <summary>Number of <c>*_factions.json</c> files under <see cref="FactionsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; }
}

View File

@ -0,0 +1,201 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Loads and validates <c>content/factions/*_factions.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-134).</summary>
public static class FactionDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static FactionDefinitionCatalog Load(string factionsDirectory, string schemaPath, ILogger logger)
{
factionsDirectory = Path.GetFullPath(factionsDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List<string>();
if (!File.Exists(schemaPath))
errors.Add($"error: missing schema file {schemaPath}");
if (!Directory.Exists(factionsDirectory))
errors.Add($"error: missing directory {factionsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(factionsDirectory, "*_factions.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
if (jsonFiles.Length == 0)
errors.Add($"error: no *_factions.json files under {factionsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var factionIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal);
foreach (var path in jsonFiles)
{
JsonNode? root;
try
{
root = JsonNode.Parse(File.ReadAllText(path));
}
catch (JsonException ex)
{
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
continue;
}
if (root is not JsonObject rootObj)
{
errors.Add($"error: {path}: expected JSON object at root");
continue;
}
var schemaVersionNode = rootObj["schemaVersion"];
if (schemaVersionNode is not JsonValue schemaVersionValue ||
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
schemaVersion != 1)
{
var got = schemaVersionNode?.ToJsonString() ?? "null";
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
continue;
}
var factionsNode = rootObj["factions"];
if (factionsNode is not JsonArray factionsArray)
{
errors.Add($"error: {path}: expected top-level 'factions' array");
continue;
}
for (var i = 0; i < factionsArray.Count; i++)
{
var item = factionsArray[i];
if (item is not JsonObject rowObj)
{
errors.Add($"error: {path}: factions[{i}] must be an object");
continue;
}
var eval = schema.Evaluate(rowObj, evalOptions);
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
if (!eval.IsValid)
{
if (schemaMsgs.Count == 0)
schemaMsgs.Add($"error: {path} factions[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var rowSchemaErrors = schemaMsgs.Count;
var fid = (rowObj["id"] as JsonValue)?.GetValue<string>();
if (fid is not null && rowSchemaErrors == 0)
{
if (factionIdToSourceFile.TryGetValue(fid, out var prevPath))
{
errors.Add($"error: duplicate faction id '{fid}' in {prevPath} and {path}");
continue;
}
factionIdToSourceFile[fid] = path;
rows[fid] = ParseRow(rowObj);
}
}
}
ThrowIfAny(errors);
var roster = PrototypeE7M3FactionCatalogRules.TryGetRosterGateError(factionIdToSourceFile);
if (roster is not null)
{
errors.Add(roster);
ThrowIfAny(errors);
}
var standingBand = PrototypeE7M3FactionCatalogRules.TryGetStandingBandGateError(rows);
if (standingBand is not null)
{
errors.Add(standingBand);
ThrowIfAny(errors);
}
var freeze = PrototypeE7M3FactionCatalogRules.TryGetFreezeGateError(rows);
if (freeze is not null)
{
errors.Add(freeze);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Loaded faction catalog from {FactionsDirectory}: {FactionCount} faction(s) across {CatalogFileCount} JSON catalog file(s).",
factionsDirectory,
rows.Count,
jsonFiles.Length);
}
return new FactionDefinitionCatalog(factionsDirectory, rows, jsonFiles.Length);
}
private static FactionDefRow ParseRow(JsonObject rowObj)
{
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var minStanding = (rowObj["minStanding"] as JsonValue)!.GetValue<int>();
var maxStanding = (rowObj["maxStanding"] as JsonValue)!.GetValue<int>();
var neutralStanding = (rowObj["neutralStanding"] as JsonValue)!.GetValue<int>();
return new FactionDefRow(id, displayName, minStanding, maxStanding, neutralStanding);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, index, sink);
}
if (!r.HasErrors)
return;
foreach (var kv in r.Errors!)
{
var loc = r.InstanceLocation?.ToString();
if (string.IsNullOrEmpty(loc) || loc == "#")
loc = "(root)";
sink.Add($"error: {filePath} factions[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Faction catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -0,0 +1,39 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Adapter over <see cref="FactionDefinitionCatalog"/> (NEO-134).</summary>
public sealed class FactionDefinitionRegistry(FactionDefinitionCatalog catalog) : IFactionDefinitionRegistry
{
/// <inheritdoc />
public bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition)
{
if (factionId is null)
{
definition = null;
return false;
}
if (catalog.ById.TryGetValue(factionId, out var row))
{
definition = row;
return true;
}
definition = null;
return false;
}
/// <inheritdoc />
public IReadOnlyList<FactionDefRow> GetDefinitionsInIdOrder()
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<FactionDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Result of <see cref="FactionGateOperations.TryEvaluate"/> (NEO-137).</summary>
public readonly record struct FactionGateEvaluateOutcome(
bool Success,
string? ReasonCode,
string? FailingFactionId,
int? RequiredMinStanding,
int? CurrentStanding);

View File

@ -0,0 +1,7 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Stable deny reason codes for <see cref="FactionGateOperations.TryEvaluate"/> (NEO-137).</summary>
public static class FactionGateEvaluateReasonCodes
{
public const string GateBlocked = "gate_blocked";
}

View File

@ -0,0 +1,61 @@
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Evaluates <see cref="FactionGateRuleRow"/> minimum-standing gates (NEO-137).
/// Quest accept wiring: <see cref="QuestStateOperations.TryAccept"/>.
/// NEO-141 telemetry hook site: deny return path below.
/// </summary>
public static class FactionGateOperations
{
/// <summary>
/// All rules must pass (AND). Empty rules succeed immediately.
/// Compares player standing vs <see cref="FactionGateRuleRow.MinStanding"/> with <c>standing &gt;= minStanding</c>.
/// </summary>
public static FactionGateEvaluateOutcome TryEvaluate(
string playerId,
IReadOnlyList<FactionGateRuleRow> rules,
IFactionStandingStore standingStore)
{
var normalizedPlayerId = FactionStandingIds.NormalizePlayerId(playerId);
if (rules.Count == 0)
{
return Success();
}
foreach (var rule in rules)
{
var factionId = FactionStandingIds.NormalizeFactionId(rule.FactionId);
var read = standingStore.TryGetStanding(normalizedPlayerId, factionId);
if (!read.Success)
{
return Deny(read.ReasonCode, factionId, rule.MinStanding, null);
}
if (read.Standing < rule.MinStanding)
{
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `faction_gate_blocked` ---
// TODO(E9.M1): catalog emit — playerId, factionId, minStanding, currentStanding, gate context.
// No ingest or ILogger here (comments-only).
return Deny(
FactionGateEvaluateReasonCodes.GateBlocked,
factionId,
rule.MinStanding,
read.Standing);
}
}
return Success();
}
private static FactionGateEvaluateOutcome Success() =>
new(true, null, null, null, null);
private static FactionGateEvaluateOutcome Deny(
string? reasonCode,
string factionId,
int minStanding,
int? currentStanding) =>
new(false, reasonCode, factionId, minStanding, currentStanding);
}

View File

@ -0,0 +1,41 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Id normalization for faction standing stores (NEO-135).</summary>
public static class FactionStandingIds
{
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizePlayerId(string? playerId)
{
var trimmed = playerId?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
return string.Empty;
}
return trimmed.ToLowerInvariant();
}
/// <summary>Trim; empty when input is null/whitespace. Faction ids stay case-sensitive (catalog ids are lowercase snake).</summary>
public static string NormalizeFactionId(string? factionId)
{
var trimmed = factionId?.Trim();
return string.IsNullOrEmpty(trimmed) ? string.Empty : trimmed;
}
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="factionId"/>.</summary>
public static string MakeStandingKey(string? playerId, string? factionId)
{
var player = NormalizePlayerId(playerId);
var faction = NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0)
{
return string.Empty;
}
return $"{player}\0{faction}";
}
/// <summary>Clamps <paramref name="standing"/> to the faction definition band.</summary>
public static int ClampStanding(int standing, FactionDefRow definition) =>
Math.Clamp(standing, definition.MinStanding, definition.MaxStanding);
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Result of <see cref="IFactionStandingStore.TryApplyStandingDelta"/> (NEO-135).</summary>
public readonly record struct FactionStandingMutationOutcome(
bool Success,
string? ReasonCode,
int PreviousStanding,
int NewStanding);

View File

@ -0,0 +1,7 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Result of <see cref="IFactionStandingStore.TryGetStanding"/> (NEO-135).</summary>
public readonly record struct FactionStandingReadOutcome(
bool Success,
string? ReasonCode,
int Standing);

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Stable deny reason codes for faction standing store operations (NEO-135).</summary>
public static class FactionStandingReasonCodes
{
public const string UnknownFaction = "unknown_faction";
public const string PlayerNotWritable = "player_not_writable";
}

View File

@ -0,0 +1,25 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Registers faction standing + reputation delta audit persistence (NEO-135).</summary>
public static class FactionStandingServiceCollectionExtensions
{
/// <summary>PostgreSQL when <c>ConnectionStrings:NeonSprawl</c> is set; otherwise in-memory fallback.</summary>
public static IServiceCollection AddFactionStandingStores(this IServiceCollection services, IConfiguration configuration)
{
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IFactionStandingStore, PostgresFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, PostgresReputationDeltaStore>();
}
else
{
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
}
return services;
}
}

View File

@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Read-only access to validated <see cref="FactionDefRow"/> entries loaded at startup (<see cref="FactionDefinitionCatalog"/>).
/// </summary>
public interface IFactionDefinitionRegistry
{
/// <summary>Attempts to resolve a faction by stable <c>id</c> (see <c>faction-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? factionId, [NotNullWhen(true)] out FactionDefRow? definition);
/// <summary>Every loaded definition, ordered by <see cref="FactionDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<FactionDefRow> GetDefinitionsInIdOrder();
}

View File

@ -0,0 +1,23 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Persisted per-player faction standing keyed by <c>(playerId, factionId)</c> (NEO-135).
/// Missing row ⇒ neutral <c>0</c> at read. Consumed by <c>ReputationOperations</c> (NEO-136) and gate eval (NEO-137).
/// </summary>
public interface IFactionStandingStore
{
/// <summary>Whether mutations for <paramref name="playerId"/> are allowed (dev bucket or Postgres <c>player_position</c> row).</summary>
bool CanWritePlayer(string playerId);
/// <summary>
/// Reads standing for one faction. Missing row ⇒ <c>0</c> clamped to faction min/max.
/// Unknown faction id ⇒ structured deny with <see cref="FactionStandingReasonCodes.UnknownFaction"/>.
/// </summary>
FactionStandingReadOutcome TryGetStanding(string playerId, string factionId);
/// <summary>
/// Applies a signed delta, clamps resulting standing to faction min/max, and persists.
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
/// </summary>
FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount);
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Append-only reputation delta audit log (NEO-135).</summary>
public interface IReputationDeltaStore
{
/// <summary>
/// First append with a given row <c>Id</c> returns <c>true</c>; duplicate ids return <c>false</c>.
/// Postgres: row references <c>player_position</c> — orchestrators (NEO-136) should apply standing before audit append.
/// </summary>
bool TryAppend(ReputationDeltaRow row);
/// <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);
}

View File

@ -0,0 +1,124 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Thread-safe in-memory faction standing; seeds the configured dev player (NEO-135).</summary>
public sealed class InMemoryFactionStandingStore(
IOptions<GamePositionOptions> options,
IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore
{
private readonly HashSet<string> knownPlayers = CreateKnownPlayers(options.Value);
private readonly ConcurrentDictionary<string, int> standingByKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
private static HashSet<string> CreateKnownPlayers(GamePositionOptions o)
{
var id = FactionStandingIds.NormalizePlayerId(o.DevPlayerId);
if (id.Length == 0)
{
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
}
return new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
}
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
return player.Length > 0 && knownPlayers.Contains(player);
}
/// <inheritdoc />
public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
if (!CanWritePlayer(player))
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
var key = FactionStandingIds.MakeStandingKey(player, faction);
var raw = standingByKey.GetValueOrDefault(key, 0);
return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition));
}
/// <inheritdoc />
public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
if (!CanWritePlayer(player))
{
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
var key = FactionStandingIds.MakeStandingKey(player, faction);
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
var raw = standingByKey.GetValueOrDefault(key, 0);
var previousStanding = FactionStandingIds.ClampStanding(raw, definition);
var newStanding = FactionStandingIds.ClampStanding(previousStanding + deltaAmount, definition);
standingByKey[key] = newStanding;
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
}
}
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
new(false, reasonCode, 0);
private static FactionStandingMutationOutcome DenyMutation(string reasonCode) =>
new(false, reasonCode, 0, 0);
/// <summary>Test-only seed of raw standing before read-clamp (NEO-135 unit tests).</summary>
internal void SeedRawStandingForTests(string playerId, string factionId, int rawStanding)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0 || !CanWritePlayer(player))
{
return;
}
var key = FactionStandingIds.MakeStandingKey(player, faction);
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
standingByKey[key] = rawStanding;
}
}
}

View File

@ -0,0 +1,69 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Thread-safe in-memory append-only reputation delta audit log (NEO-135).</summary>
public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
{
private readonly ConcurrentDictionary<string, ReputationDeltaRow> rowsById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryAppend(ReputationDeltaRow row)
{
if (!IsValidRow(row))
{
return false;
}
var normalized = row with
{
Id = row.Id.Trim(),
PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId),
FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId),
SourceKind = row.SourceKind.Trim(),
SourceId = row.SourceId.Trim(),
};
lock (idLocks.GetOrAdd(normalized.Id, _ => new object()))
{
if (rowsById.ContainsKey(normalized.Id))
{
return false;
}
rowsById[normalized.Id] = normalized;
return true;
}
}
/// <inheritdoc />
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return [];
}
IEnumerable<ReputationDeltaRow> query = rowsById.Values
.Where(r => string.Equals(r.PlayerId, player, StringComparison.Ordinal))
.OrderBy(static r => r.RecordedAt)
.ThenBy(static r => r.Id, StringComparer.Ordinal);
if (limit is > 0)
{
query = query.Take(limit.Value);
}
return query.ToArray();
}
private static bool IsValidRow(ReputationDeltaRow row) =>
row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&
FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 &&
row.SourceKind.Trim().Length > 0 &&
row.SourceId.Trim().Length > 0;
}

View File

@ -0,0 +1,154 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>PostgreSQL-backed faction standing keyed by normalized player id + faction id (NEO-135).</summary>
public sealed class PostgresFactionStandingStore(
Npgsql.NpgsqlDataSource dataSource,
IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore
{
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return false;
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
return PlayerExists(conn, player);
}
/// <inheritdoc />
public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, player))
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT standing
FROM player_faction_standing
WHERE player_id = @pid AND faction_id = @fid
LIMIT 1;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("fid", faction);
var scalar = cmd.ExecuteScalar();
var raw = scalar is null or DBNull
? 0
: scalar is int xi
? xi
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition));
}
/// <inheritdoc />
public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, player, tx))
{
tx.Rollback();
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
var raw = 0;
using (var sel = new Npgsql.NpgsqlCommand(
"""
SELECT standing
FROM player_faction_standing
WHERE player_id = @pid AND faction_id = @fid
LIMIT 1
FOR UPDATE;
""",
conn,
tx))
{
sel.Parameters.AddWithValue("pid", player);
sel.Parameters.AddWithValue("fid", faction);
var scalar = sel.ExecuteScalar();
raw = scalar is null or DBNull
? 0
: scalar is int xi
? xi
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
}
var previousStanding = FactionStandingIds.ClampStanding(raw, definition);
var newStanding = FactionStandingIds.ClampStanding(previousStanding + deltaAmount, definition);
using var upsert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at)
VALUES (@pid, @fid, @standing, now())
ON CONFLICT (player_id, faction_id)
DO UPDATE SET standing = EXCLUDED.standing, updated_at = now();
""",
conn,
tx);
upsert.Parameters.AddWithValue("pid", player);
upsert.Parameters.AddWithValue("fid", faction);
upsert.Parameters.AddWithValue("standing", newStanding);
upsert.ExecuteNonQuery();
tx.Commit();
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
new(false, reasonCode, 0);
private static FactionStandingMutationOutcome DenyMutation(string reasonCode) =>
new(false, reasonCode, 0, 0);
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Applies NEO-135 faction standing table DDL once per process.</summary>
public static class PostgresPlayerFactionStandingBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V009__player_faction_standing.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-135 faction standing DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = File.ReadAllText(ddlPath);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
cmd.ExecuteNonQuery();
Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -0,0 +1,39 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Applies NEO-135 reputation delta audit table DDL once per process.</summary>
public static class PostgresReputationDeltaBootstrap
{
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V010__reputation_delta_audit.sql");
private static readonly object SchemaGate = new();
private static int _schemaReady;
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
lock (SchemaGate)
{
if (Volatile.Read(ref _schemaReady) != 0)
{
return;
}
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
if (!File.Exists(ddlPath))
{
throw new FileNotFoundException($"NEO-135 reputation delta DDL not found at '{ddlPath}'.", ddlPath);
}
var ddl = File.ReadAllText(ddlPath);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
cmd.ExecuteNonQuery();
Volatile.Write(ref _schemaReady, 1);
}
}
}

View File

@ -0,0 +1,100 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary>
public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore
{
/// <inheritdoc />
public bool TryAppend(ReputationDeltaRow row)
{
if (!IsValidRow(row))
{
return false;
}
var normalized = row with
{
Id = row.Id.Trim(),
PlayerId = FactionStandingIds.NormalizePlayerId(row.PlayerId),
FactionId = FactionStandingIds.NormalizeFactionId(row.FactionId),
SourceKind = row.SourceKind.Trim(),
SourceId = row.SourceId.Trim(),
};
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
INSERT INTO reputation_delta_audit (
id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at)
VALUES (@id, @pid, @fid, @delta, @result, @kind, @source_id, @recorded)
ON CONFLICT (id) DO NOTHING;
""",
conn);
cmd.Parameters.AddWithValue("id", normalized.Id);
cmd.Parameters.AddWithValue("pid", normalized.PlayerId);
cmd.Parameters.AddWithValue("fid", normalized.FactionId);
cmd.Parameters.AddWithValue("delta", normalized.DeltaAmount);
cmd.Parameters.AddWithValue("result", normalized.ResultingStanding);
cmd.Parameters.AddWithValue("kind", normalized.SourceKind);
cmd.Parameters.AddWithValue("source_id", normalized.SourceId);
cmd.Parameters.AddWithValue("recorded", normalized.RecordedAt);
return cmd.ExecuteNonQuery() == 1;
}
/// <inheritdoc />
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return [];
}
PostgresReputationDeltaBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
var sql = limit is > 0
? """
SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at
FROM reputation_delta_audit
WHERE player_id = @pid
ORDER BY recorded_at ASC, id ASC
LIMIT @lim;
"""
: """
SELECT id, player_id, faction_id, delta_amount, resulting_standing, source_kind, source_id, recorded_at
FROM reputation_delta_audit
WHERE player_id = @pid
ORDER BY recorded_at ASC, id ASC;
""";
using var cmd = new Npgsql.NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("pid", player);
if (limit is > 0)
{
cmd.Parameters.AddWithValue("lim", limit.Value);
}
var rows = new List<ReputationDeltaRow>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(new ReputationDeltaRow(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetInt32(3),
reader.GetInt32(4),
reader.GetString(5),
reader.GetString(6),
reader.GetFieldValue<DateTimeOffset>(7)));
}
return rows;
}
private static bool IsValidRow(ReputationDeltaRow row) =>
row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&
FactionStandingIds.NormalizeFactionId(row.FactionId).Length > 0 &&
row.SourceKind.Trim().Length > 0 &&
row.SourceId.Trim().Length > 0;
}

View File

@ -0,0 +1,95 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Prototype E7M3 faction catalog gates (NEO-134), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_*</c> and <c>_prototype_e7m3_faction_*</c> gate functions.
/// </summary>
public static class PrototypeE7M3FactionCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedFactionIds = FrozenSet.ToFrozenSet(
[
"prototype_faction_grid_operators",
"prototype_faction_rust_collective",
],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_FACTION_FREEZE</c>.</summary>
public static readonly FrozenDictionary<string, FactionDefRow> ExpectedFactionFreeze =
new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
["prototype_faction_grid_operators"] = new(
"prototype_faction_grid_operators",
"Grid Operators",
-100,
100,
0),
["prototype_faction_rust_collective"] = new(
"prototype_faction_rust_collective",
"Rust Collective",
-100,
100,
0),
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the prototype faction roster fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetRosterGateError(IReadOnlyDictionary<string, string> factionIdToSourceFile)
{
var ids = factionIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedFactionIds))
{
return
"error: prototype E7M3 expects exactly faction ids " +
$"[{string.Join(", ", ExpectedFactionIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}
return null;
}
/// <summary>Returns a human-readable error if faction rows diverge from the E7M3 freeze table, otherwise <see langword="null"/>.</summary>
public static string? TryGetFreezeGateError(IReadOnlyDictionary<string, FactionDefRow> rowsById)
{
foreach (var (fid, expected) in ExpectedFactionFreeze)
{
if (!rowsById.TryGetValue(fid, out var actual))
return $"error: missing faction '{fid}'";
if (!RowsEqual(expected, actual))
{
return
$"error: faction '{fid}' must match E7M3 freeze table " +
$"(expected displayName '{expected.DisplayName}', minStanding {expected.MinStanding}, " +
$"maxStanding {expected.MaxStanding}, neutralStanding {expected.NeutralStanding}; " +
$"got displayName '{actual.DisplayName}', minStanding {actual.MinStanding}, " +
$"maxStanding {actual.MaxStanding}, neutralStanding {actual.NeutralStanding})";
}
}
return null;
}
/// <summary>Returns a human-readable error if standing band ordering fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetStandingBandGateError(IReadOnlyDictionary<string, FactionDefRow> rowsById)
{
foreach (var (fid, row) in rowsById)
{
if (row.MinStanding > row.NeutralStanding || row.NeutralStanding > row.MaxStanding)
{
return
$"error: faction '{fid}' requires minStanding <= neutralStanding <= maxStanding " +
$"(got minStanding {row.MinStanding}, neutralStanding {row.NeutralStanding}, maxStanding {row.MaxStanding})";
}
}
return null;
}
private static bool RowsEqual(FactionDefRow left, FactionDefRow right) =>
left.DisplayName == right.DisplayName &&
left.MinStanding == right.MinStanding &&
left.MaxStanding == right.MaxStanding &&
left.NeutralStanding == right.NeutralStanding;
}

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Result of <see cref="ReputationOperations.TryApplyDelta"/> (NEO-136).</summary>
public readonly record struct ReputationApplyOutcome(
bool Success,
string? ReasonCode,
int PreviousStanding,
int NewStanding,
ReputationDeltaRow? AuditRow);

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Stable deny reason codes for <see cref="ReputationOperations.TryApplyDelta"/> (NEO-136).</summary>
public static class ReputationApplyReasonCodes
{
public const string InvalidDelta = "invalid_delta";
public const string InvalidDeltaId = "invalid_delta_id";
public const string InvalidSource = "invalid_source";
public const string AuditAppendFailed = "audit_append_failed";
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Append-only reputation delta audit row (NEO-135).</summary>
public sealed record ReputationDeltaRow(
string Id,
string PlayerId,
string FactionId,
int DeltaAmount,
int ResultingStanding,
string SourceKind,
string SourceId,
DateTimeOffset RecordedAt);

View File

@ -0,0 +1,7 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>Stable source-kind values for <see cref="ReputationDeltaRow"/> (NEO-135).</summary>
public static class ReputationDeltaSourceKinds
{
public const string QuestCompletion = "quest_completion";
}

View File

@ -0,0 +1,93 @@
namespace NeonSprawl.Server.Game.Factions;
/// <summary>
/// Orchestrates auditable faction standing apply: standing mutation + append-only audit row (NEO-136).
/// Quest reward wiring: <see cref="Rewards.RewardRouterOperations"/> (NEO-138).
/// NEO-141 telemetry hook site: successful apply path in <see cref="TryApplyDelta"/>.
/// </summary>
public static class ReputationOperations
{
/// <summary>
/// Applies a signed standing delta for one player+faction, clamps to faction bounds, and appends one audit row.
/// Callers must not invoke <see cref="IFactionStandingStore.TryApplyStandingDelta"/> alone for game apply.
/// </summary>
public static ReputationApplyOutcome TryApplyDelta(
string playerId,
string factionId,
int deltaAmount,
string deltaId,
string sourceKind,
string sourceId,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
if (deltaAmount == 0)
{
return Deny(ReputationApplyReasonCodes.InvalidDelta);
}
var normalizedPlayerId = FactionStandingIds.NormalizePlayerId(playerId);
var normalizedFactionId = FactionStandingIds.NormalizeFactionId(factionId);
var normalizedDeltaId = deltaId.Trim();
if (normalizedDeltaId.Length == 0)
{
return Deny(ReputationApplyReasonCodes.InvalidDeltaId);
}
var normalizedSourceKind = sourceKind.Trim();
var normalizedSourceId = sourceId.Trim();
if (normalizedSourceKind.Length == 0 || normalizedSourceId.Length == 0)
{
return Deny(ReputationApplyReasonCodes.InvalidSource);
}
var mutation = standingStore.TryApplyStandingDelta(normalizedPlayerId, normalizedFactionId, deltaAmount);
if (!mutation.Success)
{
return new ReputationApplyOutcome(
false,
mutation.ReasonCode,
mutation.PreviousStanding,
mutation.NewStanding,
null);
}
var appliedDelta = mutation.NewStanding - mutation.PreviousStanding;
var row = new ReputationDeltaRow(
normalizedDeltaId,
normalizedPlayerId,
normalizedFactionId,
appliedDelta,
mutation.NewStanding,
normalizedSourceKind,
normalizedSourceId,
timeProvider.GetUtcNow());
if (auditStore.TryAppend(row))
{
// --- Telemetry hook site (NEO-141): future E9.M1 catalog event `reputation_delta` ---
return new ReputationApplyOutcome(
true,
null,
mutation.PreviousStanding,
mutation.NewStanding,
row);
}
if (appliedDelta != 0)
{
_ = standingStore.TryApplyStandingDelta(normalizedPlayerId, normalizedFactionId, -appliedDelta);
}
return new ReputationApplyOutcome(
false,
ReputationApplyReasonCodes.AuditAppendFailed,
mutation.PreviousStanding,
mutation.PreviousStanding,
null);
}
private static ReputationApplyOutcome Deny(string reasonCode) =>
new(false, reasonCode, 0, 0, null);
}

View File

@ -16,6 +16,9 @@ public sealed class GamePositionOptions
/// <summary>When true, maps <c>POST /game/__dev/combat-targets-fixture</c> to reset prototype dummy HP (also enabled in Development/Testing).</summary> /// <summary>When true, maps <c>POST /game/__dev/combat-targets-fixture</c> to reset prototype dummy HP (also enabled in Development/Testing).</summary>
public bool EnableCombatTargetFixtureApi { get; set; } public bool EnableCombatTargetFixtureApi { get; set; }
/// <summary>When true, maps <c>POST …/__dev/quest-fixture</c> for Bruno/manual QA quest progress setup (also enabled in Development).</summary>
public bool EnableQuestFixtureApi { get; set; }
/// <summary>World position for the dev player at process start.</summary> /// <summary>World position for the dev player at process start.</summary>
public DefaultPositionOptions DefaultPosition { get; set; } = new(); public DefaultPositionOptions DefaultPosition { get; set; } = new();

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Minimum standing gate on <see cref="QuestDefRow.FactionGateRules"/> (NEO-134).</summary>
public sealed record FactionGateRuleRow(string FactionId, int MinStanding);

View File

@ -3,26 +3,31 @@ using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
/// <summary> /// <summary>
/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from /// Prototype quest roster + cross-ref gates (NEO-113, NEO-133), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>, /// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_QUEST_IDS</c> (five ids),
/// <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>, <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>, /// <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>, <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>,
/// and <c>_prototype_e7m1_*</c> gate functions. /// and <c>_prototype_e7m1_*</c> / <c>_prototype_e7m3_*</c> gate functions.
/// Class name retained for NEO-113 call sites; rename to <c>PrototypeE7M3QuestCatalogRules</c> planned in NEO-134.
/// </summary> /// </summary>
public static class PrototypeE7M1QuestCatalogRules public static class PrototypeE7M1QuestCatalogRules
{ {
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>.</summary> /// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_QUEST_IDS</c> (five quests).</summary>
public static readonly FrozenSet<string> ExpectedQuestIds = FrozenSet.ToFrozenSet( public static readonly FrozenSet<string> ExpectedQuestIds = FrozenSet.ToFrozenSet(
[ [
"prototype_quest_gather_intro", "prototype_quest_gather_intro",
"prototype_quest_refine_intro", "prototype_quest_refine_intro",
"prototype_quest_combat_intro", "prototype_quest_combat_intro",
"prototype_quest_operator_chain", "prototype_quest_operator_chain",
"prototype_quest_grid_contract",
], ],
StringComparer.Ordinal); StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary> /// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary>
public const string ChainQuestId = "prototype_quest_operator_chain"; public const string ChainQuestId = "prototype_quest_operator_chain";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID</c>.</summary>
public const string GridContractQuestId = "prototype_quest_grid_contract";
/// <summary>First frozen onboarding quest id (gather intro).</summary> /// <summary>First frozen onboarding quest id (gather intro).</summary>
public const string GatherIntroQuestId = "prototype_quest_gather_intro"; public const string GatherIntroQuestId = "prototype_quest_gather_intro";
@ -41,14 +46,14 @@ public static class PrototypeE7M1QuestCatalogRules
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary> /// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary>
public const string ChainTerminalItemId = "contract_handoff_token"; public const string ChainTerminalItemId = "contract_handoff_token";
/// <summary>Returns a human-readable error if the E7M1 quest contract fails, otherwise <see langword="null"/>.</summary> /// <summary>Returns a human-readable error if the prototype quest roster fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetE7M1GateError(IReadOnlyDictionary<string, string> questIdToSourceFile) public static string? TryGetE7M1GateError(IReadOnlyDictionary<string, string> questIdToSourceFile)
{ {
var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedQuestIds)) if (!ids.SetEquals(ExpectedQuestIds))
{ {
return return
"error: prototype E7M1 expects exactly quest ids " + "error: prototype quest roster expects exactly quest ids " +
$"[{string.Join(", ", ExpectedQuestIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + $"[{string.Join(", ", ExpectedQuestIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
} }

View File

@ -30,6 +30,9 @@ public static class PrototypeE7M2QuestCatalogRules
[PrototypeE7M1QuestCatalogRules.ChainQuestId] = new( [PrototypeE7M1QuestCatalogRules.ChainQuestId] = new(
[new RewardGrantRow("survey_drone_kit", 1)], [new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)]), [new QuestSkillXpGrantRow("salvage", 50)]),
[PrototypeE7M1QuestCatalogRules.GridContractQuestId] = new(
[],
[]),
}.ToFrozenDictionary(StringComparer.Ordinal); }.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise <see langword="null"/>.</summary> /// <summary>Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise <see langword="null"/>.</summary>

View File

@ -0,0 +1,235 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Encounters;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Prototype E7M3 quest faction gates (NEO-134), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_*</c> and <c>_prototype_e7m3_*</c> gate functions.
/// </summary>
public static class PrototypeE7M3QuestFactionRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID</c>.</summary>
public const string GridContractGateFactionId = "prototype_faction_grid_operators";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING</c>.</summary>
public const int GridContractMinStanding = 15;
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID</c>.</summary>
public const string GridContractObjectiveItemId = "survey_drone_kit";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_COMPLETION_BUNDLES</c>.</summary>
public static readonly FrozenDictionary<string, QuestRewardBundleRow> ExpectedCompletionBundles =
new Dictionary<string, QuestRewardBundleRow>(StringComparer.Ordinal)
{
[PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("salvage", 25)],
[]),
[PrototypeE7M1QuestCatalogRules.RefineIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("refine", 25)],
[]),
[PrototypeE7M1QuestCatalogRules.CombatIntroQuestId] = new(
[],
[new QuestSkillXpGrantRow("salvage", 25)],
[]),
[PrototypeE7M1QuestCatalogRules.ChainQuestId] = new(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)],
[new ReputationGrantRow("prototype_faction_grid_operators", 15)]),
[PrototypeE7M1QuestCatalogRules.GridContractQuestId] = new(
[],
[],
[new ReputationGrantRow("prototype_faction_rust_collective", 10)]),
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if quest faction refs fail, otherwise <see langword="null"/>.</summary>
public static string? TryGetFactionCrossRefError(
IReadOnlyDictionary<string, QuestDefRow> rowsById,
IReadOnlySet<string> knownFactionIds)
{
foreach (var (qid, row) in rowsById)
{
for (var gi = 0; gi < row.FactionGateRules.Count; gi++)
{
var rule = row.FactionGateRules[gi];
if (!knownFactionIds.Contains(rule.FactionId))
{
return
$"error: quest '{qid}' factionGateRules[{gi}]: factionId '{rule.FactionId}' " +
"is not in the frozen prototype faction catalog";
}
}
if (row.CompletionRewardBundle is null)
continue;
for (var gi = 0; gi < row.CompletionRewardBundle.ReputationGrants.Count; gi++)
{
var grant = row.CompletionRewardBundle.ReputationGrants[gi];
if (!knownFactionIds.Contains(grant.FactionId))
{
return
$"error: quest '{qid}' completionRewardBundle.reputationGrants[{gi}]: factionId '{grant.FactionId}' " +
"is not in the frozen prototype faction catalog";
}
}
}
return null;
}
/// <summary>Returns a human-readable error if bundle contents diverge from the E7M3 freeze table, otherwise <see langword="null"/>.</summary>
public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
{
foreach (var (qid, expected) in ExpectedCompletionBundles)
{
if (!rowsById.TryGetValue(qid, out var row))
return $"error: missing quest '{qid}'";
if (row.CompletionRewardBundle is null)
return $"error: quest '{qid}' must include completionRewardBundle object";
var actual = NormalizeBundle(row.CompletionRewardBundle);
var expectedNormalized = NormalizeBundle(expected);
if (!BundlesEqual(actual, expectedNormalized))
{
return
$"error: quest '{qid}' completionRewardBundle must match E7M3 freeze table " +
$"(expected {FormatBundle(expectedNormalized)}, got {FormatBundle(actual)})";
}
}
return null;
}
/// <summary>Returns a human-readable error if grid contract quest shape fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetGridContractShapeError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
{
var qid = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
if (!rowsById.TryGetValue(qid, out var row))
return $"error: missing quest '{qid}'";
if (!row.PrerequisiteQuestIds.SequenceEqual(
[PrototypeE7M1QuestCatalogRules.ChainQuestId],
StringComparer.Ordinal))
{
var got = $"[{string.Join(", ", row.PrerequisiteQuestIds.Select(s => "'" + s + "'"))}]";
return
$"error: quest '{qid}' prerequisiteQuestIds must be ['{PrototypeE7M1QuestCatalogRules.ChainQuestId}'], got {got}";
}
if (row.FactionGateRules.Count != 1 ||
row.FactionGateRules[0].FactionId != GridContractGateFactionId ||
row.FactionGateRules[0].MinStanding != GridContractMinStanding)
{
var expected =
$"[{{ factionId: '{GridContractGateFactionId}', minStanding: {GridContractMinStanding} }}]";
var gotRules = string.Join(
", ",
row.FactionGateRules.Select(r => $"{{ factionId: '{r.FactionId}', minStanding: {r.MinStanding} }}"));
return $"error: quest '{qid}' factionGateRules must be {expected}, got [{gotRules}]";
}
if (row.Steps.Count != 1)
return $"error: quest '{qid}' must have exactly one step";
var step = row.Steps[0];
if (step.Objectives.Count != 1)
return $"error: quest '{qid}' must have exactly one objective";
var objective = step.Objectives[0];
if (objective.Kind != "inventory_has_item")
{
return $"error: quest '{qid}' objective kind must be 'inventory_has_item'";
}
if (objective.ItemId != GridContractObjectiveItemId)
{
return
$"error: quest '{qid}' objective itemId must be '{GridContractObjectiveItemId}', got '{objective.ItemId}'";
}
if (objective.Quantity != 1)
{
return $"error: quest '{qid}' objective quantity must be 1, got {objective.Quantity}";
}
return null;
}
private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) =>
new(
bundle.ItemGrants
.OrderBy(g => g.ItemId, StringComparer.Ordinal)
.ThenBy(g => g.Quantity)
.ToArray(),
bundle.SkillXpGrants
.OrderBy(g => g.SkillId, StringComparer.Ordinal)
.ThenBy(g => g.Amount)
.ToArray(),
bundle.ReputationGrants
.OrderBy(g => g.FactionId, StringComparer.Ordinal)
.ThenBy(g => g.Amount)
.ToArray());
private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right)
{
if (left.ItemGrants.Length != right.ItemGrants.Length ||
left.SkillXpGrants.Length != right.SkillXpGrants.Length ||
left.ReputationGrants.Length != right.ReputationGrants.Length)
{
return false;
}
for (var i = 0; i < left.ItemGrants.Length; i++)
{
if (left.ItemGrants[i].ItemId != right.ItemGrants[i].ItemId ||
left.ItemGrants[i].Quantity != right.ItemGrants[i].Quantity)
{
return false;
}
}
for (var i = 0; i < left.SkillXpGrants.Length; i++)
{
if (left.SkillXpGrants[i].SkillId != right.SkillXpGrants[i].SkillId ||
left.SkillXpGrants[i].Amount != right.SkillXpGrants[i].Amount)
{
return false;
}
}
for (var i = 0; i < left.ReputationGrants.Length; i++)
{
if (left.ReputationGrants[i].FactionId != right.ReputationGrants[i].FactionId ||
left.ReputationGrants[i].Amount != right.ReputationGrants[i].Amount)
{
return false;
}
}
return true;
}
private static string FormatBundle(NormalizedBundle bundle)
{
var itemGrants = string.Join(
", ",
bundle.ItemGrants.Select(g => $"{{ itemId: '{g.ItemId}', quantity: {g.Quantity} }}"));
var skillXpGrants = string.Join(
", ",
bundle.SkillXpGrants.Select(g => $"{{ skillId: '{g.SkillId}', amount: {g.Amount} }}"));
var reputationGrants = string.Join(
", ",
bundle.ReputationGrants.Select(g => $"{{ factionId: '{g.FactionId}', amount: {g.Amount} }}"));
return
$"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}], reputationGrants: [{reputationGrants}] }}";
}
private sealed record NormalizedBundle(
RewardGrantRow[] ItemGrants,
QuestSkillXpGrantRow[] SkillXpGrants,
ReputationGrantRow[] ReputationGrants);
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
@ -18,7 +19,8 @@ public static class QuestAcceptApi
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore, ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) => IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
TimeProvider timeProvider) =>
{ {
if (body is not null && if (body is not null &&
body.SchemaVersion != 0 && body.SchemaVersion != 0 &&
@ -46,6 +48,7 @@ public static class QuestAcceptApi
perkUnlockEngine, perkUnlockEngine,
perkStore, perkStore,
deliveryStore, deliveryStore,
standingStore,
timeProvider); timeProvider);
return Results.Json(MapResponse(trimmedId, deliveryStore, result)); return Results.Json(MapResponse(trimmedId, deliveryStore, result));

View File

@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
@ -25,6 +26,7 @@ public static class QuestCatalogServiceCollectionExtensions
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>(); var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>(); var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>(); var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
var factionCatalog = sp.GetRequiredService<FactionDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>() var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog"); .CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog");
@ -50,6 +52,7 @@ public static class QuestCatalogServiceCollectionExtensions
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
return QuestDefinitionCatalogLoader.Load( return QuestDefinitionCatalogLoader.Load(
questsDir, questsDir,
@ -62,6 +65,7 @@ public static class QuestCatalogServiceCollectionExtensions
knownItemIds, knownItemIds,
knownRecipeIds, knownRecipeIds,
knownEncounterIds, knownEncounterIds,
knownFactionIds,
skillCatalog.ById, skillCatalog.ById,
logger); logger);
}); });

View File

@ -1,12 +1,13 @@
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
/// <summary>Load-time quest row (NEO-113).</summary> /// <summary>Load-time quest row (NEO-113, NEO-134).</summary>
public sealed class QuestDefRow( public sealed class QuestDefRow(
string id, string id,
string displayName, string displayName,
IReadOnlyList<string> prerequisiteQuestIds, IReadOnlyList<string> prerequisiteQuestIds,
IReadOnlyList<QuestStepDefRow> steps, IReadOnlyList<QuestStepDefRow> steps,
QuestRewardBundleRow? completionRewardBundle = null) QuestRewardBundleRow? completionRewardBundle = null,
IReadOnlyList<FactionGateRuleRow>? factionGateRules = null)
{ {
public string Id { get; } = id; public string Id { get; } = id;
@ -17,4 +18,6 @@ public sealed class QuestDefRow(
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps; public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle; public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle;
public IReadOnlyList<FactionGateRuleRow> FactionGateRules { get; } = factionGateRules ?? [];
} }

View File

@ -8,7 +8,7 @@ using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113, NEO-125).</summary> /// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113, NEO-125, NEO-134).</summary>
public static class QuestDefinitionCatalogLoader public static class QuestDefinitionCatalogLoader
{ {
private static JsonSchema? _questObjectiveDefSchema; private static JsonSchema? _questObjectiveDefSchema;
@ -26,6 +26,7 @@ public static class QuestDefinitionCatalogLoader
IReadOnlySet<string> knownItemIds, IReadOnlySet<string> knownItemIds,
IReadOnlySet<string> knownRecipeIds, IReadOnlySet<string> knownRecipeIds,
IReadOnlySet<string> knownEncounterIds, IReadOnlySet<string> knownEncounterIds,
IReadOnlySet<string> knownFactionIds,
IReadOnlyDictionary<string, SkillDefRow> skillDefsById, IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
ILogger logger) ILogger logger)
{ {
@ -214,6 +215,27 @@ public static class QuestDefinitionCatalogLoader
ThrowIfAny(errors); ThrowIfAny(errors);
} }
var factionCrossRef = PrototypeE7M3QuestFactionRules.TryGetFactionCrossRefError(rows, knownFactionIds);
if (factionCrossRef is not null)
{
errors.Add(factionCrossRef);
ThrowIfAny(errors);
}
var e7m3BundleFreeze = PrototypeE7M3QuestFactionRules.TryGetCompletionBundleFreezeError(rows);
if (e7m3BundleFreeze is not null)
{
errors.Add(e7m3BundleFreeze);
ThrowIfAny(errors);
}
var gridContractShape = PrototypeE7M3QuestFactionRules.TryGetGridContractShapeError(rows);
if (gridContractShape is not null)
{
errors.Add(gridContractShape);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information)) if (logger.IsEnabled(LogLevel.Information))
{ {
logger.LogInformation( logger.LogInformation(
@ -306,7 +328,27 @@ public static class QuestDefinitionCatalogLoader
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>(); var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray); var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray);
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject); var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject);
return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle); var factionGateRules = ParseFactionGateRules(rowObj["factionGateRules"] as JsonArray);
return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle, factionGateRules);
}
private static IReadOnlyList<FactionGateRuleRow> ParseFactionGateRules(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<FactionGateRuleRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject ruleObj)
continue;
var factionId = (ruleObj["factionId"] as JsonValue)!.GetValue<string>();
var minStanding = (ruleObj["minStanding"] as JsonValue)!.GetValue<int>();
rows.Add(new FactionGateRuleRow(factionId, minStanding));
}
return rows;
} }
private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj) private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj)
@ -316,7 +358,27 @@ public static class QuestDefinitionCatalogLoader
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray); var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray);
var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray); var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray);
return new QuestRewardBundleRow(itemGrants, skillXpGrants); var reputationGrants = ParseReputationGrantRows(bundleObj["reputationGrants"] as JsonArray);
return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants);
}
private static IReadOnlyList<ReputationGrantRow> ParseReputationGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<ReputationGrantRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var factionId = (grantObj["factionId"] as JsonValue)!.GetValue<string>();
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
rows.Add(new ReputationGrantRow(factionId, amount));
}
return rows;
} }
private static IReadOnlyList<RewardGrantRow> ParseItemGrantRows(JsonArray? array) private static IReadOnlyList<RewardGrantRow> ParseItemGrantRows(JsonArray? array)
@ -451,6 +513,9 @@ public static class QuestDefinitionCatalogLoader
if (_questDefSchema is not null) if (_questDefSchema is not null)
return; return;
var schemaDir = Path.GetDirectoryName(questDefSchemaPath)!;
CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "faction-gate-rule.schema.json"));
CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "reputation-grant-row.schema.json"));
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath); CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath); CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath); CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);

View File

@ -0,0 +1,42 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Dev-only route to seed quest progress for Bruno and manual QA (NEO-137).</summary>
public static class QuestFixtureApi
{
public static WebApplication MapQuestFixtureApi(this WebApplication app)
{
app.MapPost(
"/game/players/{id}/__dev/quest-fixture",
(string id, QuestFixtureRequest? body, IPositionStateStore positions,
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
if (!QuestFixtureOperations.TryApply(
trimmedId,
body,
questRegistry,
progressStore,
timeProvider))
{
return Results.NotFound();
}
return Results.Json(new QuestFixtureResponse { Applied = true });
});
return app;
}
}

View File

@ -0,0 +1,18 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>POST body for <c>POST /game/players/{{id}}/__dev/quest-fixture</c> (NEO-137 Bruno/manual QA).</summary>
public sealed class QuestFixtureRequest
{
public const int CurrentSchemaVersion = 1;
public int SchemaVersion { get; init; }
/// <summary>Quest ids to mark completed via store activate+complete (no reward delivery).</summary>
public IReadOnlyList<string> CompletedQuestIds { get; init; } = [];
}
/// <summary>POST response for quest fixture apply.</summary>
public sealed class QuestFixtureResponse
{
public bool Applied { get; init; }
}

View File

@ -0,0 +1,68 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
public static class QuestFixtureOperations
{
/// <summary>
/// Marks each quest completed via store activate + mark-complete (no reward delivery).
/// Idempotent when a quest is already completed.
/// </summary>
public static bool TryApply(
string playerId,
QuestFixtureRequest body,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
TimeProvider timeProvider)
{
if (body.CompletedQuestIds.Count == 0)
{
return true;
}
if (!progressStore.CanWritePlayer(playerId))
{
return false;
}
foreach (var questId in body.CompletedQuestIds)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
{
return false;
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing) &&
existing.Status == QuestProgressStatus.Completed)
{
continue;
}
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out _))
{
if (!progressStore.TryActivate(playerId, normalizedQuestId, out _))
{
return false;
}
}
if (progressStore.TryMarkComplete(
playerId,
normalizedQuestId,
timeProvider.GetUtcNow(),
out _))
{
continue;
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) &&
afterAttempt.Status == QuestProgressStatus.Completed)
{
continue;
}
return false;
}
return true;
}
}

View File

@ -2,7 +2,16 @@ using NeonSprawl.Server.Game.Encounters;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
/// <summary>Composite completion rewards on <see cref="QuestDefRow.CompletionRewardBundle"/> (NEO-125).</summary> /// <summary>Composite completion rewards on <see cref="QuestDefRow.CompletionRewardBundle"/> (NEO-125, NEO-134).</summary>
public sealed record QuestRewardBundleRow( public sealed record QuestRewardBundleRow(
IReadOnlyList<RewardGrantRow> ItemGrants, IReadOnlyList<RewardGrantRow> ItemGrants,
IReadOnlyList<QuestSkillXpGrantRow> SkillXpGrants); IReadOnlyList<QuestSkillXpGrantRow> SkillXpGrants,
IReadOnlyList<ReputationGrantRow> ReputationGrants)
{
public QuestRewardBundleRow(
IReadOnlyList<RewardGrantRow> itemGrants,
IReadOnlyList<QuestSkillXpGrantRow> skillXpGrants)
: this(itemGrants, skillXpGrants, [])
{
}
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Rewards;
@ -29,6 +30,7 @@ public static class QuestStateOperations
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -61,6 +63,16 @@ public static class QuestStateOperations
} }
} }
var gate = FactionGateOperations.TryEvaluate(
playerId,
definition.FactionGateRules,
standingStore);
if (!gate.Success)
{
// Quest-level deny always surfaces faction_gate_blocked (ops may carry unknown_faction for telemetry).
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked);
}
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot)) if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
{ {
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` --- // --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` ---

View File

@ -16,4 +16,6 @@ public static class QuestStateReasonCodes
public const string NotActive = "not_active"; public const string NotActive = "not_active";
public const string InvalidStepIndex = "invalid_step_index"; public const string InvalidStepIndex = "invalid_step_index";
public const string FactionGateBlocked = "faction_gate_blocked";
} }

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Reputation grant row on <see cref="QuestRewardBundleRow.ReputationGrants"/> (NEO-134).</summary>
public sealed record ReputationGrantRow(string FactionId, int Amount);

View File

@ -154,4 +154,16 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-objective-def.schema.json</c>. /// When unset, resolved as <c>{parent of quests directory}/schemas/quest-objective-def.schema.json</c>.
/// </summary> /// </summary>
public string? QuestObjectiveDefSchemaPath { get; set; } public string? QuestObjectiveDefSchemaPath { get; set; }
/// <summary>
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/factions</c> directory.
/// </summary>
public string? FactionsDirectory { get; set; }
/// <summary>
/// Optional override for <c>faction-def.schema.json</c>.
/// When unset, resolved as <c>{parent of factions directory}/schemas/faction-def.schema.json</c>.
/// </summary>
public string? FactionDefSchemaPath { get; set; }
} }

Some files were not shown because too many files have changed in this diff Show More