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: >-
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
Game__EnableCombatTargetFixtureApi: "true"
Game__EnableQuestFixtureApi: "true"
run: |
set -euo pipefail
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();
expect(body.schemaVersion).to.equal(1);
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 () {
@ -54,4 +54,19 @@ tests {
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 {
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 {
@ -21,7 +21,7 @@ tests {
expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
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 () {
@ -29,6 +29,7 @@ tests {
const expectedIds = [
"prototype_quest_combat_intro",
"prototype_quest_gather_intro",
"prototype_quest_grid_contract",
"prototype_quest_operator_chain",
"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) |
| [`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) |
| [`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) |
**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 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).

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": {
"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": [
{
@ -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."
},
"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": {
"$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",
"$id": "https://neon-sprawl.local/schemas/quest-reward-bundle.json",
"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",
"additionalProperties": false,
"properties": {
@ -19,6 +19,13 @@
"$ref": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json"
},
"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": [
@ -37,6 +44,14 @@
"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.
- Key contracts: `FactionStanding`, `ReputationDelta`, `FactionGateRule`
- Dependencies: E7.M1
- Dependencies: E7.M1, E7.M2
- Stage target: Pre-production
### 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.
<a id="slice-3---faction-ledger-pre-production"></a>
### Slice 3 - Faction ledger (pre-production)
- Scope: E7.M3 gating at least one faction line and mission set.
- Dependencies: E7.M1
- Dependencies: E7.M1, E7.M2
- Acceptance criteria:
- 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`.
**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)
- 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.
- 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_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).

View File

@ -7,38 +7,82 @@
| **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **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
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
- 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
| Contract | Role |
|----------|------|
| `FactionStanding` | Current rep per faction. |
| `ReputationDelta` | Change events with source attribution. |
| `FactionGateRule` | Unlock conditions for content. |
| `FactionDef` | Faction id, display name, min/max/neutral standing. |
| `FactionStanding` | Current rep per faction for a player. |
| `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
- **E7.M1** — QuestStateMachine.
- **E7.M1** — QuestStateMachine (`TryAccept` gate hook site).
- **E7.M2** — RewardAndUnlockRouter (bundle apply + idempotent delivery store).
## Dependents (by design)
- **E7.M4** — ContractMissionGenerator.
- **E8.M2** — GuildCorpProgressionState.
- **E4.M1** — ZoneGraphAndTravelRules (`TravelRule` faction gates).
## 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
- 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)

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 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 |
### 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 |
| 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 |
| 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)
- 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);
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
@ -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_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"
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"
MASTERY_DIR = REPO_ROOT / "content/mastery"
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"
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
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.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
@ -160,8 +166,9 @@ PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
"contract_handoff_token": 1,
}
# Epic 7 Slice 1 prototype lock (NEO-112): exact quest ids after schema passes.
# Keep in sync with E7.M1 freeze table and future NEO-113 server loader.
# Epic 7 Slice 1 prototype lock (NEO-112): Slice 1 onboarding quest ids (historical subset).
# 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_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:
"""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:
"""Return a human-readable error if Slice 1 contract fails, else None."""
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:
"""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"))
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"))
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"))
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(
[
(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)),
(skill_xp_grant_schema["$id"], Resource.from_contents(skill_xp_grant_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)
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(
*,
quest_files: list[Path],
@ -998,13 +1143,13 @@ def _validate_quest_catalogs(
return errors, seen_ids, id_to_row
def _prototype_e7m1_quest_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E7M1 quest contract fails, else None."""
def _prototype_e7m3_quest_roster_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E7M3 five-quest roster fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E7M1_QUEST_IDS:
if ids != PROTOTYPE_E7M3_QUEST_IDS:
return (
"error: prototype E7M1 expects exactly quest ids "
f"{sorted(PROTOTYPE_E7M1_QUEST_IDS)!r}, got {sorted(ids)!r}"
"error: prototype E7M3 expects exactly quest ids "
f"{sorted(PROTOTYPE_E7M3_QUEST_IDS)!r}, got {sorted(ids)!r}"
)
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:
"""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)
if not isinstance(row, dict):
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]],
) -> str | 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)
if not isinstance(row, dict):
continue
@ -1206,6 +1351,165 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate(
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:
"""Return a human-readable error if Slice 4 contract fails, else None."""
if len(track_skill_ids) != 1:
@ -1556,6 +1860,15 @@ def main() -> int:
if not QUEST_SKILL_XP_GRANT_SCHEMA.is_file():
print(f"error: missing schema {QUEST_SKILL_XP_GRANT_SCHEMA}", file=sys.stderr)
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_validator = Draft202012Validator(skill_schema)
@ -1578,6 +1891,8 @@ def main() -> int:
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
encounter_validator = Draft202012Validator(encounter_schema)
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():
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)
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] = {}
id_to_category: dict[str, 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)
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_files=quest_files,
quest_validator=quest_validator,
@ -1958,9 +2305,9 @@ def main() -> int:
print(f"content validation failed with {quest_errors} error(s)", file=sys.stderr)
return 1
e7m1_quest_err = _prototype_e7m1_quest_gate(quest_seen_ids)
if e7m1_quest_err:
print(e7m1_quest_err, file=sys.stderr)
e7m3_quest_roster_err = _prototype_e7m3_quest_roster_gate(quest_seen_ids)
if e7m3_quest_roster_err:
print(e7m3_quest_roster_err, file=sys.stderr)
return 1
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)
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(
"content OK: "
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(reward_table_files)} reward table 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(seen_ids)} unique skill 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(reward_table_seen_ids)} unique reward table 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(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.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
@ -18,6 +19,8 @@ public sealed class QuestAcceptApiTests
private const string PlayerId = "dev-local-1";
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId;
[Fact]
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
@ -217,6 +220,7 @@ public sealed class QuestAcceptApiTests
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
var standingStore = services.GetRequiredService<IFactionStandingStore>();
var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept(
PlayerId,
@ -231,6 +235,7 @@ public sealed class QuestAcceptApiTests
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
timeProvider).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
@ -289,4 +294,28 @@ public sealed class QuestAcceptApiTests
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
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() =>
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 NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
@ -18,6 +19,8 @@ namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionCatalogLoaderTests
{
private static readonly FrozenSet<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
@ -115,7 +118,10 @@ public class QuestDefinitionCatalogLoaderTests
],
"completionRewardBundle": {
"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": [
{
@ -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.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.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);
}
@ -212,7 +250,8 @@ public class QuestDefinitionCatalogLoaderTests
string defSchemaPath,
string stepSchemaPath,
string objectiveSchemaPath,
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null) =>
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null,
IReadOnlySet<string>? knownFactionIds = null) =>
QuestDefinitionCatalogLoader.Load(
questsDir,
defSchemaPath,
@ -224,6 +263,7 @@ public class QuestDefinitionCatalogLoaderTests
KnownItemIds,
KnownRecipeIds,
KnownEncounterIds,
knownFactionIds ?? KnownFactionIds,
skillDefsById ?? RepoSkillDefs,
NullLogger.Instance);
@ -238,7 +278,7 @@ public class QuestDefinitionCatalogLoaderTests
// Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert
Assert.Equal(4, catalog.DistinctQuestCount);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
}
[Fact]
@ -250,7 +290,7 @@ public class QuestDefinitionCatalogLoaderTests
// Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert
Assert.Equal(4, catalog.DistinctQuestCount);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather);
@ -267,6 +307,14 @@ public class QuestDefinitionCatalogLoaderTests
Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants);
Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId);
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]
@ -436,7 +484,7 @@ public class QuestDefinitionCatalogLoaderTests
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
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]
@ -709,6 +757,88 @@ public class QuestDefinitionCatalogLoaderTests
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]
public void Load_ShouldThrow_WhenMissingSchemaFile()
{
@ -734,7 +864,7 @@ public class QuestDefinitionCatalogLoaderTests
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
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.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);

View File

@ -260,7 +260,7 @@ public class QuestDefinitionRegistryTests
Assert.Equal(GatherIntroId, questId);
Assert.False(unknownNormalized);
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));
foreach (var expectedId in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds)
{

View File

@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionsWorldApiTests
{
[Fact]
public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFourFrozenQuestsInIdOrder()
public async Task GetQuestDefinitions_ShouldReturnSchemaV1_WithFrozenQuestsInIdOrder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
@ -21,7 +21,7 @@ public class QuestDefinitionsWorldApiTests
Assert.NotNull(body);
Assert.Equal(QuestDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Quests);
Assert.Equal(4, body.Quests.Count);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, body.Quests.Count);
Assert.Equal(
PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal).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 NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
@ -302,6 +303,7 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
@ -442,6 +444,7 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
TimeProvider.System);
}
@ -464,5 +467,6 @@ public sealed class QuestObjectiveWiringTests
IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider);
}

View File

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

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
@ -19,6 +20,62 @@ public sealed class QuestStateOperationsTests
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
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]
public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted()
@ -438,6 +495,38 @@ public sealed class QuestStateOperationsTests
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)
{
var total = 0;
@ -505,6 +594,7 @@ public sealed class QuestStateOperationsTests
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
@ -538,6 +628,8 @@ public sealed class QuestStateOperationsTests
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
TimeProvider.System);
}
@ -552,5 +644,7 @@ public sealed class QuestStateOperationsTests
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore ReputationDeltaStore,
TimeProvider TimeProvider);
}

