Merge pull request #172 from ViPro-Technologies/NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci

NEO-133: E7M3-01 faction catalog, quest gate/rep schemas, and CI
pull/173/head
VinPropane 2026-06-15 20:02:15 -04:00 committed by GitHub
commit f248022c2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1251 additions and 52 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -115,7 +115,10 @@ public class QuestDefinitionCatalogLoaderTests
], ],
"completionRewardBundle": { "completionRewardBundle": {
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }], "itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }] "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }],
"reputationGrants": [
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
]
}, },
"steps": [ "steps": [
{ {
@ -167,6 +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.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
} }
@ -238,7 +273,7 @@ public class QuestDefinitionCatalogLoaderTests
// Act // Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert // Assert
Assert.Equal(4, catalog.DistinctQuestCount); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
} }
[Fact] [Fact]
@ -250,7 +285,7 @@ public class QuestDefinitionCatalogLoaderTests
// Act // Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert // Assert
Assert.Equal(4, catalog.DistinctQuestCount); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather); Assert.NotNull(gather);
@ -267,6 +302,14 @@ public class QuestDefinitionCatalogLoaderTests
Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants); Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants);
Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId); Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId);
Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount); Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount);
Assert.True(catalog.TryGetQuest(PrototypeE7M1QuestCatalogRules.GridContractQuestId, out var gridContract));
Assert.NotNull(gridContract);
Assert.Equal(
[PrototypeE7M1QuestCatalogRules.ChainQuestId],
gridContract!.PrerequisiteQuestIds);
Assert.NotNull(gridContract.CompletionRewardBundle);
Assert.Empty(gridContract.CompletionRewardBundle!.ItemGrants);
Assert.Empty(gridContract.CompletionRewardBundle.SkillXpGrants);
} }
[Fact] [Fact]
@ -436,7 +479,7 @@ public class QuestDefinitionCatalogLoaderTests
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E7M1 expects exactly quest ids", ioe.Message, StringComparison.Ordinal); Assert.Contains("prototype quest roster expects exactly quest ids", ioe.Message, StringComparison.Ordinal);
} }
[Fact] [Fact]
@ -734,7 +777,7 @@ public class QuestDefinitionCatalogLoaderTests
// Assert // Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>(); var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>();
Assert.Equal(4, questCatalog.DistinctQuestCount); Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, questCatalog.DistinctQuestCount);
Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather)); Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather); Assert.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName); Assert.Equal("Intro: Salvage Run", gather!.DisplayName);

View File

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

View File

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

View File

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

View File

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

View File

@ -451,6 +451,9 @@ public static class QuestDefinitionCatalogLoader
if (_questDefSchema is not null) if (_questDefSchema is not null)
return; return;
var schemaDir = Path.GetDirectoryName(questDefSchemaPath)!;
CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "faction-gate-rule.schema.json"));
CatalogSchemaRegistry.GetOrRegisterFromFile(Path.Combine(schemaDir, "reputation-grant-row.schema.json"));
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath); CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath); CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath); CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);