diff --git a/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru
index abfc4c8..676509e 100644
--- a/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru
+++ b/bruno/neon-sprawl-server/quest-definitions/Get quest definitions.bru
@@ -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,
+ });
+ });
}
diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru
index 1f17c1d..8c3a811 100644
--- a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru
+++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru
@@ -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",
];
diff --git a/content/README.md b/content/README.md
index cdb789b..51f0360 100644
--- a/content/README.md
+++ b/content/README.md
@@ -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).
diff --git a/content/factions/prototype_factions.json b/content/factions/prototype_factions.json
new file mode 100644
index 0000000..bb06737
--- /dev/null
+++ b/content/factions/prototype_factions.json
@@ -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
+ }
+ ]
+}
diff --git a/content/quests/prototype_quests.json b/content/quests/prototype_quests.json
index 4ebae45..32ae65f 100644
--- a/content/quests/prototype_quests.json
+++ b/content/quests/prototype_quests.json
@@ -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
+ }
+ ]
+ }
+ ]
}
]
}
diff --git a/content/schemas/faction-def.schema.json b/content/schemas/faction-def.schema.json
new file mode 100644
index 0000000..dd9ab84
--- /dev/null
+++ b/content/schemas/faction-def.schema.json
@@ -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."
+ }
+ }
+}
diff --git a/content/schemas/faction-gate-rule.schema.json b/content/schemas/faction-gate-rule.schema.json
new file mode 100644
index 0000000..20b0fd5
--- /dev/null
+++ b/content/schemas/faction-gate-rule.schema.json
@@ -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."
+ }
+ }
+}
diff --git a/content/schemas/quest-def.schema.json b/content/schemas/quest-def.schema.json
index 30ccfbd..f1515c4 100644
--- a/content/schemas/quest-def.schema.json
+++ b/content/schemas/quest-def.schema.json
@@ -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."
}
}
}
diff --git a/content/schemas/quest-reward-bundle.schema.json b/content/schemas/quest-reward-bundle.schema.json
index ae2aa8d..34bc6fc 100644
--- a/content/schemas/quest-reward-bundle.schema.json
+++ b/content/schemas/quest-reward-bundle.schema.json
@@ -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
+ }
+ }
}
]
}
diff --git a/content/schemas/reputation-grant-row.schema.json b/content/schemas/reputation-grant-row.schema.json
new file mode 100644
index 0000000..6dd2a15
--- /dev/null
+++ b/content/schemas/reputation-grant-row.schema.json
@@ -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."
+ }
+ }
+}
diff --git a/docs/decomposition/epics/epic_07_quest_faction.md b/docs/decomposition/epics/epic_07_quest_faction.md
index 01bcdd0..99f2c5d 100644
--- a/docs/decomposition/epics/epic_07_quest_faction.md
+++ b/docs/decomposition/epics/epic_07_quest_faction.md
@@ -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.
+
+
### 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.
diff --git a/docs/decomposition/modules/E7_M1_QuestStateMachine.md b/docs/decomposition/modules/E7_M1_QuestStateMachine.md
index c931843..9e604f1 100644
--- a/docs/decomposition/modules/E7_M1_QuestStateMachine.md
+++ b/docs/decomposition/modules/E7_M1_QuestStateMachine.md
@@ -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).
diff --git a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md
index 7a1bf29..087f9de 100644
--- a/docs/decomposition/modules/E7_M3_FactionReputationLedger.md
+++ b/docs/decomposition/modules/E7_M3_FactionReputationLedger.md
@@ -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)): faction schemas + `prototype_factions.json` + quest gate/rep extensions + CI. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-02** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) → **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)
diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md
index 909193a..b89c922 100644
--- a/docs/decomposition/modules/documentation_and_implementation_alignment.md
+++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md
@@ -61,6 +61,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 |
| E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`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.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 |
| E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 |
+| E7.M3 | In Progress | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions (`factionGateRules`, `reputationGrants`), five-quest CI gates, minimal server roster parity. **E7M3-02+** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**; client capstone [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 |
---
diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md
index 1da837b..a2b12ea 100644
--- a/docs/decomposition/modules/module_dependency_register.md
+++ b/docs/decomposition/modules/module_dependency_register.md
@@ -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
diff --git a/docs/plans/E7M1-prototype-backlog.md b/docs/plans/E7M1-prototype-backlog.md
index 159439a..af3ba24 100644
--- a/docs/plans/E7M1-prototype-backlog.md
+++ b/docs/plans/E7M1-prototype-backlog.md
@@ -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.
---
diff --git a/docs/plans/E7M2-prototype-backlog.md b/docs/plans/E7M2-prototype-backlog.md
index 2b64a24..ccc356c 100644
--- a/docs/plans/E7M2-prototype-backlog.md
+++ b/docs/plans/E7M2-prototype-backlog.md
@@ -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.
---
diff --git a/docs/plans/E7M3-pre-production-backlog.md b/docs/plans/E7M3-pre-production-backlog.md
new file mode 100644
index 0000000..25f3fb5
--- /dev/null
+++ b/docs/plans/E7M3-pre-production-backlog.md
@@ -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 **1–11** 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-133–NEO-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)
diff --git a/docs/plans/NEO-133-implementation-plan.md b/docs/plans/NEO-133-implementation-plan.md
new file mode 100644
index 0000000..07eb56f
--- /dev/null
+++ b/docs/plans/NEO-133-implementation-plan.md
@@ -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-135–137 | Faction standing store, delta audit, gate evaluation on accept |
+| NEO-138 | `RewardRouterOperations` reputation apply + idempotent delivery |
+| NEO-139–143 | 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.
diff --git a/docs/reviews/2026-06-15-NEO-133.md b/docs/reviews/2026-06-15-NEO-133.md
new file mode 100644
index 0000000..1f53d9a
--- /dev/null
+++ b/docs/reviews/2026-06-15-NEO-133.md
@@ -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-134–NEO-138. Grid contract can be accepted without standing check until NEO-137/NEO-138.
diff --git a/scripts/validate_content.py b/scripts/validate_content.py
index fd578de..2e48a60 100644
--- a/scripts/validate_content.py
+++ b/scripts/validate_content.py
@@ -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)"
)
diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs
index 78f953f..26f0911 100644
--- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs
@@ -35,4 +35,12 @@ 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"));
}
diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs
index 37ead2a..8dc20c6 100644
--- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs
@@ -115,7 +115,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 +170,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 +221,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);
}
@@ -238,7 +273,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 +285,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 +302,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 +479,7 @@ public class QuestDefinitionCatalogLoaderTests
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType(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]
@@ -734,7 +777,7 @@ public class QuestDefinitionCatalogLoaderTests
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var questCatalog = factory.Services.GetRequiredService();
- 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);
diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs
index 671f6f6..b48685c 100644
--- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionRegistryTests.cs
@@ -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)
{
diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs
index bb1c333..40ee9ec 100644
--- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionsWorldApiTests.cs
@@ -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());
diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs
index 0c168e0..5b93314 100644
--- a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs
+++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs
@@ -3,26 +3,31 @@ using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Quests;
///
-/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from
-/// scripts/validate_content.py PROTOTYPE_E7M1_QUEST_IDS,
+/// Prototype quest roster + cross-ref gates (NEO-113, NEO-133), mirrored from
+/// scripts/validate_content.py PROTOTYPE_E7M3_QUEST_IDS (five ids),
/// PROTOTYPE_E7M1_CHAIN_QUEST_ID, PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID,
-/// and _prototype_e7m1_* gate functions.
+/// and _prototype_e7m1_* / _prototype_e7m3_* gate functions.
+/// Class name retained for NEO-113 call sites; rename to PrototypeE7M3QuestCatalogRules planned in NEO-134.
///
public static class PrototypeE7M1QuestCatalogRules
{
- /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_QUEST_IDS.
+ /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_QUEST_IDS (five quests).
public static readonly FrozenSet 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);
/// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_QUEST_ID.
public const string ChainQuestId = "prototype_quest_operator_chain";
+ /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID.
+ public const string GridContractQuestId = "prototype_quest_grid_contract";
+
/// First frozen onboarding quest id (gather intro).
public const string GatherIntroQuestId = "prototype_quest_gather_intro";
@@ -41,14 +46,14 @@ public static class PrototypeE7M1QuestCatalogRules
/// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID.
public const string ChainTerminalItemId = "contract_handoff_token";
- /// Returns a human-readable error if the E7M1 quest contract fails, otherwise .
+ /// Returns a human-readable error if the prototype quest roster fails, otherwise .
public static string? TryGetE7M1GateError(IReadOnlyDictionary 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 + "'"))}]";
}
diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs
index 959425f..321638f 100644
--- a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs
+++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs
@@ -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);
/// Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise .
diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs
index 5d80031..1e456d1 100644
--- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs
+++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs
@@ -451,6 +451,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);