View File

@ -8,6 +8,7 @@ using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
@ -62,6 +63,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"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:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
@ -71,8 +75,10 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
builder.UseSetting("Content:EncountersDirectory", encountersDir);
builder.UseSetting("Content:FactionsDirectory", factionsDir);
builder.UseSetting("Content:QuestsDirectory", questsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
builder.ConfigureTestServices(services =>
{
@ -84,6 +90,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
d.ServiceType == typeof(IFactionStandingStore) ||
d.ServiceType == typeof(IReputationDeltaStore) ||
d.ServiceType == typeof(IPlayerInventoryStore) ||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
@ -106,6 +114,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
services.AddSingleton<IFactionStandingStore, InMemoryFactionStandingStore>();
services.AddSingleton<IReputationDeltaStore, InMemoryReputationDeltaStore>();
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
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>
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>
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;
/// <summary>
/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>,
/// Prototype quest roster + cross-ref gates (NEO-113, NEO-133), mirrored from
/// <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>,
/// 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>
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(
[
"prototype_quest_gather_intro",
"prototype_quest_refine_intro",
"prototype_quest_combat_intro",
"prototype_quest_operator_chain",
"prototype_quest_grid_contract",
],
StringComparer.Ordinal);
/// <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";
/// <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>
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>
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)
{
var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedQuestIds))
{
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 + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}

View File

@ -30,6 +30,9 @@ public static class PrototypeE7M2QuestCatalogRules
[PrototypeE7M1QuestCatalogRules.ChainQuestId] = new(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 50)]),
[PrototypeE7M1QuestCatalogRules.GridContractQuestId] = new(
[],
[]),
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <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.Mastery;
using NeonSprawl.Server.Game.PositionState;
@ -18,7 +19,8 @@ public static class QuestAcceptApi
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
IRewardDeliveryStore deliveryStore, IFactionStandingStore standingStore,
TimeProvider timeProvider) =>
{
if (body is not null &&
body.SchemaVersion != 0 &&
@ -46,6 +48,7 @@ public static class QuestAcceptApi
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
timeProvider);
return Results.Json(MapResponse(trimmedId, deliveryStore, result));

View File

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

View File

@ -1,12 +1,13 @@
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(
string id,
string displayName,
IReadOnlyList<string> prerequisiteQuestIds,
IReadOnlyList<QuestStepDefRow> steps,
QuestRewardBundleRow? completionRewardBundle = null)
QuestRewardBundleRow? completionRewardBundle = null,
IReadOnlyList<FactionGateRuleRow>? factionGateRules = null)
{
public string Id { get; } = id;
@ -17,4 +18,6 @@ public sealed class QuestDefRow(
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
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;
/// <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
{
private static JsonSchema? _questObjectiveDefSchema;
@ -26,6 +26,7 @@ public static class QuestDefinitionCatalogLoader
IReadOnlySet<string> knownItemIds,
IReadOnlySet<string> knownRecipeIds,
IReadOnlySet<string> knownEncounterIds,
IReadOnlySet<string> knownFactionIds,
IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
ILogger logger)
{
@ -214,6 +215,27 @@ public static class QuestDefinitionCatalogLoader
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))
{
logger.LogInformation(
@ -306,7 +328,27 @@ public static class QuestDefinitionCatalogLoader
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray);
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)
@ -316,7 +358,27 @@ public static class QuestDefinitionCatalogLoader
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] 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)
@ -451,6 +513,9 @@ public static class QuestDefinitionCatalogLoader
if (_questDefSchema is not null)
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(questSkillXpGrantSchemaPath);
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;
/// <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(
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.Mastery;
using NeonSprawl.Server.Game.Rewards;
@ -29,6 +30,7 @@ public static class QuestStateOperations
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
TimeProvider timeProvider)
{
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))
{
// --- 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 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>.
/// </summary>
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