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 CIpull/173/head
commit
f248022c2d
|
|
@ -17,7 +17,7 @@ tests {
|
|||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.quests).to.be.an("array");
|
||||
expect(body.quests.length).to.equal(4);
|
||||
expect(body.quests.length).to.equal(5);
|
||||
});
|
||||
|
||||
test("quests are ascending by id (ordinal)", function () {
|
||||
|
|
@ -54,4 +54,19 @@ tests {
|
|||
quantity: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("prototype_quest_grid_contract has operator-chain prereq and kit hand-in objective", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.quests.find((x) => x.id === "prototype_quest_grid_contract");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Grid Contract");
|
||||
expect(row.prerequisiteQuestIds).to.eql(["prototype_quest_operator_chain"]);
|
||||
expect(row.steps).to.have.length(1);
|
||||
expect(row.steps[0].objectives[0]).to.eql({
|
||||
id: "grid_contract_obj_kit",
|
||||
kind: "inventory_has_item",
|
||||
itemId: "survey_drone_kit",
|
||||
quantity: 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-119: fresh dev-local-1 row before any quest accept — all four catalog quests not_started.
|
||||
NEO-119: fresh dev-local-1 row before any quest accept — all five catalog quests not_started.
|
||||
}
|
||||
|
||||
get {
|
||||
|
|
@ -21,7 +21,7 @@ tests {
|
|||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
|
||||
expect(body.quests).to.be.an("array");
|
||||
expect(body.quests.length).to.equal(4);
|
||||
expect(body.quests.length).to.equal(5);
|
||||
});
|
||||
|
||||
test("all prototype quests are not_started with empty counters", function () {
|
||||
|
|
@ -29,6 +29,7 @@ tests {
|
|||
const expectedIds = [
|
||||
"prototype_quest_combat_intro",
|
||||
"prototype_quest_gather_intro",
|
||||
"prototype_quest_grid_contract",
|
||||
"prototype_quest_operator_chain",
|
||||
"prototype_quest_refine_intro",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
|
||||
- Responsibility: Track faction standing and gate faction-specific contracts/content.
|
||||
- Key contracts: `FactionStanding`, `ReputationDelta`, `FactionGateRule`
|
||||
- Dependencies: E7.M1
|
||||
- Dependencies: E7.M1, E7.M2
|
||||
- Stage target: Pre-production
|
||||
|
||||
### E7.M4 - ContractMissionGenerator
|
||||
|
|
@ -68,14 +68,19 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
|
||||
**Linear backlog:** [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) — [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). Verify quest completion rewards in **Godot**, not Bruno-only. Encounter loot ([E5.M3](../modules/E5_M3_EncounterAndRewardTables.md)) unchanged in Slice 2.
|
||||
|
||||
<a id="slice-3---faction-ledger-pre-production"></a>
|
||||
|
||||
### Slice 3 - Faction ledger (pre-production)
|
||||
|
||||
- Scope: E7.M3 gating at least one faction line and mission set.
|
||||
- Dependencies: E7.M1
|
||||
- Dependencies: E7.M1, E7.M2
|
||||
- Acceptance criteria:
|
||||
- Reputation deltas auditable; gates fail closed on tamper.
|
||||
- Player earns Grid Operators rep from operator chain; faction-gated quest accept denied below threshold.
|
||||
- Telemetry hooks: `reputation_delta`, `faction_gate_blocked`.
|
||||
|
||||
**Linear backlog:** [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) — [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Verify faction standing + gate deny in **Godot**, not Bruno-only.
|
||||
|
||||
### Slice 4 - Contract generator (pre-production)
|
||||
|
||||
- Scope: E7.M4 emitting repeatable contracts from templates by zone difficulty.
|
||||
|
|
@ -97,4 +102,5 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
|
||||
- Prototype quest counts and integrated chain meet master plan minimums **in the Godot client** (Slice 1 capstone [NEO-123](../../manual-qa/NEO-123.md)), not Bruno-only.
|
||||
- Slice 2 quest **completion** rewards (items + skill XP) visible in Godot with idempotent delivery (capstone [NEO-132](../../manual-qa/NEO-132.md)), not Bruno-only.
|
||||
- Pre-production adds faction + contracts with validation.
|
||||
- Slice 3 faction rep + gated quest line visible in Godot (capstone [NEO-143](../../manual-qa/NEO-143.md)), not Bruno-only.
|
||||
- Pre-production adds contracts (E7.M4) with validation beyond Slice 3.
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
|
|||
| **`prototype_quest_combat_intro`** | Intro: Clear the Pocket | *(none)* | 1× **`encounter_complete`**: **`prototype_combat_pocket`** |
|
||||
| **`prototype_quest_operator_chain`** | Operator Chain | **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`** | 4 steps: gather **`scrap_metal_bulk` ×5** → craft **`refine_scrap_standard`** → craft **`make_field_stim_mk0`** → **`inventory_has_item`** **`contract_handoff_token` ×1** |
|
||||
|
||||
**CI enforcement (NEO-112):** `scripts/validate_content.py` requires **exactly** these four quest ids; objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; chain quest terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01).
|
||||
**CI enforcement (NEO-112):** `scripts/validate_content.py` still enforces the **four Slice 1 quest ids** in the freeze table above (prerequisite graph, objective cross-refs, chain terminal step). **Roster allowlist** expanded to **five** quests under [E7.M3](E7_M3_FactionReputationLedger.md) (`PROTOTYPE_E7M3_QUEST_IDS`, NEO-133) — adds **`prototype_quest_grid_contract`**. Shared gates: objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; operator chain terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01).
|
||||
|
||||
**Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113).
|
||||
|
||||
|
|
|
|||
|
|
@ -7,38 +7,82 @@
|
|||
| **Module ID** | E7.M3 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **Stage target** | Pre-production |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | In Progress — **E7M3-01 catalog landed** ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)): faction schemas + `prototype_factions.json` + quest gate/rep extensions + CI. Slice 3 backlog [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md): **E7M3-02** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) → **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. |
|
||||
| **Linear** | Label **`E7.M3`** · [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md) |
|
||||
|
||||
## Purpose
|
||||
|
||||
Faction standing, reputation deltas, and `FactionGateRule` evaluation for content and contracts.
|
||||
Character-wide **faction standing**, auditable **reputation deltas**, and **`FactionGateRule`** evaluation for quest accept (and future zone/travel gates via [E4.M1](E4_M1_ZoneGraphAndTravelRules.md)). Rep grants flow through [E7.M2](E7_M2_RewardAndUnlockRouter.md) **`completionRewardBundle`** idempotency.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Persist auditable `FactionStanding`; apply `ReputationDelta`; fail closed on tamper for gates.
|
||||
- Persist **`FactionStanding`** per player per faction; apply **`ReputationDelta`** with clamp and audit log.
|
||||
- Evaluate **`FactionGateRule`** on quest accept — fail closed on unknown faction or tampered rules (startup validation).
|
||||
- Emit telemetry hook sites: **`reputation_delta`**, **`faction_gate_blocked`**.
|
||||
|
||||
## Key contracts
|
||||
|
||||
| Contract | Role |
|
||||
|----------|------|
|
||||
| `FactionStanding` | Current rep per faction. |
|
||||
| `ReputationDelta` | Change events with source attribution. |
|
||||
| `FactionGateRule` | Unlock conditions for content. |
|
||||
| `FactionDef` | Faction id, display name, min/max/neutral standing. |
|
||||
| `FactionStanding` | Current rep per faction for a player. |
|
||||
| `ReputationDelta` | Signed change with source attribution (quest id, etc.). |
|
||||
| `FactionGateRule` | Minimum standing required (e.g. on `QuestDef`). |
|
||||
| `reputationGrants` | Rows on `QuestRewardBundle` — `factionId` + `amount`. |
|
||||
|
||||
## Module dependencies
|
||||
|
||||
- **E7.M1** — QuestStateMachine.
|
||||
- **E7.M1** — QuestStateMachine (`TryAccept` gate hook site).
|
||||
- **E7.M2** — RewardAndUnlockRouter (bundle apply + idempotent delivery store).
|
||||
|
||||
## Dependents (by design)
|
||||
|
||||
- **E7.M4** — ContractMissionGenerator.
|
||||
- **E8.M2** — GuildCorpProgressionState.
|
||||
- **E4.M1** — ZoneGraphAndTravelRules (`TravelRule` faction gates).
|
||||
|
||||
## Related implementation slices
|
||||
|
||||
Epic 7 **Slice 3** — `reputation_delta`, `faction_gate_blocked`.
|
||||
Epic 7 **Slice 3** — at least one faction quest line with rep grant + gated accept; auditable deltas; telemetry **`reputation_delta`**, **`faction_gate_blocked`**. Backlog: [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md).
|
||||
|
||||
## Linear backlog (decomposed)
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E7M3-01 | [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) |
|
||||
| E7M3-02 | [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) |
|
||||
| E7M3-03 | [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) |
|
||||
| E7M3-04 | [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) |
|
||||
| E7M3-05 | [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) |
|
||||
| E7M3-06 | [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) |
|
||||
| E7M3-07 | [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) |
|
||||
| E7M3-08 | [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) |
|
||||
| E7M3-09 | [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) |
|
||||
| E7M3-10 | [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) |
|
||||
| E7M3-11 | [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) |
|
||||
|
||||
## Prototype Slice 3 freeze (E7M3-01)
|
||||
|
||||
### Faction catalog
|
||||
|
||||
| `FactionDef.id` | `displayName` | Standing band |
|
||||
|-----------------|---------------|---------------|
|
||||
| **`prototype_faction_grid_operators`** | Grid Operators | -100…100 (neutral 0) |
|
||||
| **`prototype_faction_rust_collective`** | Rust Collective | -100…100 (neutral 0) |
|
||||
|
||||
### Quest content changes
|
||||
|
||||
| `QuestDef.id` | Rep / gate |
|
||||
|---------------|------------|
|
||||
| **`prototype_quest_operator_chain`** | **`completionRewardBundle.reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (existing item + skill XP unchanged) |
|
||||
| **`prototype_quest_grid_contract`** *(new)* | **`prerequisiteQuestIds`**: operator chain; **`factionGateRules`**: Grid Operators **`minStanding` 15**; objective **`inventory_has_item`** **`survey_drone_kit` ×1**; completion rep **Rust Collective +10** |
|
||||
|
||||
**Roster:** five quests total (four E7.M1 onboarding + one faction line). E7.M1 four-quest freeze expands under E7M3 CI gates (`PROTOTYPE_E7M3_QUEST_IDS` in `scripts/validate_content.py`).
|
||||
|
||||
**CI enforcement (NEO-133):** `scripts/validate_content.py` validates `content/factions/*_factions.json`, five-quest roster, faction cross-refs on gates and `reputationGrants`, E7M3 completion bundle freeze, and grid-contract shape.
|
||||
|
||||
## Source anchors
|
||||
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7, faction reputation.
|
||||
- Game design: [skills.md — Rep / story](../../../docs/game-design/skills.md) (character-wide faction rep).
|
||||
- [Module dependency register](module_dependency_register.md)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -113,7 +113,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Ready |
|
||||
|
||||
**E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**; label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. **E7M2-09 / NEO-132** client capstone landed — [`NEO-132` manual QA](../../manual-qa/NEO-132.md), [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132), plan [NEO-132](../../plans/NEO-132-implementation-plan.md). Epic 7 Slice 2 client capstone complete.
|
||||
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
|
||||
| E7.M3 | FactionReputationLedger | E7.M1, E7.M2 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | In Progress |
|
||||
|
||||
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143); label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. Client capstone **E7M3-11** [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143).
|
||||
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
|
||||
|
||||
### Epic 8 — Social / Guild
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
| 11 | **E7M1-11** | client | E7M1-08, E7M1-09 |
|
||||
| 12 | **E7M1-12** | client | E7M1-11 |
|
||||
|
||||
**Downstream (separate modules):** [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) Slice 2 backlog [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md) ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)–[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)); [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) faction ledger (pre-production); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
|
||||
**Downstream (separate modules):** [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) Slice 3 backlog [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)–[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Working backlog for **Epic 7 — Slice 2** ([reward and unlock routing](../decom
|
|||
| 8 | **E7M2-08** | client | E7M2-06 |
|
||||
| 9 | **E7M2-09** | client | E7M2-08 |
|
||||
|
||||
**Downstream (separate modules):** [E6.M4](../decomposition/modules/E6_M4_RewardParityEnforcer.md) lint against bundles; [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) reputation grants (pre-production); E5.M3 **`EncounterCompleteEvent`** full router consumer (optional follow-on).
|
||||
**Downstream (separate modules):** [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) Slice 3 backlog [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)–[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,379 @@
|
|||
# E7.M3 — Pre-production story backlog (FactionReputationLedger)
|
||||
|
||||
Working backlog for **Epic 7 — Slice 3** ([faction ledger](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production)). Decomposition and contracts: [E7.M3 — FactionReputationLedger](../decomposition/modules/E7_M3_FactionReputationLedger.md).
|
||||
|
||||
**Full-stack policy:** Epics deliver **playable game features**. Every **player-visible** story has a **`client`** Linear issue created in **this same decomposition pass** — not an undocumented follow-up. Reference: [E7M2 (paired server+client)](../plans/E7M2-prototype-backlog.md), [E7M1 (quest HUD + capstone)](../plans/E7M1-prototype-backlog.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E7.M3`**; add **`server`** or **`client`** + **`Story`** as listed.
|
||||
|
||||
**Precursor (do not re-scope):** [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) **Ready** — quest accept/advance/complete ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)–[NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)). [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **Ready** — idempotent **`completionRewardBundle`** delivery ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)–[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)). Slice 3 **extends** E7.M2 bundles with **`reputationGrants`**; does not migrate encounter loot or duplicate quest completion grants.
|
||||
|
||||
**Upstream (must be landed):** E7.M1 quest state + accept gates; E7.M2 **`RewardRouterOperations`** + **`IRewardDeliveryStore`**; [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md) solo idempotency.
|
||||
|
||||
**Prototype faction spine (frozen in E7M3-01):** **two** frozen `FactionDef` ids; **one** reputation grant on **`prototype_quest_operator_chain`**; **one** faction-gated follow-on quest **`prototype_quest_grid_contract`**.
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts.
|
||||
|
||||
| Slug | Layer | Linear |
|
||||
|------|-------|--------|
|
||||
| E7M3-01 | server | [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) |
|
||||
| E7M3-02 | server | [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134) |
|
||||
| E7M3-03 | server | [NEO-135](https://linear.app/neon-sprawl/issue/NEO-135) |
|
||||
| E7M3-04 | server | [NEO-136](https://linear.app/neon-sprawl/issue/NEO-136) |
|
||||
| E7M3-05 | server | [NEO-137](https://linear.app/neon-sprawl/issue/NEO-137) |
|
||||
| E7M3-06 | server | [NEO-138](https://linear.app/neon-sprawl/issue/NEO-138) |
|
||||
| E7M3-07 | server | [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139) |
|
||||
| E7M3-08 | server | [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140) |
|
||||
| E7M3-09 | server | [NEO-141](https://linear.app/neon-sprawl/issue/NEO-141) |
|
||||
| E7M3-10 | client | [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) |
|
||||
| E7M3-11 | client | [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) |
|
||||
|
||||
**Dependency graph in Linear:** E7M3-02 blocked by E7M3-01. E7M3-03 blocked by E7M3-02. E7M3-04 blocked by E7M3-03. E7M3-05 blocked by E7M3-02 and E7M3-03. E7M3-06 blocked by E7M3-04. E7M3-07 blocked by E7M3-03. E7M3-08 blocked by E7M3-06 and E7M3-07. E7M3-09 blocked by E7M3-04 and E7M3-05. E7M3-10 blocked by E7M3-07 and E7M3-08. E7M3-11 blocked by E7M3-10.
|
||||
|
||||
**Board order:** estimates **1–11** matching slug order (E7M3-01 = 1 … E7M3-11 = 11). On the Epic 7 project board, sort by **Estimate** (ascending).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Layer | Depends on |
|
||||
|-------|------|-------|------------|
|
||||
| 1 | **E7M3-01** | server | E7.M1/E7.M2 content frozen |
|
||||
| 2 | **E7M3-02** | server | E7M3-01 |
|
||||
| 3 | **E7M3-03** | server | E7M3-02 |
|
||||
| 4 | **E7M3-04** | server | E7M3-03 |
|
||||
| 5 | **E7M3-05** | server | E7M3-02, E7M3-03 |
|
||||
| 6 | **E7M3-06** | server | E7M3-04 |
|
||||
| 7 | **E7M3-07** | server | E7M3-03 |
|
||||
| 8 | **E7M3-08** | server | E7M3-06, E7M3-07 |
|
||||
| 9 | **E7M3-09** | server | E7M3-04, E7M3-05 |
|
||||
| 10 | **E7M3-10** | client | E7M3-07, E7M3-08 |
|
||||
| 11 | **E7M3-11** | client | E7M3-10 |
|
||||
|
||||
**Downstream (separate modules):** [E7.M4](../decomposition/modules/E7_M4_ContractMissionGenerator.md) contract generator; [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) **`TravelRule`** faction gates; [E8.M2](../decomposition/modules/E8_M2_GuildCorpProgressionState.md) guild/corp rep coupling.
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| Faction count | **Two** frozen ids (corp + street) | Readable contrast; Slice 3 AC needs **one** gated line — corp line is flagship |
|
||||
| Standing range | **-100…100**, neutral **0** | Standard RPG band; clamp on apply |
|
||||
| Rep grants | **`reputationGrants`** rows on **`completionRewardBundle`** | Reuse E7.M2 idempotent delivery path; audit via delivery store |
|
||||
| Gate evaluation | **`TryAccept`** only (quest accept) | E4.M1 travel gates deferred; epic Slice 3 is quest/mission gating |
|
||||
| Gate deny | **`faction_gate_blocked`** reason code | Epic telemetry vocabulary |
|
||||
| Fail-closed | Unknown **`factionId`** in grant or gate → deny | Tamper / bad content cannot silently pass |
|
||||
| Delta audit | Append-only **`ReputationDelta`** log per apply | Slice 3 AC: auditable deltas |
|
||||
| Idempotency | Rep grants ride **`{playerId}:quest_complete:{questId}`** delivery key | Same as E7.M2; replay does not double-apply standing |
|
||||
| Party mode | **Solo only** | E8.M1 not landed |
|
||||
| Quest roster | **Five** quests (four E7.M1 + one faction line) | Operator chain grants rep; gated quest proves accept gate |
|
||||
| HUD fidelity | Prototype labels / debug panels | NEO-122 / NEO-131 precedent |
|
||||
|
||||
### Prototype faction freeze (E7M3-01)
|
||||
|
||||
| `FactionDef.id` | `displayName` | `minStanding` | `maxStanding` | `neutralStanding` |
|
||||
|-----------------|---------------|---------------|---------------|---------------------|
|
||||
| **`prototype_faction_grid_operators`** | Grid Operators | -100 | 100 | 0 |
|
||||
| **`prototype_faction_rust_collective`** | Rust Collective | -100 | 100 | 0 |
|
||||
|
||||
### Prototype reputation + gate freeze (E7M3-01)
|
||||
|
||||
| Quest id | Change | Detail |
|
||||
|----------|--------|--------|
|
||||
| **`prototype_quest_operator_chain`** | Extend **`completionRewardBundle`** | Add **`reputationGrants`**: **`prototype_faction_grid_operators`** **+15** (item + skill XP rows unchanged) |
|
||||
| **`prototype_quest_grid_contract`** | **New** quest | **`prerequisiteQuestIds`**: **`prototype_quest_operator_chain`**; **`factionGateRules`**: **`prototype_faction_grid_operators`** **`minStanding` 15**; 1 step **`inventory_has_item`** **`survey_drone_kit` ×1**; completion bundle: **`prototype_faction_rust_collective`** **+10** rep only |
|
||||
|
||||
Accepting **`prototype_quest_grid_contract`** before operator-chain completion or before **15** Grid Operators standing returns **`faction_gate_blocked`** or **`prerequisite_incomplete`** respectively.
|
||||
|
||||
---
|
||||
|
||||
### E7M3-01 — FactionDef catalog + quest gate/rep schemas + CI
|
||||
|
||||
**Goal:** Lock faction content shape, extend quest schemas for gates + rep grants, and CI validation before server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/faction-def.schema.json`, `content/schemas/faction-gate-rule.schema.json`, `content/schemas/reputation-grant-row.schema.json`.
|
||||
- Extend `content/schemas/quest-def.schema.json` with optional **`factionGateRules`** array.
|
||||
- Extend `content/schemas/quest-reward-bundle.schema.json` with optional **`reputationGrants`** array.
|
||||
- `content/factions/prototype_factions.json` — two frozen faction rows.
|
||||
- Update `content/quests/prototype_quests.json` — operator-chain rep grant + fifth quest per freeze table.
|
||||
- `scripts/validate_content.py`: faction schema validation, quest gate cross-refs, rep grant cross-refs, five-quest allowlist (four E7.M1 + **`prototype_quest_grid_contract`**), E7M2 bundle freeze updated for operator-chain rep row.
|
||||
- Designer note in [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) + `content/README.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, stores, operations, HTTP, Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates faction JSON and extended quest JSON.
|
||||
- [x] Exactly two prototype faction ids; fifth quest id present with gate + objective cross-refs.
|
||||
- [x] Invalid faction refs in gates or grants fail CI.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-02 — Server faction catalog load (fail-fast)
|
||||
|
||||
**Goal:** Disk → host: startup load of `content/factions/*_factions.json` with CI-parity validation.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Loader + registry types under `server/NeonSprawl.Server/Game/Factions/` (or `Game/Reputation/`).
|
||||
- **`IFactionDefinitionRegistry`** + DI registration.
|
||||
- Cross-check quest **`factionGateRules`** / bundle **`reputationGrants`** faction ids at startup (after quest catalog load).
|
||||
- Unit tests (AAA) for happy path and broken faction refs.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Per-player standing store, apply logic, HTTP.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Host exits on invalid faction catalog or broken quest faction refs at startup.
|
||||
- [ ] Registry resolves faction metadata by id.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-03 — Faction standing store + reputation delta audit store
|
||||
|
||||
**Goal:** Durable per-player **`FactionStanding`** snapshot and append-only **`ReputationDelta`** audit log.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`IFactionStandingStore`**, **`IReputationDeltaStore`** — in-memory implementations; optional Postgres migrations (NEO-116-style split).
|
||||
- Default standing **0** per known faction when missing; clamp reads to faction min/max.
|
||||
- Audit row: player, faction, delta amount, resulting standing, source kind (`quest_completion`), source id (quest id), timestamp/id.
|
||||
- Unit tests: first apply, clamp at bounds, audit append.
|
||||
- `server/README.md` store sections.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Public apply API surface (E7M3-04), quest wiring (E7M3-06).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Standing readable per player+faction; deltas append-only and queryable for audit tests.
|
||||
- [ ] Unknown faction id returns structured deny at store boundary.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-04 — `ReputationOperations` (apply `ReputationDelta`)
|
||||
|
||||
**Goal:** Server-internal apply for standing changes with clamp, audit, and structured outcomes.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`ReputationOperations.TryApplyDelta`** in `server/NeonSprawl.Server/Game/Factions/`.
|
||||
- Validates faction id via **`IFactionDefinitionRegistry`**; fail-closed on unknown faction.
|
||||
- Emits audit **`ReputationDelta`** row on success.
|
||||
- Unit + integration tests (AAA): happy path, clamp min/max, unknown faction deny.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Quest bundle wiring (E7M3-06), accept gates (E7M3-05).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Delta apply updates standing once and records audit row.
|
||||
- [ ] Standing never exceeds faction min/max after apply.
|
||||
|
||||
**Client counterpart:** none (server engine).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-05 — `FactionGateOperations` + wire into `QuestStateOperations.TryAccept`
|
||||
|
||||
**Goal:** Quest accept evaluates **`FactionGateRule`** rows fail-closed before activation.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`FactionGateOperations.TryEvaluate`** — all rules must pass (AND); compare player standing vs **`minStanding`** per rule.
|
||||
- Call from **`TryAccept`** after prerequisite check, before **`TryActivate`**.
|
||||
- New reason code **`faction_gate_blocked`** in **`QuestStateReasonCodes`** + accept deny telemetry hook site.
|
||||
- Integration tests: accept gated quest denied below threshold; succeeds at threshold; unknown faction in rule fails closed at startup (E7M3-02) not runtime.
|
||||
- Bruno smoke for accept deny/success on **`prototype_quest_grid_contract`**.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Zone/travel gates (E4.M1); Godot HUD (E7M3-10).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Accept returns **`faction_gate_blocked`** when standing below **`minStanding`**.
|
||||
- [ ] Accept succeeds when all rules satisfied.
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142) — HUD surfacing (blocked by E7M3-07/E7M3-08).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-06 — Extend `RewardRouterOperations` for `reputationGrants`
|
||||
|
||||
**Goal:** Quest completion bundles apply faction rep grants idempotently with item/skill XP rows.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend **`RewardRouterOperations.TryDeliverQuestCompletion`** to apply **`reputationGrants`** via **`ReputationOperations`** after validation.
|
||||
- Extend **`IRewardDeliveryStore`** / **`RewardDeliveryEvent`** snapshot with rep grant lines.
|
||||
- Compensating rollback policy: if rep apply fails after items/xp committed, document rollback behavior (mirror E7.M2 partial-failure policy).
|
||||
- Integration tests: operator-chain completion → +15 Grid Operators once; replay idempotent.
|
||||
- Extend Bruno quest-progress smoke for standing side effect (optional subfolder).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP read projection (E7M3-08), Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] First-time quest completion applies rep grants exactly once per delivery key.
|
||||
- [ ] Unknown faction id in bundle denies delivery before store record.
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-07 — `GET /game/players/{id}/faction-standing` HTTP read
|
||||
|
||||
**Goal:** Client-readable standing snapshot for all frozen factions.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`GET /game/players/{id}/faction-standing`** — `FactionStandingApi` + DTOs in `Game/Factions/`.
|
||||
- Response lists each catalog faction with current standing (default 0).
|
||||
- Integration tests + Bruno `bruno/neon-sprawl-server/faction-standing/`.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot HUD (E7M3-10).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] GET returns stable JSON for prototype player after quest rep grants.
|
||||
- [ ] Unknown player returns **404** (position gate precedent).
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-08 — Extend quest HTTP projections for rep grants + gate rules
|
||||
|
||||
**Goal:** Client can see gate requirements and completion rep summary without local math.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend **`GET /game/world/quest-definitions`** with optional **`factionGateRules`** projection per quest.
|
||||
- Extend **`GET …/quest-progress`** **`completionRewardSummary`** with **`reputationGrants`** lines (mirror items/skill XP).
|
||||
- Update **`QuestAcceptApi`** embedded quest rows if applicable.
|
||||
- Integration tests + Bruno assertions on grid-contract quest row.
|
||||
- `server/README.md` quest + faction sections.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot parse/HUD (E7M3-10).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Gated quest definition includes **`factionGateRules`** in world GET.
|
||||
- [ ] Completed quest rows include rep lines in **`completionRewardSummary`** when delivered.
|
||||
|
||||
**Client counterpart:** [NEO-142](https://linear.app/neon-sprawl/issue/NEO-142).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-09 — Faction telemetry hook sites (comment-only)
|
||||
|
||||
**Goal:** Anchor future E9.M1 catalog events without ingest.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Comment-only **`reputation_delta`** in **`ReputationOperations`** (successful apply).
|
||||
- Comment-only **`faction_gate_blocked`** in **`FactionGateOperations`** / **`TryAccept`** deny path.
|
||||
- `server/README.md` hook list.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- E9.M1 ingest, dashboards.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook sites documented in README; no behavior change beyond comments.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-10 — Client faction standing + gate feedback HUD (Godot)
|
||||
|
||||
**Goal:** Player sees faction standing and accept gate denials in Godot without Bruno.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`faction_standing_client.gd`**, **`FactionStandingLabel`** (or extend economy HUD section).
|
||||
- Parse **`GET …/faction-standing`**; refresh after quest completion transitions (reuse **`quest_hud_controller.gd`** signals).
|
||||
- Surface **`faction_gate_blocked`** on accept feedback label (NEO-122 pattern).
|
||||
- Extend **`quest_progress_client.gd`** / reward label for **`reputationGrants`** lines in **`completionRewardSummary`**.
|
||||
- GdUnit parse tests; `client/README.md` section.
|
||||
- `docs/manual-qa/NEO-142.md` component checklist.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final journal UI; faction art pass.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Standing visible after operator-chain completion (+15 Grid Operators).
|
||||
- [ ] Accepting grid contract below rep shows readable deny copy on HUD.
|
||||
|
||||
**Server counterpart:** [NEO-139](https://linear.app/neon-sprawl/issue/NEO-139), [NEO-140](https://linear.app/neon-sprawl/issue/NEO-140).
|
||||
|
||||
---
|
||||
|
||||
### E7M3-11 — Playable faction reputation + gate capstone (Godot)
|
||||
|
||||
**Goal:** Prove Epic 7 Slice 3 acceptance **in Godot**: earn rep from operator chain, accept gated faction quest once; gates fail closed.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`docs/manual-qa/NEO-143.md`**: capstone session — complete operator chain → verify standing **15** → accept **`prototype_quest_grid_contract`** → complete hand-in → Rust Collective **+10**; retry accept gate before rep (denied).
|
||||
- **`client/README.md`**: **End-to-end faction reputation loop** section; cross-links NEO-133–NEO-142.
|
||||
- Module alignment on completion: [E7_M3](../decomposition/modules/E7_M3_FactionReputationLedger.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md), [module_dependency_register.md](../decomposition/modules/module_dependency_register.md).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Contract generator (E7.M4); zone travel gates (E4.M1); Bruno-only proof.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Human completes **`docs/manual-qa/NEO-143.md`** with server + client.
|
||||
- [ ] Epic 7 Slice 3 AC: reputation deltas auditable; gates fail closed on tamper (unknown faction denied at startup).
|
||||
- [ ] Re-read [epic_07 Slice 3 AC](../decomposition/epics/epic_07_quest_faction.md#slice-3---faction-ledger-pre-production).
|
||||
|
||||
**Client counterpart:** this story **is** the Slice 3 client capstone.
|
||||
|
||||
---
|
||||
|
||||
## Decomposition complete checklist
|
||||
|
||||
- [x] Every player-visible AC has a **`client`** Linear issue (NEO-142, NEO-143)
|
||||
- [x] No forbidden deferral language in backlog
|
||||
- [x] Capstone client story exists (E7M3-11 / NEO-143)
|
||||
- [x] Epic DoD updated for Godot verification (see epic_07 update)
|
||||
- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M3 row updated (decomposition pass)
|
||||
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M3 note updated
|
||||
|
||||
## Related docs
|
||||
|
||||
- [epic_07_quest_faction.md](../decomposition/epics/epic_07_quest_faction.md)
|
||||
- [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md)
|
||||
- [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md)
|
||||
- [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md)
|
||||
- [neon_sprawl_vision.plan.md — Prototype Scope](../../neon_sprawl_vision.plan.md)
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# NEO-133 — E7M3-01: FactionDef catalog + quest gate/rep schemas + CI
|
||||
|
||||
**Linear:** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)
|
||||
**Branch:** `NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci`
|
||||
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-01**
|
||||
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
|
||||
**Pattern:** [NEO-124-implementation-plan.md](NEO-124-implementation-plan.md) (schemas + catalog + `validate_content.py` gates)
|
||||
|
||||
## Goal
|
||||
|
||||
Land faction and quest content contracts for Epic 7 Slice 3 so **CI** validates faction JSON, extended quest fields (`factionGateRules`, `completionRewardBundle.reputationGrants`), and the frozen five-quest prototype roster **before** NEO-134 server faction catalog load.
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
Decisions are already frozen in [E7M3-pre-production-backlog.md — Kickoff decisions](E7M3-pre-production-backlog.md#kickoff-decisions) and [E7_M3 — Prototype Slice 3 freeze](../decomposition/modules/E7_M3_FactionReputationLedger.md#prototype-slice-3-freeze-e7m3-01). No blocking `AskQuestion` items.
|
||||
|
||||
| Topic | Decision | Evidence |
|
||||
|-------|----------|----------|
|
||||
| JSON naming | camelCase: `reputationGrants`, `factionGateRules`, `factionId`, `minStanding` | E7M2 bundle pattern; backlog kickoff table |
|
||||
| Rep delivery | Via `completionRewardBundle.reputationGrants` (E7.M2 bundle idempotency path) | E7_M3 module doc; NEO-138 applies rep |
|
||||
| Gate site | Quest **accept** only (Slice 3) | Backlog E7M3-06; zone gates deferred to E4.M1 |
|
||||
| Faction roster | Exactly two ids: `prototype_faction_grid_operators`, `prototype_faction_rust_collective` | E7_M3 freeze table |
|
||||
| Quest roster | Five quests: four E7.M1 onboarding + `prototype_quest_grid_contract` | E7_M3 freeze; expands `PROTOTYPE_E7M1_QUEST_IDS` → `PROTOTYPE_E7M3_QUEST_IDS` |
|
||||
| Operator chain | Add Grid Operators **+15** `reputationGrants`; keep existing item + skill XP | E7_M3 freeze |
|
||||
| Grid contract | Prereq operator chain; Grid Operators `minStanding` **15**; objective `inventory_has_item` `survey_drone_kit` ×1; completion Rust Collective **+10** | E7_M3 freeze |
|
||||
| Server loader / stores / HTTP / Godot | **Out of scope** (NEO-134+) | Backlog E7M3-01 scope |
|
||||
| `dotnet test` green | **Minimal roster parity** on existing E7.M1/E7.M2 catalog rules only (see below) | Content JSON change breaks four-quest server freeze without constant sync |
|
||||
|
||||
### Minimal server parity (CI / test green only)
|
||||
|
||||
Adding the fifth quest and updated `prototype_quests.json` will fail server startup tests until `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` includes five ids. This is **not** NEO-134 loader work — only constant + freeze-table sync mirroring NEO-124 → NEO-125:
|
||||
|
||||
- Extend `ExpectedQuestIds` + add `GridContractQuestId` / objective constants.
|
||||
- Add `prototype_quest_grid_contract` to `PrototypeE7M2QuestCatalogRules.ExpectedCompletionBundles` with **empty** `itemGrants` / `skillXpGrants` (rep-only bundle; server `ParseCompletionRewardBundle` ignores `reputationGrants` until NEO-138).
|
||||
- Operator chain JSON may include `reputationGrants`; server E7M2 freeze still compares item + skill XP only (unchanged normalized bundle).
|
||||
|
||||
No new faction loader, no `IFactionDefinitionRegistry`, no gate evaluation.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/faction-def.schema.json` | `FactionDef`: `id`, `displayName`, `minStanding`, `maxStanding`, `neutralStanding` |
|
||||
| `content/schemas/faction-gate-rule.schema.json` | `factionId`, `minStanding` |
|
||||
| `content/schemas/reputation-grant-row.schema.json` | `factionId`, `amount` (signed int) |
|
||||
| `content/factions/prototype_factions.json` | Two frozen factions per E7_M3 freeze |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| `content/schemas/quest-def.schema.json` | Optional `factionGateRules` array (`$ref` faction-gate-rule) |
|
||||
| `content/schemas/quest-reward-bundle.schema.json` | Optional `reputationGrants` array (`$ref` reputation-grant-row) |
|
||||
| `content/quests/prototype_quests.json` | Operator chain `reputationGrants`; new `prototype_quest_grid_contract` |
|
||||
| `scripts/validate_content.py` | Faction load/validate; `PROTOTYPE_E7M3_QUEST_IDS` (5); faction cross-ref gates; `PROTOTYPE_E7M3_COMPLETION_BUNDLES` freeze (item + skill + rep); rename/supersede E7M1 four-quest id gate |
|
||||
| `content/README.md` | Document `content/factions/`, new schemas, five-quest roster |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | Mark catalog landed when done |
|
||||
| `server/.../PrototypeE7M1QuestCatalogRules.cs` | Five-quest roster + grid-contract constants |
|
||||
| `server/.../PrototypeE7M2QuestCatalogRules.cs` | Grid-contract empty item/skill bundle in freeze |
|
||||
|
||||
## CI gates (`validate_content.py`)
|
||||
|
||||
1. **Faction catalog** — schema validate `content/factions/*_factions.json`; freeze exactly two faction ids + display names + standing band (-100…100, neutral 0).
|
||||
2. **Quest roster** — `PROTOTYPE_E7M3_QUEST_IDS` frozen set of five ids (includes all four E7M1 ids + `prototype_quest_grid_contract`).
|
||||
3. **E7M1 gates retained** — acyclic prerequisites, objective cross-refs, chain terminal token (updated for fifth quest).
|
||||
4. **Faction cross-refs** — every `factionGateRules[].factionId` and `reputationGrants[].factionId` must exist in faction catalog.
|
||||
5. **E7M3 completion bundle freeze** — per-quest expected `itemGrants`, `skillXpGrants`, `reputationGrants` for all five quests (supersedes E7M2 freeze for operator chain + grid contract rep rows; first three gather/craft quests unchanged).
|
||||
6. **Grid contract shape** — prereq operator chain; `factionGateRules` Grid Operators minStanding 15; objective `inventory_has_item` / `survey_drone_kit`.
|
||||
|
||||
## Tests
|
||||
|
||||
| Layer | What |
|
||||
|-------|------|
|
||||
| CI | `python3 scripts/validate_content.py` (existing PR gate) |
|
||||
| Server | Existing quest catalog loader/registry tests — should pass after roster parity constants; no new `[Fact]` unless a regression needs explicit grid-contract coverage |
|
||||
|
||||
Manual QA: **none** (content-only; Godot capstone is NEO-143).
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Schemas (`faction-def`, `faction-gate-rule`, `reputation-grant-row`; extend quest-def + quest-reward-bundle).
|
||||
2. `prototype_factions.json` + quest JSON edits.
|
||||
3. `validate_content.py` gates + bundle freeze tables.
|
||||
4. Minimal server roster/freeze constant sync.
|
||||
5. `content/README.md` + E7_M3 module doc status line.
|
||||
6. Run `python3 scripts/validate_content.py` and `dotnet test` on server project.
|
||||
|
||||
## Out of scope (follow-on tickets)
|
||||
|
||||
| Ticket | Work |
|
||||
|--------|------|
|
||||
| NEO-134 | Server faction catalog fail-fast load |
|
||||
| NEO-135–137 | Faction standing store, delta audit, gate evaluation on accept |
|
||||
| NEO-138 | `RewardRouterOperations` reputation apply + idempotent delivery |
|
||||
| NEO-139–143 | HTTP read, telemetry, Godot HUD, capstone |
|
||||
|
||||
## Client counterpart
|
||||
|
||||
None for E7M3-01. Client faction HUD and capstone: **NEO-142** / **NEO-143**.
|
||||
|
||||
## Blocks
|
||||
|
||||
**NEO-134** (server faction catalog load) is blocked until this catalog + CI lands.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Schemas:** `faction-def`, `faction-gate-rule`, `reputation-grant-row`; extended `quest-def` + `quest-reward-bundle`.
|
||||
- **Catalog:** `prototype_factions.json` (two factions); `prototype_quests.json` operator-chain rep + `prototype_quest_grid_contract`.
|
||||
- **CI:** `PROTOTYPE_E7M3_*` gates in `validate_content.py` (roster, faction freeze, bundle freeze, cross-refs, grid-contract shape).
|
||||
- **Server parity:** five-quest `ExpectedQuestIds`, grid-contract E7M2 bundle freeze, schema registry for new `$ref`s.
|
||||
- **Docs:** `content/README.md` E7 Slice 3 paragraph; E7_M3 module CI line.
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Code review — NEO-133 (E7M3-01)
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Scope:** Branch `NEO-133-e7m3-factiondef-catalog-quest-gate-rep-schemas-ci` — commits `7183461` … `0948d9d` vs `main`
|
||||
**Base:** `main` (merge-base at `33cfc62`)
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
This branch lands Epic 7 Slice 3 **content contracts** ahead of server faction load: three new JSON schemas (`FactionDef`, `FactionGateRule`, `ReputationGrantRow`), `prototype_factions.json`, quest JSON extensions (`factionGateRules`, `reputationGrants`), a fifth quest (`prototype_quest_grid_contract`), and matching CI gates in `scripts/validate_content.py`. Server changes are intentionally minimal—five-quest roster constants, rep-only grid-contract E7M2 bundle freeze entry, and schema `$ref` registration so startup validation accepts the new quest JSON. Documentation and decomposition tracking (E7.M3 backlog, module register, alignment table) are updated coherently. Risk is low: no runtime faction/rep behavior yet; `validate_content.py` and `dotnet test` both pass locally.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-133-implementation-plan.md` | **Matches** — shipped items reconcile with plan (schemas, catalog, CI, server parity, docs). |
|
||||
| `docs/plans/E7M3-pre-production-backlog.md` | **Matches** — E7M3-01 AC checked; client capstone NEO-142/143 documented. |
|
||||
| `docs/decomposition/modules/E7_M3_FactionReputationLedger.md` | **Matches** — Slice 3 freeze table and CI line updated. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — Slice 1 freeze table preserved; CI paragraph cross-links E7.M3 five-quest roster. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M3 status In Progress, E7.M2 dependency added. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M3 row added. |
|
||||
| `docs/decomposition/epics/epic_07_quest_faction.md` | **Matches** — Slice 3 backlog link and Godot capstone AC. |
|
||||
| `content/README.md` | **Matches** — factions folder, Slice 3 paragraph. |
|
||||
| `server/README.md` | **N/A for NEO-133** — no faction section yet (planned NEO-134). |
|
||||
|
||||
Register/tracking updates for E7.M3 are appropriately landed in this PR.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**E7.M1 module doc vs five-quest CI** — `E7_M1_QuestStateMachine.md` still states CI requires exactly the four Slice 1 quest ids. Add a one-line cross-link to E7.M3 (`PROTOTYPE_E7M3_QUEST_IDS`) so readers do not assume the four-quest gate is still the active roster check.~~ **Done.** CI paragraph cross-links E7.M3 roster expansion.
|
||||
|
||||
2. ~~**Server constant naming** — `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds` and `TryGetE7M1GateError` now enforce **five** ids while labels/messages still say “E7M1”. When NEO-134 lands, consider `PrototypeE7M3QuestCatalogRules` (or rename + update error strings) to avoid onboarding confusion.~~ **Done (partial).** Error string now says “prototype quest roster”; class/XML docs note NEO-134 rename. Full type rename deferred to NEO-134.
|
||||
|
||||
3. ~~**Remove dead CI helper** — `_prototype_e7m1_quest_gate` in `scripts/validate_content.py` is no longer called (superseded by `_prototype_e7m3_quest_roster_gate`). Delete or mark deprecated to prevent accidental reintroduction of the four-quest gate.~~ **Done.** Dead helper removed.
|
||||
|
||||
4. ~~**Faction standing band schema** — `faction-def.schema.json` does not constrain `minStanding <= neutralStanding <= maxStanding`. Optional JSON Schema `minimum`/`maximum` cross-field checks (or a CI gate mirroring faction freeze) would catch designer mistakes before NEO-134 server load.~~ **Done.** `_prototype_e7m3_faction_standing_band_gate` enforces ordering on all faction rows.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: `PROTOTYPE_E7M1_QUEST_IDS` (four ids) remains beside `PROTOTYPE_E7M3_QUEST_IDS` (five ids); fine for historical Slice 1 reference, but a short comment on E7M1 constant (“Slice 1 roster subset; CI roster gate is E7M3”) would clarify intent.~~ **Done.**
|
||||
- ~~Nit: `QuestDefinitionCatalogLoaderTests.Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract` asserts gather/chain rows but does not assert grid-contract gate/bundle fields; acceptable per plan (“no new Fact unless regression needs it”), but a single assert on grid-contract prereq/gate would cheaply lock the fifth quest shape server-side.~~ **Done.** Asserts grid-contract prereq and rep-only bundle (item/skill empty at server parse layer).
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run during review (passed):
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_content.py
|
||||
dotnet test server/NeonSprawl.Server.Tests
|
||||
```
|
||||
|
||||
Author should confirm PR CI content gate green before merge.
|
||||
|
||||
**Known intentional gap (not a blocker for E7M3-01):** Server `QuestDefRow` / `QuestRewardBundleRow` do not model `factionGateRules` or `reputationGrants`; JSON is schema-validated at load but ignored until NEO-134–NEO-138. Grid contract can be accepted without standing check until NEO-137/NEO-138.
|
||||
|
|
@ -15,7 +15,9 @@ Validates:
|
|||
- reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100)
|
||||
- encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100)
|
||||
- quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112);
|
||||
optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124)
|
||||
optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124);
|
||||
optional factionGateRules + reputationGrants (NEO-133)
|
||||
- faction catalogs: content/factions/*_factions.json rows vs content/schemas/faction-def.schema.json (NEO-133)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -46,6 +48,9 @@ QUEST_STEP_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-step-def.schema.json"
|
|||
QUEST_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-def.schema.json"
|
||||
QUEST_REWARD_BUNDLE_SCHEMA = REPO_ROOT / "content/schemas/quest-reward-bundle.schema.json"
|
||||
QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant.schema.json"
|
||||
FACTION_DEF_SCHEMA = REPO_ROOT / "content/schemas/faction-def.schema.json"
|
||||
FACTION_GATE_RULE_SCHEMA = REPO_ROOT / "content/schemas/faction-gate-rule.schema.json"
|
||||
REPUTATION_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reputation-grant-row.schema.json"
|
||||
SKILLS_DIR = REPO_ROOT / "content/skills"
|
||||
MASTERY_DIR = REPO_ROOT / "content/mastery"
|
||||
ITEMS_DIR = REPO_ROOT / "content/items"
|
||||
|
|
@ -56,6 +61,7 @@ NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"
|
|||
REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
|
||||
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
|
||||
QUESTS_DIR = REPO_ROOT / "content/quests"
|
||||
FACTIONS_DIR = REPO_ROOT / "content/factions"
|
||||
|
||||
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
|
||||
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
||||
|
|
@ -160,8 +166,9 @@ PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
|
|||
"contract_handoff_token": 1,
|
||||
}
|
||||
|
||||
# Epic 7 Slice 1 prototype lock (NEO-112): exact quest ids after schema passes.
|
||||
# Keep in sync with E7.M1 freeze table and future NEO-113 server loader.
|
||||
# Epic 7 Slice 1 prototype lock (NEO-112): Slice 1 onboarding quest ids (historical subset).
|
||||
# Active CI roster gate is PROTOTYPE_E7M3_QUEST_IDS (five quests, NEO-133).
|
||||
# Keep in sync with E7.M1 freeze table; server ExpectedQuestIds expanded for E7M3.
|
||||
PROTOTYPE_E7M1_QUEST_IDS = frozenset(
|
||||
{
|
||||
"prototype_quest_gather_intro",
|
||||
|
|
@ -195,6 +202,74 @@ PROTOTYPE_E7M2_COMPLETION_BUNDLES: dict[str, dict] = {
|
|||
},
|
||||
}
|
||||
|
||||
# Epic 7 Slice 3 prototype lock (NEO-133): five-quest roster + faction catalog.
|
||||
# Keep in sync with E7.M3 freeze table and future NEO-134 server loader.
|
||||
PROTOTYPE_E7M3_FACTION_IDS = frozenset(
|
||||
{
|
||||
"prototype_faction_grid_operators",
|
||||
"prototype_faction_rust_collective",
|
||||
}
|
||||
)
|
||||
PROTOTYPE_E7M3_FACTION_FREEZE: dict[str, dict] = {
|
||||
"prototype_faction_grid_operators": {
|
||||
"displayName": "Grid Operators",
|
||||
"minStanding": -100,
|
||||
"maxStanding": 100,
|
||||
"neutralStanding": 0,
|
||||
},
|
||||
"prototype_faction_rust_collective": {
|
||||
"displayName": "Rust Collective",
|
||||
"minStanding": -100,
|
||||
"maxStanding": 100,
|
||||
"neutralStanding": 0,
|
||||
},
|
||||
}
|
||||
PROTOTYPE_E7M3_QUEST_IDS = frozenset(
|
||||
{
|
||||
"prototype_quest_gather_intro",
|
||||
"prototype_quest_refine_intro",
|
||||
"prototype_quest_combat_intro",
|
||||
"prototype_quest_operator_chain",
|
||||
"prototype_quest_grid_contract",
|
||||
}
|
||||
)
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID = "prototype_quest_grid_contract"
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID = "prototype_quest_operator_chain"
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID = "prototype_faction_grid_operators"
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING = 15
|
||||
PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID = "survey_drone_kit"
|
||||
PROTOTYPE_E7M3_COMPLETION_BUNDLES: dict[str, dict] = {
|
||||
"prototype_quest_gather_intro": {
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [{"skillId": "salvage", "amount": 25}],
|
||||
"reputationGrants": [],
|
||||
},
|
||||
"prototype_quest_refine_intro": {
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [{"skillId": "refine", "amount": 25}],
|
||||
"reputationGrants": [],
|
||||
},
|
||||
"prototype_quest_combat_intro": {
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [{"skillId": "salvage", "amount": 25}],
|
||||
"reputationGrants": [],
|
||||
},
|
||||
"prototype_quest_operator_chain": {
|
||||
"itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}],
|
||||
"skillXpGrants": [{"skillId": "salvage", "amount": 50}],
|
||||
"reputationGrants": [
|
||||
{"factionId": "prototype_faction_grid_operators", "amount": 15}
|
||||
],
|
||||
},
|
||||
"prototype_quest_grid_contract": {
|
||||
"itemGrants": [],
|
||||
"skillXpGrants": [],
|
||||
"reputationGrants": [
|
||||
{"factionId": "prototype_faction_rust_collective", "amount": 10}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_completion_bundle(bundle: dict) -> dict:
|
||||
"""Normalize grant rows for stable freeze-table comparison."""
|
||||
|
|
@ -224,6 +299,23 @@ def _normalize_completion_bundle(bundle: dict) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _normalize_e7m3_completion_bundle(bundle: dict) -> dict:
|
||||
"""Normalize item, skill XP, and reputation grant rows for E7M3 freeze-table comparison."""
|
||||
normalized = _normalize_completion_bundle(bundle)
|
||||
reputation_grants = bundle.get("reputationGrants")
|
||||
normalized["reputationGrants"] = sorted(
|
||||
[
|
||||
{"factionId": grant["factionId"], "amount": grant["amount"]}
|
||||
for grant in (reputation_grants if isinstance(reputation_grants, list) else [])
|
||||
if isinstance(grant, dict)
|
||||
and isinstance(grant.get("factionId"), str)
|
||||
and isinstance(grant.get("amount"), int)
|
||||
],
|
||||
key=lambda grant: (grant["factionId"], grant["amount"]),
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 1 contract fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
|
|
@ -895,13 +987,15 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str
|
|||
|
||||
|
||||
def _quest_def_validator() -> Draft202012Validator:
|
||||
"""Build a validator that resolves quest-def $ref to step, objective, and bundle schemas."""
|
||||
"""Build a validator that resolves quest-def $ref to step, objective, bundle, and gate schemas."""
|
||||
objective_schema = json.loads(QUEST_OBJECTIVE_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
step_schema = json.loads(QUEST_STEP_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
def_schema = json.loads(QUEST_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
bundle_schema = json.loads(QUEST_REWARD_BUNDLE_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_xp_grant_schema = json.loads(QUEST_SKILL_XP_GRANT_SCHEMA.read_text(encoding="utf-8"))
|
||||
grant_row_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
|
||||
faction_gate_rule_schema = json.loads(FACTION_GATE_RULE_SCHEMA.read_text(encoding="utf-8"))
|
||||
reputation_grant_row_schema = json.loads(REPUTATION_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
|
||||
registry = Registry().with_resources(
|
||||
[
|
||||
(def_schema["$id"], Resource.from_contents(def_schema)),
|
||||
|
|
@ -910,11 +1004,62 @@ def _quest_def_validator() -> Draft202012Validator:
|
|||
(bundle_schema["$id"], Resource.from_contents(bundle_schema)),
|
||||
(skill_xp_grant_schema["$id"], Resource.from_contents(skill_xp_grant_schema)),
|
||||
(grant_row_schema["$id"], Resource.from_contents(grant_row_schema)),
|
||||
(faction_gate_rule_schema["$id"], Resource.from_contents(faction_gate_rule_schema)),
|
||||
(reputation_grant_row_schema["$id"], Resource.from_contents(reputation_grant_row_schema)),
|
||||
]
|
||||
)
|
||||
return Draft202012Validator(def_schema, registry=registry)
|
||||
|
||||
|
||||
def _validate_faction_catalogs(
|
||||
*,
|
||||
faction_files: list[Path],
|
||||
faction_validator: Draft202012Validator,
|
||||
) -> tuple[int, dict[str, str], dict[str, dict]]:
|
||||
"""Validate faction JSON files. Returns (error_count, seen_ids, id_to_row)."""
|
||||
errors = 0
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_row: dict[str, dict] = {}
|
||||
|
||||
for path in faction_files:
|
||||
rel = str(path.relative_to(REPO_ROOT))
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
schema_version = data.get("schemaVersion")
|
||||
if schema_version != 1:
|
||||
print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
factions = data.get("factions")
|
||||
if not isinstance(factions, list):
|
||||
print(f"error: {rel}: expected top-level 'factions' array", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
for i, row in enumerate(factions):
|
||||
if not isinstance(row, dict):
|
||||
print(f"error: {rel}: factions[{i}] must be an object", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
row_schema_errors = 0
|
||||
for err in sorted(faction_validator.iter_errors(row), key=lambda e: e.path):
|
||||
loc = ".".join(str(p) for p in err.path) or "(root)"
|
||||
print(f"error: {rel} factions[{i}] {loc}: {err.message}", file=sys.stderr)
|
||||
row_schema_errors += 1
|
||||
errors += 1
|
||||
fid = row.get("id")
|
||||
if isinstance(fid, str) and row_schema_errors == 0:
|
||||
prev = seen_ids.get(fid)
|
||||
if prev:
|
||||
print(f"error: duplicate faction id {fid!r} in {prev} and {rel}", file=sys.stderr)
|
||||
errors += 1
|
||||
else:
|
||||
seen_ids[fid] = rel
|
||||
id_to_row[fid] = row
|
||||
|
||||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _validate_quest_catalogs(
|
||||
*,
|
||||
quest_files: list[Path],
|
||||
|
|
@ -998,13 +1143,13 @@ def _validate_quest_catalogs(
|
|||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _prototype_e7m1_quest_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E7M1 quest contract fails, else None."""
|
||||
def _prototype_e7m3_quest_roster_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E7M3 five-quest roster fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
if ids != PROTOTYPE_E7M1_QUEST_IDS:
|
||||
if ids != PROTOTYPE_E7M3_QUEST_IDS:
|
||||
return (
|
||||
"error: prototype E7M1 expects exactly quest ids "
|
||||
f"{sorted(PROTOTYPE_E7M1_QUEST_IDS)!r}, got {sorted(ids)!r}"
|
||||
"error: prototype E7M3 expects exactly quest ids "
|
||||
f"{sorted(PROTOTYPE_E7M3_QUEST_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
|
@ -1132,7 +1277,7 @@ def _prototype_e7m1_chain_terminal_gate(id_to_row: dict[str, dict]) -> str | Non
|
|||
|
||||
def _prototype_e7m2_completion_bundle_presence_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if a frozen quest lacks completionRewardBundle, else None."""
|
||||
for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS):
|
||||
for qid in sorted(PROTOTYPE_E7M3_QUEST_IDS):
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing quest {qid!r}"
|
||||
|
|
@ -1166,7 +1311,7 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate(
|
|||
skill_id_to_allowed_kinds: dict[str, list[str]],
|
||||
) -> str | None:
|
||||
"""Return a human-readable error if bundle refs fail cross-checks, else None."""
|
||||
for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS):
|
||||
for qid in sorted(PROTOTYPE_E7M3_QUEST_IDS):
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
|
|
@ -1206,6 +1351,165 @@ def _prototype_e7m2_completion_bundle_cross_ref_gate(
|
|||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_faction_roster_gate(faction_seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E7M3 faction roster fails, else None."""
|
||||
ids = frozenset(faction_seen_ids.keys())
|
||||
if ids != PROTOTYPE_E7M3_FACTION_IDS:
|
||||
return (
|
||||
"error: prototype E7M3 expects exactly faction ids "
|
||||
f"{sorted(PROTOTYPE_E7M3_FACTION_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_faction_freeze_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if faction rows diverge from E7M3 freeze table, else None."""
|
||||
for fid, expected in PROTOTYPE_E7M3_FACTION_FREEZE.items():
|
||||
row = id_to_row.get(fid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing faction {fid!r}"
|
||||
for key, expected_value in expected.items():
|
||||
if row.get(key) != expected_value:
|
||||
return (
|
||||
f"error: faction {fid!r} must match E7M3 freeze table "
|
||||
f"(expected {key}={expected_value!r}, got {row.get(key)!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_faction_standing_band_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if faction standing band ordering fails, else None."""
|
||||
for fid, row in id_to_row.items():
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
min_standing = row.get("minStanding")
|
||||
max_standing = row.get("maxStanding")
|
||||
neutral_standing = row.get("neutralStanding")
|
||||
if not all(isinstance(value, int) for value in (min_standing, max_standing, neutral_standing)):
|
||||
continue
|
||||
if min_standing > neutral_standing or neutral_standing > max_standing:
|
||||
return (
|
||||
f"error: faction {fid!r} requires minStanding <= neutralStanding <= maxStanding "
|
||||
f"(got min={min_standing!r}, neutral={neutral_standing!r}, max={max_standing!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_faction_cross_ref_gate(
|
||||
id_to_row: dict[str, dict],
|
||||
faction_seen_ids: dict[str, str],
|
||||
) -> str | None:
|
||||
"""Return a human-readable error if quest faction refs fail, else None."""
|
||||
known_factions = frozenset(faction_seen_ids.keys())
|
||||
for qid, row in id_to_row.items():
|
||||
gate_rules = row.get("factionGateRules")
|
||||
if isinstance(gate_rules, list):
|
||||
for gi, rule in enumerate(gate_rules):
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
faction_id = rule.get("factionId")
|
||||
if isinstance(faction_id, str) and faction_id not in known_factions:
|
||||
return (
|
||||
f"error: quest {qid!r} factionGateRules[{gi}]: factionId {faction_id!r} "
|
||||
"is not in the frozen prototype faction catalog"
|
||||
)
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if isinstance(bundle, dict):
|
||||
reputation_grants = bundle.get("reputationGrants")
|
||||
if isinstance(reputation_grants, list):
|
||||
for gi, grant in enumerate(reputation_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
faction_id = grant.get("factionId")
|
||||
if isinstance(faction_id, str) and faction_id not in known_factions:
|
||||
return (
|
||||
f"error: quest {qid!r} completionRewardBundle.reputationGrants[{gi}]: "
|
||||
f"factionId {faction_id!r} is not in the frozen prototype faction catalog"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_completion_bundle_freeze_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if bundle contents diverge from E7M3 freeze table, else None."""
|
||||
for qid, expected in PROTOTYPE_E7M3_COMPLETION_BUNDLES.items():
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing quest {qid!r}"
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if not isinstance(bundle, dict):
|
||||
return f"error: quest {qid!r} must include completionRewardBundle object"
|
||||
actual = _normalize_e7m3_completion_bundle(bundle)
|
||||
expected_normalized = _normalize_e7m3_completion_bundle(expected)
|
||||
if actual != expected_normalized:
|
||||
return (
|
||||
f"error: quest {qid!r} completionRewardBundle must match E7M3 freeze table "
|
||||
f"(expected {expected_normalized!r}, got {actual!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m3_grid_contract_shape_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if grid contract quest shape fails, else None."""
|
||||
row = id_to_row.get(PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r}"
|
||||
|
||||
prereqs = row.get("prerequisiteQuestIds")
|
||||
if prereqs != [PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID]:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} prerequisiteQuestIds must be "
|
||||
f"{[PROTOTYPE_E7M3_GRID_CONTRACT_PREREQ_QUEST_ID]!r}, got {prereqs!r}"
|
||||
)
|
||||
|
||||
gate_rules = row.get("factionGateRules")
|
||||
expected_gate = [
|
||||
{
|
||||
"factionId": PROTOTYPE_E7M3_GRID_CONTRACT_GATE_FACTION_ID,
|
||||
"minStanding": PROTOTYPE_E7M3_GRID_CONTRACT_MIN_STANDING,
|
||||
}
|
||||
]
|
||||
if gate_rules != expected_gate:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} factionGateRules must be "
|
||||
f"{expected_gate!r}, got {gate_rules!r}"
|
||||
)
|
||||
|
||||
steps = row.get("steps")
|
||||
if not isinstance(steps, list) or len(steps) != 1:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} must have exactly one step"
|
||||
)
|
||||
step = steps[0]
|
||||
if not isinstance(step, dict):
|
||||
return f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} step must be an object"
|
||||
objectives = step.get("objectives")
|
||||
if not isinstance(objectives, list) or len(objectives) != 1:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} must have exactly one objective"
|
||||
)
|
||||
obj = objectives[0]
|
||||
if not isinstance(obj, dict):
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective must be an object"
|
||||
)
|
||||
if obj.get("kind") != "inventory_has_item":
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective kind must be "
|
||||
"'inventory_has_item'"
|
||||
)
|
||||
if obj.get("itemId") != PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective itemId must be "
|
||||
f"{PROTOTYPE_E7M3_GRID_CONTRACT_OBJECTIVE_ITEM_ID!r}, got {obj.get('itemId')!r}"
|
||||
)
|
||||
if obj.get("quantity") != 1:
|
||||
return (
|
||||
f"error: quest {PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID!r} objective quantity must be 1, "
|
||||
f"got {obj.get('quantity')!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 4 contract fails, else None."""
|
||||
if len(track_skill_ids) != 1:
|
||||
|
|
@ -1556,6 +1860,15 @@ def main() -> int:
|
|||
if not QUEST_SKILL_XP_GRANT_SCHEMA.is_file():
|
||||
print(f"error: missing schema {QUEST_SKILL_XP_GRANT_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not FACTION_DEF_SCHEMA.is_file():
|
||||
print(f"error: missing schema {FACTION_DEF_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not FACTION_GATE_RULE_SCHEMA.is_file():
|
||||
print(f"error: missing schema {FACTION_GATE_RULE_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not REPUTATION_GRANT_ROW_SCHEMA.is_file():
|
||||
print(f"error: missing schema {REPUTATION_GRANT_ROW_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_validator = Draft202012Validator(skill_schema)
|
||||
|
|
@ -1578,6 +1891,8 @@ def main() -> int:
|
|||
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
encounter_validator = Draft202012Validator(encounter_schema)
|
||||
quest_validator = _quest_def_validator()
|
||||
faction_schema = json.loads(FACTION_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
faction_validator = Draft202012Validator(faction_schema)
|
||||
|
||||
if not SKILLS_DIR.is_dir():
|
||||
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
|
||||
|
|
@ -1679,6 +1994,15 @@ def main() -> int:
|
|||
print(f"error: no *_quests.json files under {QUESTS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not FACTIONS_DIR.is_dir():
|
||||
print(f"error: missing directory {FACTIONS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
faction_files = sorted(FACTIONS_DIR.glob("*_factions.json"))
|
||||
if not faction_files:
|
||||
print(f"error: no *_factions.json files under {FACTIONS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_category: dict[str, str] = {}
|
||||
skill_id_to_allowed_kinds: dict[str, list[str]] = {}
|
||||
|
|
@ -1950,6 +2274,29 @@ def main() -> int:
|
|||
print(e5m3_encounter_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
faction_errors, faction_seen_ids, faction_id_to_row = _validate_faction_catalogs(
|
||||
faction_files=faction_files,
|
||||
faction_validator=faction_validator,
|
||||
)
|
||||
if faction_errors:
|
||||
print(f"content validation failed with {faction_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_faction_roster_err = _prototype_e7m3_faction_roster_gate(faction_seen_ids)
|
||||
if e7m3_faction_roster_err:
|
||||
print(e7m3_faction_roster_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_faction_freeze_err = _prototype_e7m3_faction_freeze_gate(faction_id_to_row)
|
||||
if e7m3_faction_freeze_err:
|
||||
print(e7m3_faction_freeze_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_faction_band_err = _prototype_e7m3_faction_standing_band_gate(faction_id_to_row)
|
||||
if e7m3_faction_band_err:
|
||||
print(e7m3_faction_band_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
quest_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs(
|
||||
quest_files=quest_files,
|
||||
quest_validator=quest_validator,
|
||||
|
|
@ -1958,9 +2305,9 @@ def main() -> int:
|
|||
print(f"content validation failed with {quest_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m1_quest_err = _prototype_e7m1_quest_gate(quest_seen_ids)
|
||||
if e7m1_quest_err:
|
||||
print(e7m1_quest_err, file=sys.stderr)
|
||||
e7m3_quest_roster_err = _prototype_e7m3_quest_roster_gate(quest_seen_ids)
|
||||
if e7m3_quest_roster_err:
|
||||
print(e7m3_quest_roster_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m1_prereq_err = _prototype_e7m1_quest_prerequisite_gate(quest_id_to_row)
|
||||
|
|
@ -1996,6 +2343,24 @@ def main() -> int:
|
|||
print(e7m2_bundle_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_faction_cross_ref_err = _prototype_e7m3_faction_cross_ref_gate(
|
||||
quest_id_to_row,
|
||||
faction_seen_ids,
|
||||
)
|
||||
if e7m3_faction_cross_ref_err:
|
||||
print(e7m3_faction_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_bundle_freeze_err = _prototype_e7m3_completion_bundle_freeze_gate(quest_id_to_row)
|
||||
if e7m3_bundle_freeze_err:
|
||||
print(e7m3_bundle_freeze_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m3_grid_contract_err = _prototype_e7m3_grid_contract_shape_gate(quest_id_to_row)
|
||||
if e7m3_grid_contract_err:
|
||||
print(e7m3_grid_contract_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"content OK: "
|
||||
f"{len(skill_files)} skill catalog file(s), "
|
||||
|
|
@ -2009,6 +2374,7 @@ def main() -> int:
|
|||
f"{len(npc_behavior_files)} npc behavior catalog file(s), "
|
||||
f"{len(reward_table_files)} reward table catalog file(s), "
|
||||
f"{len(encounter_files)} encounter catalog file(s), "
|
||||
f"{len(faction_files)} faction catalog file(s), "
|
||||
f"{len(quest_files)} quest catalog file(s), "
|
||||
f"{len(seen_ids)} unique skill id(s), "
|
||||
f"{len(item_seen_ids)} unique item id(s), "
|
||||
|
|
@ -2018,6 +2384,7 @@ def main() -> int:
|
|||
f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), "
|
||||
f"{len(reward_table_seen_ids)} unique reward table id(s), "
|
||||
f"{len(encounter_seen_ids)} unique encounter id(s), "
|
||||
f"{len(faction_seen_ids)} unique faction id(s), "
|
||||
f"{len(quest_seen_ids)} unique quest id(s), "
|
||||
f"{len(track_skill_ids)} mastery track(s)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
],
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }],
|
||||
"reputationGrants": [
|
||||
{ "factionId": "prototype_faction_grid_operators", "amount": 15 }
|
||||
]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
|
|
@ -167,6 +170,36 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prototype_quest_grid_contract",
|
||||
"displayName": "Grid Contract",
|
||||
"prerequisiteQuestIds": ["prototype_quest_operator_chain"],
|
||||
"factionGateRules": [
|
||||
{
|
||||
"factionId": "prototype_faction_grid_operators",
|
||||
"minStanding": 15
|
||||
}
|
||||
],
|
||||
"completionRewardBundle": {
|
||||
"reputationGrants": [
|
||||
{ "factionId": "prototype_faction_rust_collective", "amount": 10 }
|
||||
]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "grid_contract_step_kit",
|
||||
"displayName": "Deliver survey drone kit",
|
||||
"objectives": [
|
||||
{
|
||||
"id": "grid_contract_obj_kit",
|
||||
"kind": "inventory_has_item",
|
||||
"itemId": "survey_drone_kit",
|
||||
"quantity": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -188,6 +221,8 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
|
||||
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +273,7 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
// Act
|
||||
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||
// Assert
|
||||
Assert.Equal(4, catalog.DistinctQuestCount);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -250,7 +285,7 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
// Act
|
||||
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||
// Assert
|
||||
Assert.Equal(4, catalog.DistinctQuestCount);
|
||||
Assert.Equal(PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Count, catalog.DistinctQuestCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
|
||||
Assert.NotNull(gather);
|
||||
|
|
@ -267,6 +302,14 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants);
|
||||
Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId);
|
||||
Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount);
|
||||
Assert.True(catalog.TryGetQuest(PrototypeE7M1QuestCatalogRules.GridContractQuestId, out var gridContract));
|
||||
Assert.NotNull(gridContract);
|
||||
Assert.Equal(
|
||||
[PrototypeE7M1QuestCatalogRules.ChainQuestId],
|
||||
gridContract!.PrerequisiteQuestIds);
|
||||
Assert.NotNull(gridContract.CompletionRewardBundle);
|
||||
Assert.Empty(gridContract.CompletionRewardBundle!.ItemGrants);
|
||||
Assert.Empty(gridContract.CompletionRewardBundle.SkillXpGrants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -436,7 +479,7 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<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]
|
||||
|
|
@ -734,7 +777,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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -3,26 +3,31 @@ using System.Collections.Frozen;
|
|||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>,
|
||||
/// Prototype quest roster + cross-ref gates (NEO-113, NEO-133), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_QUEST_IDS</c> (five ids),
|
||||
/// <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>, <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>,
|
||||
/// and <c>_prototype_e7m1_*</c> gate functions.
|
||||
/// and <c>_prototype_e7m1_*</c> / <c>_prototype_e7m3_*</c> gate functions.
|
||||
/// Class name retained for NEO-113 call sites; rename to <c>PrototypeE7M3QuestCatalogRules</c> planned in NEO-134.
|
||||
/// </summary>
|
||||
public static class PrototypeE7M1QuestCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>.</summary>
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_QUEST_IDS</c> (five quests).</summary>
|
||||
public static readonly FrozenSet<string> ExpectedQuestIds = FrozenSet.ToFrozenSet(
|
||||
[
|
||||
"prototype_quest_gather_intro",
|
||||
"prototype_quest_refine_intro",
|
||||
"prototype_quest_combat_intro",
|
||||
"prototype_quest_operator_chain",
|
||||
"prototype_quest_grid_contract",
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary>
|
||||
public const string ChainQuestId = "prototype_quest_operator_chain";
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M3_GRID_CONTRACT_QUEST_ID</c>.</summary>
|
||||
public const string GridContractQuestId = "prototype_quest_grid_contract";
|
||||
|
||||
/// <summary>First frozen onboarding quest id (gather intro).</summary>
|
||||
public const string GatherIntroQuestId = "prototype_quest_gather_intro";
|
||||
|
||||
|
|
@ -41,14 +46,14 @@ public static class PrototypeE7M1QuestCatalogRules
|
|||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary>
|
||||
public const string ChainTerminalItemId = "contract_handoff_token";
|
||||
|
||||
/// <summary>Returns a human-readable error if the E7M1 quest contract fails, otherwise <see langword="null"/>.</summary>
|
||||
/// <summary>Returns a human-readable error if the prototype quest roster fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetE7M1GateError(IReadOnlyDictionary<string, string> questIdToSourceFile)
|
||||
{
|
||||
var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedQuestIds))
|
||||
{
|
||||
return
|
||||
"error: prototype E7M1 expects exactly quest ids " +
|
||||
"error: prototype quest roster expects exactly quest ids " +
|
||||
$"[{string.Join(", ", ExpectedQuestIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue