NEO-133: land faction catalog, quest gate/rep schemas, and CI gates

Add two prototype factions, extend quest bundles with reputationGrants
and factionGateRules, freeze five-quest roster in validate_content.py,
and sync minimal server quest roster parity for green startup tests.
pull/172/head
VinPropane 2026-06-14 14:54:20 -04:00
parent 718346163f
commit 0948d9d4a8
20 changed files with 592 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M3 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Pre-production |
| **Status** | In Progress — Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-01** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) (decomposed). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
| **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
@ -77,7 +77,9 @@ Epic 7 **Slice 3** — at least one faction quest line with rep grant + gated ac
| **`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.
**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

File diff suppressed because one or more lines are too long

View File

@ -108,9 +108,9 @@ Accepting **`prototype_quest_grid_contract`** before operator-chain completion o
**Acceptance criteria**
- [ ] PR gate validates faction JSON and extended quest JSON.
- [ ] Exactly two prototype faction ids; fifth quest id present with gate + objective cross-refs.
- [ ] Invalid faction refs in gates or grants fail CI.
- [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).

View File

@ -101,3 +101,11 @@ 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

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

View File

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

View File

@ -115,7 +115,10 @@ public class QuestDefinitionCatalogLoaderTests
],
"completionRewardBundle": {
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }],
"reputationGrants": [
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
]
},
"steps": [
{
@ -167,6 +170,36 @@ public class QuestDefinitionCatalogLoaderTests
]
}
]
},
{
"id": "prototype_quest_grid_contract",
"displayName": "Grid Contract",
"prerequisiteQuestIds": ["prototype_quest_operator_chain"],
"factionGateRules": [
{
"factionId": "prototype_faction_grid_operators",
"minStanding": 15
}
],
"completionRewardBundle": {
"reputationGrants": [
{ "factionId": "prototype_faction_rust_collective", "amount": 10 }
]
},
"steps": [
{
"id": "grid_contract_step_kit",
"displayName": "Deliver survey drone kit",
"objectives": [
{
"id": "grid_contract_obj_kit",
"kind": "inventory_has_item",
"itemId": "survey_drone_kit",
"quantity": 1
}
]
}
]
}
]
}
@ -188,6 +221,8 @@ public class QuestDefinitionCatalogLoaderTests
File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
}
@ -238,7 +273,7 @@ public class QuestDefinitionCatalogLoaderTests
// Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert
Assert.Equal(4, catalog.DistinctQuestCount);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
}
[Fact]
@ -250,7 +285,7 @@ public class QuestDefinitionCatalogLoaderTests
// Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert
Assert.Equal(4, catalog.DistinctQuestCount);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather);
@ -734,7 +769,7 @@ public class QuestDefinitionCatalogLoaderTests
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>();
Assert.Equal(4, questCatalog.DistinctQuestCount);
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, questCatalog.DistinctQuestCount);
Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);

View File

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

View File

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

View File

@ -17,12 +17,16 @@ public static class PrototypeE7M1QuestCatalogRules
"prototype_quest_refine_intro",
"prototype_quest_combat_intro",
"prototype_quest_operator_chain",
"prototype_quest_grid_contract",
],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary>
public const string ChainQuestId = "prototype_quest_operator_chain";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID</c>.</summary>
public const string GridContractQuestId = "prototype_quest_grid_contract";
/// <summary>First frozen onboarding quest id (gather intro).</summary>
public const string GatherIntroQuestId = "prototype_quest_gather_intro";

View File

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

View File

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