Compare commits
18 Commits
74b6586004
...
f18309809a
| Author | SHA1 | Date |
|---|---|---|
|
|
f18309809a | |
|
|
93fd8864dc | |
|
|
e3988874d2 | |
|
|
56bc22c70d | |
|
|
1b904b594d | |
|
|
937412eda6 | |
|
|
5ab5022704 | |
|
|
215493f63b | |
|
|
b5a0f71bca | |
|
|
9cfbf43936 | |
|
|
f7a014fe69 | |
|
|
fe9b706092 | |
|
|
2afd29e4d9 | |
|
|
7ecc8746f5 | |
|
|
58d2c32152 | |
|
|
c7a9e81067 | |
|
|
4238038e11 | |
|
|
53122f9f2a |
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (contract template catalog boot NEO-145)
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-145 loads content/contracts/*_contract_templates.json at startup (fail-fast). No contract HTTP API in this story — use this request to confirm the host started after catalog validation.
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("service identity", function () {
|
||||
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: contract-catalog
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
| [`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`**, 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) |
|
||||
| [`contracts/`](contracts/) | Contract template catalogs; each row matches [`schemas/contract-template.schema.json`](schemas/contract-template.schema.json) — **stable `id`**, **`zoneDifficultyBand`**, **`encounterTemplateId`**, **`minFactionStanding`**, **`completionRewardBundle`** for [E7.M4](../docs/decomposition/modules/E7_M4_ContractMissionGenerator.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.
|
||||
|
|
@ -37,6 +38,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
|
||||
**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 E7 Slice 4 — contract templates (NEO-144):** CI expects **exactly one** contract template id **`prototype_contract_clear_combat_pocket`** aligned to [E7.M4 contract freeze](../docs/decomposition/modules/E7_M4_ContractMissionGenerator.md#prototype-slice-4-freeze-e7m4-01). **`encounterTemplateId`** must resolve to **`prototype_combat_pocket`**; **`minFactionStanding.factionId`** must resolve to the frozen faction catalog; **`completionRewardBundle`** item/skill cross-refs and **`mission_reward`** allowlist match E7.M2 rules; band **1** economy caps (item qty ≤ **10**, skill XP ≤ **25**, rep ≤ **10** per grant row) are enforced in CI. Issue/audit shape: [`contract-seed.schema.json`](schemas/contract-seed.schema.json) (no catalog file yet). See [E7M4-pre-production-backlog.md](../docs/plans/E7M4-pre-production-backlog.md) and [NEO-144 plan](../docs/plans/NEO-144-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).
|
||||
|
||||
Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"contractTemplates": [
|
||||
{
|
||||
"id": "prototype_contract_clear_combat_pocket",
|
||||
"displayName": "Clear Combat Pocket (Repeat)",
|
||||
"zoneDifficultyBand": 1,
|
||||
"objectiveKind": "encounter_clear",
|
||||
"encounterTemplateId": "prototype_combat_pocket",
|
||||
"minFactionStanding": {
|
||||
"factionId": "prototype_faction_grid_operators",
|
||||
"minStanding": 0
|
||||
},
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 15 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/contract-seed.json",
|
||||
"title": "ContractSeed",
|
||||
"description": "Inputs for contract issuance (POST …/contracts/issue body or audit record). Validated when HTTP wiring lands (NEO-147+). See docs/decomposition/modules/E7_M4_ContractMissionGenerator.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playerId", "templateId", "seedBucket"],
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Target player id for deterministic contractInstanceId suffix."
|
||||
},
|
||||
"templateId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Contract template id from content/contracts; omit selection logic uses band + standing filters."
|
||||
},
|
||||
"seedBucket": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Deterministic rotation bucket (date string or client counter) for audit and instance id hashing."
|
||||
},
|
||||
"zoneDifficultyBand": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Optional issue-time band override; prototype defaults to 1 when omitted."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/contract-template.json",
|
||||
"title": "ContractTemplate",
|
||||
"description": "Single contract template row for catalogs (e.g. content/contracts/*_contract_templates.json). Repeatable mission skeleton keyed by zone difficulty band. See docs/decomposition/modules/E7_M4_ContractMissionGenerator.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"displayName",
|
||||
"zoneDifficultyBand",
|
||||
"objectiveKind",
|
||||
"encounterTemplateId",
|
||||
"minFactionStanding",
|
||||
"completionRewardBundle"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Immutable template key for issuance APIs and telemetry."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating instance data."
|
||||
},
|
||||
"zoneDifficultyBand": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Difficulty band used to filter eligible templates at issue time (E4.M1 live zone deferred in Slice 4 v1)."
|
||||
},
|
||||
"objectiveKind": {
|
||||
"type": "string",
|
||||
"enum": ["encounter_clear"],
|
||||
"description": "Objective discriminator; Slice 4 v1 binds encounterTemplateId only."
|
||||
},
|
||||
"encounterTemplateId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Encounter template id from content/encounters (E5.M3)."
|
||||
},
|
||||
"minFactionStanding": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/faction-gate-rule.json",
|
||||
"description": "Minimum faction standing required to issue this template."
|
||||
},
|
||||
"completionRewardBundle": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/quest-reward-bundle.json",
|
||||
"description": "Repeat completion payout delivered via RewardRouterOperations (E7.M2); separate idempotency from quest and first-time encounter loot."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +89,8 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
- Generated contracts validate against economy and parity rules.
|
||||
- Telemetry hooks: `contract_issued`, `contract_complete`, reward anomalies.
|
||||
|
||||
**Linear backlog:** [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Verify repeatable contract issue + payout in **Godot**, not Bruno-only.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Risk: Quest scripting becomes engineering-heavy for solo dev.
|
||||
|
|
@ -103,4 +105,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.
|
||||
- Slice 3 faction rep + gated quest line visible in Godot (capstone [NEO-143](../../manual-qa/NEO-143.md)), not Bruno-only.
|
||||
- Slice 4 repeatable contract issue + encounter-clear payout visible in Godot (capstone [NEO-154](../../manual-qa/NEO-154.md)), not Bruno-only.
|
||||
- Pre-production adds contracts (E7.M4) with validation beyond Slice 3.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
|
|||
|
||||
**Quest completion bundles ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** optional **`completionRewardBundle`** on each quest row via [`quest-reward-bundle.schema.json`](../../../content/schemas/quest-reward-bundle.schema.json) (`itemGrants` reuses [`reward-grant-row.schema.json`](../../../content/schemas/reward-grant-row.schema.json); `skillXpGrants` via [`quest-skill-xp-grant.schema.json`](../../../content/schemas/quest-skill-xp-grant.schema.json)). E7 Slice 2 requires bundles on all four frozen quest ids with exact freeze-table contents; cross-checks **`itemGrants[].itemId`** and **`skillXpGrants[].skillId`**; each skill XP target must allow **`mission_reward`** in **`allowedXpSourceKinds`**.
|
||||
|
||||
**Contract template catalogs ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** the same script validates each row in `content/contracts/*_contract_templates.json` against [`content/schemas/contract-template.schema.json`](../../../content/schemas/contract-template.schema.json) (`completionRewardBundle` via [`quest-reward-bundle.schema.json`](../../../content/schemas/quest-reward-bundle.schema.json); `minFactionStanding` via [`faction-gate-rule.schema.json`](../../../content/schemas/faction-gate-rule.schema.json)), rejects **duplicate `id`** across files, cross-checks **`encounterTemplateId`** against encounter catalogs and bundle item/skill/faction refs against frozen catalogs, enforces the **frozen one-template** id set (`prototype_contract_clear_combat_pocket`) with exact freeze-table contents (including empty **`reputationGrants`**), and enforces **band-1 economy caps** (item qty ≤ 10, skill XP ≤ 25, rep ≤ 10 per grant row). Issue/audit shape: [`contract-seed.schema.json`](../../../content/schemas/contract-seed.schema.json) — minimal fixture smoke parse in CI (no catalog file yet). **Server startup ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** the host mirrors the same gates via `ContractTemplateCatalogLoader` before listening (see [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145)).
|
||||
|
||||
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.
|
||||
|
||||
## Source anchors
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E7.M4 |
|
||||
| **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 — Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md): **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 zone graph deferred (content-only `zoneDifficultyBand` in Slice 4 v1). |
|
||||
|
||||
## Purpose
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ Semi-procedural repeatable contracts from templates, keyed by zone difficulty ([
|
|||
|
||||
## Module dependencies
|
||||
|
||||
- **E4.M1** — ZoneGraphAndTravelRules.
|
||||
- **E4.M1** — ZoneGraphAndTravelRules (live zone read deferred; content band in Slice 4 v1).
|
||||
- **E5.M3** — EncounterAndRewardTables.
|
||||
- **E7.M3** — FactionReputationLedger.
|
||||
|
||||
|
|
@ -37,7 +37,23 @@ Semi-procedural repeatable contracts from templates, keyed by zone difficulty ([
|
|||
|
||||
## Related implementation slices
|
||||
|
||||
Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
|
||||
Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies. Backlog: [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md).
|
||||
|
||||
## Prototype Slice 4 freeze (E7M4-01)
|
||||
|
||||
**One** frozen template; objective **`encounter_clear`** on **`prototype_combat_pocket`**; repeat payout via **`completionRewardBundle`** (separate idempotency from quest + first-time encounter loot).
|
||||
|
||||
| `ContractTemplate.id` | `zoneDifficultyBand` | `encounterTemplateId` | `minFactionStanding` | `completionRewardBundle` |
|
||||
|-----------------------|----------------------|------------------------|----------------------|---------------------------|
|
||||
| **`prototype_contract_clear_combat_pocket`** | **1** | **`prototype_combat_pocket`** | **`prototype_faction_grid_operators`** **0** | **`scrap_metal_bulk` ×5**; **`salvage`** **15** (`mission_reward`) |
|
||||
|
||||
**Band policy (E7M4-07):** band **1** caps — max **10** per item grant, **25** skill XP per grant, **10** rep per grant.
|
||||
|
||||
**Idempotency:** `{playerId}:contract_complete:{contractInstanceId}`. **Active cap:** one active contract per player (prototype).
|
||||
|
||||
**CI (NEO-144):** `content/contracts/prototype_contract_templates.json` validated by `scripts/validate_content.py` — schema, single-template roster, freeze table, encounter/item/skill/faction cross-refs, band-1 economy caps. Schemas: [`contract-template.schema.json`](../../../content/schemas/contract-template.schema.json), [`contract-seed.schema.json`](../../../content/schemas/contract-seed.schema.json).
|
||||
|
||||
**Server load (NEO-145):** host fail-fast load of `content/contracts/*_contract_templates.json` via `ContractTemplateCatalogLoader` + **`IContractTemplateRegistry`** — same E7M4 gates as CI at startup; [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145).
|
||||
|
||||
## Source anchors
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -116,7 +116,9 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
| E7.M3 | FactionReputationLedger | E7.M1, E7.M2 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Ready |
|
||||
|
||||
**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) **landed**; 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`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete.
|
||||
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
|
||||
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | In Progress |
|
||||
|
||||
**E7.M4 note:** Epic 7 **Slice 4** 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-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154).
|
||||
|
||||
### Epic 8 — Social / Guild
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ Working backlog for **Epic 7 — Slice 3** ([faction ledger](../decomposition/ep
|
|||
| 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.
|
||||
**Downstream (separate modules):** [E7.M4](../decomposition/modules/E7_M4_ContractMissionGenerator.md) contract generator — [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)–[NEO-154](https://linear.app/neon-sprawl/issue/NEO-154)); [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) **`TravelRule`** faction gates; [E8.M2](../decomposition/modules/E8_M2_GuildCorpProgressionState.md) guild/corp rep coupling.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,382 @@
|
|||
# E7.M4 — Pre-production story backlog (ContractMissionGenerator)
|
||||
|
||||
Working backlog for **Epic 7 — Slice 4** ([contract generator](../decomposition/epics/epic_07_quest_faction.md#slice-4---contract-generator-pre-production)). Decomposition and contracts: [E7.M4 — ContractMissionGenerator](../decomposition/modules/E7_M4_ContractMissionGenerator.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: [E7M3 (paired server+client)](../plans/E7M3-pre-production-backlog.md), [E7M2 (reward capstone)](../plans/E7M2-prototype-backlog.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E7.M4`**; add **`server`** or **`client`** + **`Story`** as listed.
|
||||
|
||||
**Precursor (do not re-scope):** [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) **Ready** — static quest catalog + accept/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)). [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) **Ready** — faction standing + quest gates ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)–[NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)). [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) **Ready** — **`prototype_combat_pocket`** encounter clear ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)–[NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)). Slice 4 **adds repeatable contract instances**; does not migrate static quests or first-time encounter loot.
|
||||
|
||||
**Upstream (must be landed):** E7.M1 quest state; E7.M2 **`RewardRouterOperations`** + **`IRewardDeliveryStore`**; E7.M3 **`IFactionStandingStore`**; E5.M3 encounter progress/completion. [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md) solo idempotency.
|
||||
|
||||
**E4.M1 note (zone difficulty):** Epic 7 Slice 4 AC keys contracts by **zone difficulty band**. Full [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) zone graph is **not** a Slice 4 blocker — prototype uses **content-only `zoneDifficultyBand`** on templates and optional **`zoneDifficultyBand`** on issue POST until E4.M1 **`GET …/zone`** lands; replace stub input with live zone read when E4.M1 Slice 1 lands.
|
||||
|
||||
**Prototype contract spine (frozen in E7M4-01):** **one** frozen **`ContractTemplate`** id; **one** encounter objective (**`prototype_combat_pocket`**); **one** scaled repeat payout bundle; issuance filtered by **zone difficulty band** + optional **faction standing** floor.
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts.
|
||||
|
||||
| Slug | Layer | Linear |
|
||||
|------|-------|--------|
|
||||
| E7M4-01 | server | [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) |
|
||||
| E7M4-02 | server | [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) |
|
||||
| E7M4-03 | server | [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) |
|
||||
| E7M4-04 | server | [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) |
|
||||
| E7M4-05 | server | [NEO-148](https://linear.app/neon-sprawl/issue/NEO-148) |
|
||||
| E7M4-06 | server | [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) |
|
||||
| E7M4-07 | server | [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) |
|
||||
| E7M4-08 | server | [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) |
|
||||
| E7M4-09 | server | [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152) |
|
||||
| E7M4-10 | client | [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) |
|
||||
| E7M4-11 | client | [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) |
|
||||
|
||||
**Dependency graph in Linear:** E7M4-02 blocked by E7M4-01. E7M4-03 blocked by E7M4-02. E7M4-04 blocked by E7M4-03. E7M4-05 blocked by E7M4-02. E7M4-06 blocked by E7M4-04 and E7M4-05. E7M4-07 blocked by E7M4-04. E7M4-08 blocked by E7M4-06 and E7M4-07. E7M4-09 blocked by E7M4-04 and E7M4-08. E7M4-10 blocked by E7M4-08 and E7M4-09. E7M4-11 blocked by E7M4-10.
|
||||
|
||||
**Board order:** estimates **1–11** matching slug order (E7M4-01 = 1 … E7M4-11 = 11). On the Epic 7 project board, sort by **Estimate** (ascending).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Layer | Depends on |
|
||||
|-------|------|-------|------------|
|
||||
| 1 | **E7M4-01** | server | E7.M2 bundle schemas; E5.M3 encounter freeze |
|
||||
| 2 | **E7M4-02** | server | E7M4-01 |
|
||||
| 3 | **E7M4-03** | server | E7M4-02 |
|
||||
| 4 | **E7M4-04** | server | E7M4-03 |
|
||||
| 5 | **E7M4-05** | server | E7M4-02 |
|
||||
| 6 | **E7M4-06** | server | E7M4-04, E7M4-05 |
|
||||
| 7 | **E7M4-07** | server | E7M4-04 |
|
||||
| 8 | **E7M4-08** | server | E7M4-06, E7M4-07 |
|
||||
| 9 | **E7M4-09** | server | E7M4-04, E7M4-08 |
|
||||
| 10 | **E7M4-10** | client | E7M4-08, E7M4-09 |
|
||||
| 11 | **E7M4-11** | client | E7M4-10 |
|
||||
|
||||
**Downstream (separate modules):** [E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md) live zone id for issuance input; [E6.M4](../decomposition/modules/E6_M4_RewardParityEnforcer.md) full parity map; [E3.M5](../decomposition/modules/E3_M5_EconomyBalancePolicy.md) faucet/sink dashboards.
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| Contract vs quest | **Separate `ContractInstance` state** — not new static `QuestDef` rows | Repeatable midgame jobs without polluting onboarding catalog |
|
||||
| Template count | **One** frozen template id | Readable Slice 4 capstone; generator API still general |
|
||||
| Objective kind (v1) | **`encounter_clear`** — bind to E5.M3 **`encounterId`** | Reuses combat pocket loop players already know |
|
||||
| Zone difficulty | **Content `zoneDifficultyBand` on template** + optional POST override until E4.M1 | Epic AC without blocking on travel graph |
|
||||
| Faction filter | **Optional `minFactionStanding` on template** — prototype **0** (open) | Standing-based template pick deferred to second template in follow-on |
|
||||
| Issuance cap | **One active contract per player** (prototype) | Avoid HUD/stack complexity in Slice 4 |
|
||||
| Seed / determinism | **`ContractSeed`**: `playerId` + `templateId` + `seedBucket` (date or client counter) → deterministic **`contractInstanceId`** suffix for audit | Supports daily rotation without full proc-gen |
|
||||
| Completion payout | **`completionRewardBundle`** on template — deliver via extended **`RewardRouterOperations`** | Reuse E7.M2 idempotency + inventory/XP/rep apply |
|
||||
| Idempotency key | **`{playerId}:contract_complete:{contractInstanceId}`** | Per-instance repeat grants; distinct from quest and encounter keys |
|
||||
| Encounter loot | **Unchanged** — first-time **`prototype_combat_pocket_clear`** idempotent per player | Contract payout is **additional** repeat incentive, not double first-clear |
|
||||
| Economy validation | **Prototype band caps** in content + issue-time lint (not full E6.M4) | Slice 4 AC: generated contracts validate against economy rules |
|
||||
| Party mode | **Solo only** | E8.M1 not landed |
|
||||
| HUD fidelity | Prototype labels / debug panels | NEO-122 / NEO-142 precedent |
|
||||
|
||||
### Prototype contract template freeze (E7M4-01)
|
||||
|
||||
| `ContractTemplate.id` | `zoneDifficultyBand` | `encounterTemplateId` | `minFactionStanding` | `completionRewardBundle` |
|
||||
|-----------------------|----------------------|------------------------|----------------------|---------------------------|
|
||||
| **`prototype_contract_clear_combat_pocket`** | **1** | **`prototype_combat_pocket`** | **`prototype_faction_grid_operators`** **0** | **`scrap_metal_bulk` ×5**; **`salvage`** **15** (`mission_reward`) |
|
||||
|
||||
**Band policy (E7M4-07):** difficulty band **1** caps: max **10** per item grant row, max **25** skill XP per grant, max **10** rep per grant (enforced at issue + CI).
|
||||
|
||||
**Issuance:** `POST …/contracts/issue` with optional **`zoneDifficultyBand`** (default **1** for prototype district). Server selects templates where `zoneDifficultyBand` matches and player standing ≥ `minFactionStanding`. Returns **`contractInstanceId`**, template summary, objective encounter id, status **`active`**.
|
||||
|
||||
**Completion:** When **`EncounterCompletionOperations`** marks **`prototype_combat_pocket`** complete for a player with matching **active** contract instance → **`ContractCompletionOperations`** delivers bundle → status **`completed`** → record **`ContractOutcome`**.
|
||||
|
||||
---
|
||||
|
||||
### E7M4-01 — ContractTemplate catalog + schemas + CI
|
||||
|
||||
**Goal:** Lock contract content shape and CI validation before server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/contract-template.schema.json`, `content/schemas/contract-seed.schema.json` (issue request / audit shape).
|
||||
- Reuse **`quest-reward-bundle`** / **`reputation-grant-row`** `$ref`s for **`completionRewardBundle`** on templates.
|
||||
- `content/contracts/prototype_contract_templates.json` — one frozen template row per freeze table.
|
||||
- `scripts/validate_content.py`: contract schema validation, encounter cross-ref, item/skill/faction cross-refs, band-cap lint, single-template allowlist.
|
||||
- Designer note in [E7_M4](../decomposition/modules/E7_M4_ContractMissionGenerator.md) + `content/README.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, stores, operations, HTTP, Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates contract template JSON.
|
||||
- [x] Exactly one prototype template id with encounter + bundle cross-refs.
|
||||
- [x] Bundle exceeding band caps fails CI.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-02 — Server contract template catalog load (fail-fast)
|
||||
|
||||
**Goal:** Disk → host: startup load of `content/contracts/*_contract_templates.json` with CI-parity validation.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Loader + registry types under `server/NeonSprawl.Server/Game/Contracts/`.
|
||||
- **`IContractTemplateRegistry`** + DI registration.
|
||||
- Cross-check **`encounterTemplateId`** vs **`IEncounterDefinitionRegistry`** at startup.
|
||||
- Unit tests (AAA) for happy path and broken encounter refs.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Per-player instance store, generator, HTTP.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Host exits on invalid contract catalog or broken encounter refs at startup.
|
||||
- [x] Registry resolves template metadata by id.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-03 — Contract instance + outcome stores
|
||||
|
||||
**Goal:** Durable per-player **`ContractInstance`** (active/completed) and **`ContractOutcome`** audit records.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`IContractInstanceStore`**, **`IContractOutcomeStore`** — in-memory implementations; optional Postgres migrations (NEO-116-style split).
|
||||
- Instance row: `contractInstanceId`, `templateId`, `playerId`, `status` (`active` | `completed` | `expired`), `seedBucket`, `issuedAt`, optional `completedAt`.
|
||||
- Outcome row: instance id, delivery idempotency key, grant snapshot summary.
|
||||
- Unit tests: issue, complete, idempotent complete replay.
|
||||
- `server/README.md` store sections.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Generator logic (E7M4-04), payout apply (E7M4-05).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] At most one **`active`** instance per player (prototype policy).
|
||||
- [ ] Completed instances immutable; outcome append-only.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-04 — `ContractGeneratorOperations.TryIssue`
|
||||
|
||||
**Goal:** Issue a validated contract instance from **`ContractSeed`** + player context.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`ContractGeneratorOperations.TryIssue`** in `server/NeonSprawl.Server/Game/Contracts/`.
|
||||
- Inputs: `playerId`, optional `templateId`, `seedBucket`, `zoneDifficultyBand` (default 1).
|
||||
- Template selection: match band + faction standing floor; fail **`no_eligible_template`** when none.
|
||||
- Deny **`active_contract_exists`** when player already has active instance.
|
||||
- Deterministic `contractInstanceId` from seed hash + player + template.
|
||||
- Unit + integration tests (AAA): happy issue, deny paths, unknown template fail-closed.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP (E7M4-08), encounter completion wiring (E7M4-06).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Issue creates **`active`** instance with correct encounter binding.
|
||||
- [ ] Second issue while active returns structured deny.
|
||||
|
||||
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (blocked by E7M4-08).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-05 — Extend `RewardRouterOperations` for contract completion bundles
|
||||
|
||||
**Goal:** Contract completion payouts reuse E7.M2 delivery idempotency with contract-specific keys.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`RewardRouterOperations.TryDeliverContractCompletion`** — same grant kinds as quest completion (items, skill XP, rep) with key **`{playerId}:contract_complete:{contractInstanceId}`**.
|
||||
- Extend **`IRewardDeliveryStore`** / **`RewardDeliveryEvent`** for contract source kind (`contract_completion`).
|
||||
- Compensating rollback policy mirrors E7.M2 quest path.
|
||||
- Integration tests: first delivery, idempotent replay, unknown item/faction deny.
|
||||
- `server/README.md` reward router section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Encounter trigger (E7M4-06), HTTP projections (E7M4-08).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] First-time contract complete applies bundle exactly once per instance id.
|
||||
- [ ] Replay returns **`already_delivered`** without double grant.
|
||||
|
||||
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-06 — `ContractCompletionOperations` + encounter wiring
|
||||
|
||||
**Goal:** Encounter clear completes matching active contract and triggers payout.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — match **`encounterTemplateId`** on active instance.
|
||||
- Wire from **`EncounterCompletionOperations`** / **`EncounterCombatWiring`** success path (after encounter completion commit, before or after encounter loot — document order: encounter loot first, contract payout second).
|
||||
- Mark instance **`completed`**; record **`ContractOutcome`**; invoke **`TryDeliverContractCompletion`**.
|
||||
- Integration tests: issue → encounter clear → contract completed + delivery; clear without contract unchanged; wrong encounter no-op.
|
||||
- Bruno smoke: issue → encounter-progress clear → contract GET shows completed.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot HUD (E7M4-10).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Active contract completes when bound encounter clears.
|
||||
- [ ] Encounter first-time loot idempotency unchanged (E5.M3).
|
||||
|
||||
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153), [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-07 — Economy validation lint at issue time
|
||||
|
||||
**Goal:** Slice 4 AC — generated/issued contracts validate against prototype economy caps.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`ContractEconomyValidation.TryValidateTemplate`** — band caps from content policy (item qty, skill XP, rep per grant row).
|
||||
- Call from **`TryIssue`** before instance persist; deny **`economy_cap_exceeded`** fail-closed.
|
||||
- Cross-ref unknown item/skill/faction ids (registry lookups).
|
||||
- Unit tests: at-cap pass, over-cap deny, unknown id deny.
|
||||
- Document parity stub toward E6.M4 / E3.M5 in module doc (full map deferred).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Full **`RewardParityMap`** (E6.M4); live ops dashboards (E9.M2).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Template at freeze caps issues successfully.
|
||||
- [ ] Tampered catalog row over caps fails at startup (CI) and cannot issue if slipped through.
|
||||
|
||||
**Client counterpart:** none (server policy).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-08 — Contract issue POST + per-player contract GET
|
||||
|
||||
**Goal:** Client-readable contract issuance and state without local math.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`POST /game/players/{id}/contracts/issue`** — `ContractIssueApi` + DTOs.
|
||||
- **`GET /game/players/{id}/contracts`** — active + recent completed instances with template summary, objective encounter id, **`completionRewardSummary`** when delivered.
|
||||
- Integration tests + Bruno `bruno/neon-sprawl-server/contracts/`.
|
||||
- `server/README.md` sections.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot parse/HUD (E7M4-10).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] POST issue returns **`active`** instance JSON.
|
||||
- [ ] GET lists active contract with encounter objective id.
|
||||
- [ ] GET completed row includes delivery summary after encounter clear.
|
||||
|
||||
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-09 — Contract telemetry hook sites (comment-only)
|
||||
|
||||
**Goal:** Anchor future E9.M1 catalog events without ingest.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Comment-only **`contract_issued`** in **`ContractGeneratorOperations.TryIssue`** (successful issue).
|
||||
- Comment-only **`contract_complete`** in **`ContractCompletionOperations`** (successful complete + delivery).
|
||||
- Comment-only **`reward_anomaly`** stub at economy validation deny + delivery rollback paths.
|
||||
- `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).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-10 — Client contract issue + progress HUD (Godot)
|
||||
|
||||
**Goal:** Player can issue and track an active contract in Godot without Bruno.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`contract_client.gd`**, **`ContractActiveLabel`**, **`ContractIssueFeedbackLabel`** (or extend quest HUD section).
|
||||
- **`POST …/contracts/issue`** on agreed binding (e.g. **Shift+C**); **`GET …/contracts`** on boot + after encounter-complete refresh (reuse **`encounter_progress_client.gd`** signal or quest HUD controller pattern).
|
||||
- Surface issue denies (`active_contract_exists`, `no_eligible_template`, `economy_cap_exceeded`) with readable copy.
|
||||
- GdUnit parse tests; `client/README.md` section.
|
||||
- `docs/manual-qa/NEO-153.md` component checklist.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final contract board UI; zone picker (E4.M1).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Active contract visible with encounter objective id after issue.
|
||||
- [ ] Completed contract shows reward summary line on HUD.
|
||||
|
||||
**Server counterpart:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150), [NEO-152](https://linear.app/neon-sprawl/issue/NEO-152).
|
||||
|
||||
---
|
||||
|
||||
### E7M4-11 — Playable repeatable contract capstone (Godot)
|
||||
|
||||
**Goal:** Prove Epic 7 Slice 4 acceptance **in Godot**: issue contract, clear combat pocket, receive repeat payout; economy validation holds.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`docs/manual-qa/NEO-154.md`**: single-session capstone — extend [NEO-143](../manual-qa/NEO-143.md) or [NEO-111](../manual-qa/NEO-111.md) encounter path with **Shift+C** issue → clear pocket → contract reward **`scrap_metal_bulk` ×5** + salvage XP → re-issue after complete → second full loop.
|
||||
- **`client/README.md`**: **End-to-end repeatable contract loop (NEO-154)** section; cross-links NEO-144–NEO-153 and E5.M3 encounter capstone.
|
||||
- Module alignment on completion: [E7_M4](../decomposition/modules/E7_M4_ContractMissionGenerator.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**
|
||||
|
||||
- Multi-template rotation; E4.M1 zone travel; Bruno-only proof.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Human completes **`docs/manual-qa/NEO-154.md`** with server + client.
|
||||
- [ ] Epic 7 Slice 4 AC: issued contract validates economy caps; repeat completion idempotent per instance.
|
||||
- [ ] Re-read [epic_07 Slice 4 AC](../decomposition/epics/epic_07_quest_faction.md#slice-4---contract-generator-pre-production).
|
||||
|
||||
**Client counterpart:** this story **is** the Slice 4 client capstone.
|
||||
|
||||
---
|
||||
|
||||
## Decomposition complete checklist
|
||||
|
||||
- [x] Every player-visible AC has a **`client`** Linear issue (NEO-153, NEO-154)
|
||||
- [x] No forbidden deferral language in backlog
|
||||
- [x] Capstone client story exists (E7M4-11 / NEO-154)
|
||||
- [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.M4 row updated (decomposition pass)
|
||||
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M4 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)
|
||||
- [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md)
|
||||
- [E5M3-prototype-backlog.md](E5M3-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,182 @@
|
|||
# NEO-144 — E7M4-01: ContractTemplate catalog + schemas + CI
|
||||
|
||||
**Linear:** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)
|
||||
**Branch:** `NEO-144-e7m4-01-contracttemplate-catalog-schemas-ci`
|
||||
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-01**
|
||||
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
|
||||
**Pattern:** [NEO-133](NEO-133-implementation-plan.md) (faction/quest schemas + catalog + `validate_content.py`); [NEO-124](NEO-124-implementation-plan.md) (`completionRewardBundle` reuse)
|
||||
**Precursors:** [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) **`Ready`** — **`prototype_combat_pocket`**; [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **`Ready`** — **`quest-reward-bundle`** / grant rows; [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) **`Ready`** — faction catalog + rep grants
|
||||
**Blocks:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) (server contract template catalog load)
|
||||
**Client counterpart:** none (infrastructure-only); playable loop is **NEO-154** (E7M4-11)
|
||||
|
||||
## Goal
|
||||
|
||||
Lock contract content shape and CI validation before server load (NEO-145+).
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
Decisions are frozen in [E7M4-pre-production-backlog.md — Kickoff decisions](E7M4-pre-production-backlog.md#kickoff-decisions) and [E7_M4 — Prototype Slice 4 freeze](../decomposition/modules/E7_M4_ContractMissionGenerator.md#prototype-slice-4-freeze-e7m4-01). No blocking `AskQuestion` items.
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|-------|----------|----------------------|--------|
|
||||
| — | — | — | **No blocking decisions** — freeze table, band caps, single-template roster, and `$ref` reuse are fully specified in backlog + module doc. |
|
||||
|
||||
**Additional defaults (no kickoff question — settled by backlog / landed code):**
|
||||
|
||||
- **JSON naming:** camelCase (`encounterTemplateId`, `zoneDifficultyBand`, `minFactionStanding`, `completionRewardBundle`, `itemGrants`, `skillXpGrants`) — matches quest/faction catalogs.
|
||||
- **`minFactionStanding` shape:** `$ref` [`faction-gate-rule.schema.json`](../../content/schemas/faction-gate-rule.schema.json) (`factionId` + `minStanding`) — prototype row **`prototype_faction_grid_operators`** / **0** (open issuance).
|
||||
- **`objectiveKind`:** required enum **`encounter_clear`** only in Slice 4 v1; pairs with **`encounterTemplateId`** (not quest-style objective graph).
|
||||
- **Catalog envelope:** **`schemaVersion`: 1**, top-level **`contractTemplates`** array — mirrors quest/faction file layout.
|
||||
- **Band caps in CI (E7M4-01):** implement **`PROTOTYPE_E7M4_BAND1_*`** constants + `_prototype_e7m4_band_cap_gate` in `validate_content.py` now (band **1**: item qty ≤ **10**, skill XP ≤ **25**, rep ≤ **10** per grant row); E7M4-07 reuses same policy at server issue time.
|
||||
- **Over-cap AC proof:** lint runs on live catalog (at-cap template passes); tampering any grant above cap fails `python scripts/validate_content.py` — no separate invalid fixture file (NEO-124 / NEO-133 precedent).
|
||||
- **Server loader / stores / HTTP / Godot:** **Out of scope** (NEO-145+).
|
||||
- **`dotnet test`:** **No server changes expected** — contract catalog is not loaded at startup until NEO-145.
|
||||
|
||||
## Scope and out-of-scope
|
||||
|
||||
**In scope (from Linear + [E7M4-01](E7M4-pre-production-backlog.md#e7m4-01--contracttemplate-catalog--schemas--ci)):**
|
||||
|
||||
- `content/schemas/contract-template.schema.json`, `content/schemas/contract-seed.schema.json` (issue/audit request shape for NEO-147+; no catalog JSON file in this story).
|
||||
- Reuse **`quest-reward-bundle`** / **`reputation-grant-row`** / **`reward-grant-row`** / **`quest-skill-xp-grant`** `$ref`s for **`completionRewardBundle`** on templates.
|
||||
- `content/contracts/prototype_contract_templates.json` — one frozen template row per module freeze table.
|
||||
- `scripts/validate_content.py`: contract schema validation; encounter / item / skill / faction cross-refs; single-template allowlist; template + bundle freeze gate; band-cap lint.
|
||||
- Designer note + CI line in [E7_M4](../decomposition/modules/E7_M4_ContractMissionGenerator.md); **`content/README.md`** E7 Slice 4 contracts paragraph; [CT.M1](../decomposition/modules/CT_M1_ContentValidationPipeline.md) contract-catalog PR gate bullet.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Server loader, `IContractTemplateRegistry`, instance store, generator, HTTP, Godot (NEO-145–NEO-154).
|
||||
- Live **`GET …/zone`** read (E4.M1); content-only **`zoneDifficultyBand`** on template + optional seed field.
|
||||
- Full E6.M4 parity map; server **`ContractEconomyValidation`** at issue time (NEO-147 / E7M4-07).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] PR gate validates contract template JSON.
|
||||
- [x] Exactly one prototype template id with encounter + bundle cross-refs.
|
||||
- [x] Bundle exceeding band caps fails CI.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Schemas:** [`contract-template.schema.json`](../../content/schemas/contract-template.schema.json), [`contract-seed.schema.json`](../../content/schemas/contract-seed.schema.json) — `$ref`s to `quest-reward-bundle`, `faction-gate-rule`, grant rows.
|
||||
- **Catalog:** [`prototype_contract_templates.json`](../../content/contracts/prototype_contract_templates.json) — one frozen template per E7M4-01 freeze table.
|
||||
- **CI:** `PROTOTYPE_E7M4_*` gates in [`validate_content.py`](../../scripts/validate_content.py) — roster, freeze (incl. empty **`reputationGrants`** via E7M3 bundle normalizer), cross-refs (all catalog rows), band caps keyed by **`PROTOTYPE_E7M4_BAND_CAPS`**; **`contract-seed`** schema smoke fixture; contract catalog wired into PR gate `main()`.
|
||||
- **Review follow-up (2026-06-20):** freeze gate uses `_normalize_e7m3_completion_bundle`; cross-ref/band-cap gates iterate all loaded template rows; **`PROTOTYPE_E7M4_BAND_CAPS`** comment points to NEO-147 C# port.
|
||||
- **Docs:** `content/README.md` E7 Slice 4 paragraph; E7_M4 CI line; CT.M1 contract-catalog bullet; E7M4-01 backlog checkboxes.
|
||||
- **Server:** no changes (823 tests green — no contract loader until NEO-145).
|
||||
- **Band-cap negative proof:** `_prototype_e7m4_band_cap_gate` rejects `scrap_metal_bulk` ×11 locally (not committed).
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Prototype contract template freeze (E7M4-01)
|
||||
|
||||
| `ContractTemplate.id` | `zoneDifficultyBand` | `objectiveKind` | `encounterTemplateId` | `minFactionStanding` | `completionRewardBundle` |
|
||||
|-------------------------|----------------------|-----------------|-------------------------|----------------------|---------------------------|
|
||||
| **`prototype_contract_clear_combat_pocket`** | **1** | **`encounter_clear`** | **`prototype_combat_pocket`** | **`prototype_faction_grid_operators`** **0** | **`scrap_metal_bulk` ×5**; **`salvage`** **15** (`mission_reward`) |
|
||||
|
||||
**Band 1 caps (CI + future E7M4-07):** max **10** per item grant quantity; max **25** skill XP per grant; max **10** rep per grant row.
|
||||
|
||||
### 2. `contract-template.schema.json`
|
||||
|
||||
Draft 2020-12 object; `additionalProperties: false`. Required:
|
||||
|
||||
- **`id`** — pattern `^[a-z][a-z0-9_]*$`
|
||||
- **`displayName`** — non-empty string
|
||||
- **`zoneDifficultyBand`** — integer ≥ 1
|
||||
- **`objectiveKind`** — enum **`encounter_clear`** (Slice 4 v1 only)
|
||||
- **`encounterTemplateId`** — pattern `^[a-z][a-z0-9_]*$`
|
||||
- **`minFactionStanding`** — `$ref` faction-gate-rule (prototype uses Grid Operators **0**)
|
||||
- **`completionRewardBundle`** — `$ref` quest-reward-bundle
|
||||
|
||||
### 3. `contract-seed.schema.json`
|
||||
|
||||
Draft 2020-12 object for future **`POST …/contracts/issue`** body / audit (NEO-147+). Required: **`playerId`**, **`templateId`**, **`seedBucket`** (non-empty string — date bucket or client counter). Optional: **`zoneDifficultyBand`** (integer ≥ 1; prototype default **1** when omitted). No catalog file in E7M4-01 — schema-only so CI can `$ref` it from docs/tests later.
|
||||
|
||||
### 4. Catalog — `content/contracts/prototype_contract_templates.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contractTemplates": [
|
||||
{
|
||||
"id": "prototype_contract_clear_combat_pocket",
|
||||
"displayName": "Clear Combat Pocket (Repeat)",
|
||||
"zoneDifficultyBand": 1,
|
||||
"objectiveKind": "encounter_clear",
|
||||
"encounterTemplateId": "prototype_combat_pocket",
|
||||
"minFactionStanding": {
|
||||
"factionId": "prototype_faction_grid_operators",
|
||||
"minStanding": 0
|
||||
},
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 15 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. `validate_content.py` gates
|
||||
|
||||
Add **`CONTRACTS_DIR`**, schema paths, **`PROTOTYPE_E7M4_TEMPLATE_IDS`**, **`PROTOTYPE_E7M4_TEMPLATE_FREEZE`**, **`PROTOTYPE_E7M4_BAND_CAPS`** (band-keyed dict; supersedes early draft **`PROTOTYPE_E7M4_BAND1_*`** naming).
|
||||
|
||||
1. **`_contract_template_validator()`** — registry with contract-template + quest-reward-bundle + nested grant `$ref`s (same pattern as `_quest_def_validator`).
|
||||
2. **`_validate_contract_catalogs`** — load `content/contracts/*_contract_templates.json`; schema validate each row; reject duplicate **`id`**; require **`schemaVersion` 1**.
|
||||
3. **`_prototype_e7m4_template_roster_gate`** — exactly one id: **`prototype_contract_clear_combat_pocket`**.
|
||||
4. **`_prototype_e7m4_template_freeze_gate`** — row matches freeze table (band, objective kind, encounter id, min standing, bundle contents).
|
||||
5. **`_prototype_e7m4_cross_ref_gate`** — **`encounterTemplateId`** ∈ loaded encounter catalog; bundle **`itemId`** / **`skillId`** ∈ frozen item/skill catalogs; **`minFactionStanding.factionId`** ∈ faction catalog; skill XP targets list **`mission_reward`** in **`allowedXpSourceKinds`** (E7M2 rule).
|
||||
6. **`_prototype_e7m4_band_cap_gate`** — for each template, compare **`zoneDifficultyBand`** to cap table; fail when any item qty, skill XP amount, or rep amount exceeds band limits (prototype band **1** only).
|
||||
|
||||
Wire into **`main()`** after encounter + faction validation (cross-refs need encounter and faction maps) and before or after quest validation (order agnostic; prefer after faction, alongside quest bundle rules).
|
||||
|
||||
Update module docstring at top of `validate_content.py` to list contract catalogs (NEO-144).
|
||||
|
||||
### 6. Documentation
|
||||
|
||||
- **`content/README.md`:** add **`contracts/`** table row + **Prototype E7 Slice 4 — contracts (NEO-144)** paragraph (one template id, encounter cross-ref, repeat payout bundle, band-cap CI).
|
||||
- **`E7_M4_ContractMissionGenerator.md`:** add CI landed line under freeze section when complete (catalog + schemas + validate_content gates).
|
||||
- **`CT_M1_ContentValidationPipeline.md`:** bullet for contract template validation (schema, roster, freeze, cross-refs, band caps).
|
||||
|
||||
### 7. Implementation order
|
||||
|
||||
1. Schemas (`contract-template`, `contract-seed`).
|
||||
2. `prototype_contract_templates.json`.
|
||||
3. `validate_content.py` validator + gates + constants.
|
||||
4. Docs (`content/README.md`, E7_M4, CT.M1).
|
||||
5. Run `python3 scripts/validate_content.py` and `dotnet test` (expect green — no server catalog load yet).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-144-implementation-plan.md` | This plan. |
|
||||
| `content/schemas/contract-template.schema.json` | `ContractTemplate` row shape with `$ref`s to bundle + faction gate rule. |
|
||||
| `content/schemas/contract-seed.schema.json` | Issue/audit request shape for NEO-147+ (schema-only in this story). |
|
||||
| `content/contracts/prototype_contract_templates.json` | One frozen template row per E7M4-01 freeze table. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `scripts/validate_content.py` | Contract catalog load, schema registry, roster/freeze/cross-ref/band-cap gates; module docstring. |
|
||||
| `content/README.md` | Document `content/contracts/`, new schemas, Slice 4 prototype template + band-cap CI. |
|
||||
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | CI/catalog landed note under freeze section. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for contract templates. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Layer | What |
|
||||
|-------|------|
|
||||
| CI | `python3 scripts/validate_content.py` — primary AC surface (schema, roster, freeze, cross-refs, band caps on live catalog). |
|
||||
| Manual negative | Edit template bundle to **`scrap_metal_bulk` ×11** (or **`salvage` 26**) locally → validator exits non-zero (documents over-cap AC without committed bad data). |
|
||||
| Server | **`dotnet test`** — regression only; no new `[Fact]` expected (no server contract loader until NEO-145). |
|
||||
|
||||
Manual Godot QA: **none** (content-only; capstone is NEO-154).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Band caps in E7M4-01 vs E7M4-07** | CI lint in E7M4-01; server **`TryIssue`** re-validates in E7M4-07 with shared cap constants ported to C#. **NEO-145 kickoff:** mirror **`PROTOTYPE_E7M4_BAND_CAPS`** keys in C# `ContractEconomyValidation`. | **adopted** |
|
||||
| **`contract-seed` without catalog JSON** | Schema-only in E7M4-01 per backlog; validated when HTTP DTOs land in NEO-147. | **adopted** |
|
||||
| **`minFactionStanding` optional on future templates** | Required on prototype row; schema requires field for v1 (single open template). Relax to optional when second template lands. | **adopted** |
|
||||
| **Rep grants on contract bundles** | Prototype bundle is item + skill XP only; band-cap gate still checks **`reputationGrants`** if present (forward-compatible). | **adopted** |
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
# NEO-145 — E7M4-02: Server contract template catalog load (fail-fast)
|
||||
|
||||
**Linear:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)
|
||||
**Branch:** `NEO-145-e7m4-02-server-contract-template-catalog-load-fail-fast`
|
||||
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-02**
|
||||
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
|
||||
**Pattern:** [NEO-134-implementation-plan.md](NEO-134-implementation-plan.md) (faction catalog load + CI-parity gates); [NEO-113-implementation-plan.md](NEO-113-implementation-plan.md) (quest catalog cross-ref deps); [NEO-101-implementation-plan.md](NEO-101-implementation-plan.md) (encounter registry + DI)
|
||||
**Precursor:** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) **`Done`** — `contract-template` + `contract-seed` schemas, `prototype_contract_templates.json`, E7M4 CI gates (**merged to `main`**)
|
||||
**Blocks:** [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) (contract instance + outcome stores)
|
||||
**Client counterpart:** none (infrastructure-only); playable loop is **NEO-154** (E7M4-11)
|
||||
|
||||
## Goal
|
||||
|
||||
Fail-fast startup load of `content/contracts/*_contract_templates.json` with **CI-parity** validation; cross-check **`encounterTemplateId`** and bundle refs against loaded catalogs. Host refuses to listen when contract content is missing, malformed, or fails prototype E7M4 gates. **`IContractTemplateRegistry`** resolves template metadata by id for E7M4-04+.
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No blocking decisions needed.** Linear AC, [E7M4-02 backlog scope](E7M4-pre-production-backlog.md#e7m4-02--server-contract-template-catalog-load-fail-fast), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), NEO-144 shipped CI gates, and NEO-134/NEO-113 precedents settle:
|
||||
|
||||
| Topic | Decision | Evidence |
|
||||
|-------|----------|----------|
|
||||
| CI parity scope | Full E7M4 gates on server (schema, roster, freeze, cross-ref, band caps) | Backlog “CI-parity validation”; NEO-134 full E7M3 gate precedent |
|
||||
| Registry in E7M4-02 | Ship **`IContractTemplateRegistry`** + DI now | Backlog in-scope list; unlike E5M3-02/03 split, E7M4-02 explicitly includes registry |
|
||||
| Runtime validation | In-process **C#** mirroring `scripts/validate_content.py` | NEO-51/134 precedent; no Python on server images |
|
||||
| Cross-ref deps | Encounter, item, skill, faction catalogs at load | Python `_prototype_e7m4_cross_ref_gate`; quest E7M2 `mission_reward` allowlist on skill XP |
|
||||
| Load order | After item, skill, faction, encounter, quest catalogs | All cross-ref sources resolved; contracts do not affect quest load |
|
||||
| Band caps | Port **`PROTOTYPE_E7M4_BAND_CAPS`** to C# in loader gates now | NEO-144 review + plan open question; E7M4-07 reuses same constants at issue time |
|
||||
| Bundle DTO reuse | Reuse **`QuestRewardBundleRow`**, **`FactionGateRuleRow`** from `Game/Quests/` | Same `$ref`s as quest catalog; avoids duplicate grant row types |
|
||||
| Instance store / generator / HTTP / Godot | **Out of scope** | Backlog E7M4-02; NEO-146+ |
|
||||
|
||||
## Scope and out-of-scope
|
||||
|
||||
**In scope (from Linear + backlog):**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Contracts/`.
|
||||
- **`IContractTemplateRegistry`** + DI registration.
|
||||
- Cross-check **`encounterTemplateId`** vs loaded encounter catalog; bundle item/skill/faction refs + **`mission_reward`** allowlist (CI parity).
|
||||
- **`PrototypeE7M4ContractCatalogRules`** — roster, freeze, cross-ref, band-cap gates synced to `scripts/validate_content.py` `PROTOTYPE_E7M4_*` + `_prototype_e7m4_*`.
|
||||
- `ContentPathsOptions` + path resolution for `content/contracts/`.
|
||||
- Unit tests (AAA) for loader happy path and failure modes (broken encounter ref, roster/freeze/band-cap); host boot resolves contract catalog.
|
||||
- `server/README.md` contract catalog section.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Per-player **`ContractInstance`** store, generator, HTTP (**NEO-146**–**NEO-154**).
|
||||
- Issue-time economy re-validation (**NEO-147** / E7M4-07) — band-cap **constants** land here; **`TryIssue`** enforcement is later.
|
||||
- Godot client.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Host exits on invalid contract catalog or broken encounter refs at startup.
|
||||
- [x] Registry resolves template metadata by id.
|
||||
- [x] `dotnet test` covers loader gates.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Contract catalog:** `Game/Contracts/` loader, catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` (roster, freeze, cross-ref, band caps synced to `PROTOTYPE_E7M4_*`), DI + `Program.cs` load order (after quest catalog).
|
||||
- **Config:** `ContentPathsOptions` `ContractsDirectory` / `ContractTemplateSchemaPath`; `InMemoryWebApplicationFactory` pins repo `content/contracts`.
|
||||
- **Tests:** `ContractTemplateCatalogLoaderTests`, `ContractTemplateRegistryTests`, host boot via `Host_ShouldResolveContractCatalogFromDi` (839 tests green).
|
||||
- **Docs:** `server/README.md` contract catalog section; decomposition alignment (E7_M4, CT_M1, register tables).
|
||||
- **Bruno:** `bruno/neon-sprawl-server/contract-catalog/` health smoke (startup-only; no contract HTTP in E7M4-02).
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Contract DTOs and catalog (`Game/Contracts/`)
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| `ContractTemplateRow` | `Id`, `DisplayName`, `ZoneDifficultyBand`, `ObjectiveKind`, `EncounterTemplateId`, `MinFactionStanding` (`FactionGateRuleRow`), `CompletionRewardBundle` (`QuestRewardBundleRow`) |
|
||||
| `ContractTemplateCatalog` | Immutable `ById` map + directory metadata (mirror `FactionDefinitionCatalog`) |
|
||||
| `IContractTemplateRegistry` / `ContractTemplateRegistry` | `TryGetDefinition`, `GetDefinitionsInIdOrder` (mirror `IEncounterDefinitionRegistry`) |
|
||||
|
||||
### 2. `ContractTemplateCatalogLoader`
|
||||
|
||||
Mirror `FactionDefinitionCatalogLoader` + quest bundle parsing:
|
||||
|
||||
- Enumerate `*_contract_templates.json` under contracts directory; require `schemaVersion` 1 and top-level `contractTemplates` array.
|
||||
- JSON Schema validate each row against `contract-template.schema.json` with nested `$ref`s (`quest-reward-bundle`, `faction-gate-rule`, grant row schemas — same registration pattern as quest loader).
|
||||
- Reject duplicate template ids across files.
|
||||
- Run `PrototypeE7M4ContractCatalogRules` gates (below).
|
||||
- Throw `InvalidOperationException` with sorted `error:` lines on failure.
|
||||
|
||||
**Loader inputs** (from resolved singletons):
|
||||
|
||||
- `knownEncounterIds` — `EncounterDefinitionCatalog.ById.Keys`
|
||||
- `knownItemIds` — `ItemDefinitionCatalog.ById.Keys` (or `PrototypeSlice1ItemCatalogRules.ExpectedItemIds` for parity with CI)
|
||||
- `knownFactionIds` — `FactionDefinitionCatalog.ById.Keys`
|
||||
- `skillDefsById` — `SkillDefinitionCatalog.ById` (for `mission_reward` allowlist)
|
||||
|
||||
### 3. `PrototypeE7M4ContractCatalogRules`
|
||||
|
||||
Sync to `scripts/validate_content.py`:
|
||||
|
||||
| Constant / helper | Python source |
|
||||
|-------------------|---------------|
|
||||
| `ExpectedTemplateIds` | `PROTOTYPE_E7M4_TEMPLATE_IDS` |
|
||||
| `ExpectedTemplateFreeze` | `PROTOTYPE_E7M4_TEMPLATE_FREEZE` |
|
||||
| `BandCapsByDifficultyBand` | `PROTOTYPE_E7M4_BAND_CAPS` |
|
||||
| `TryGetRosterGateError` | `_prototype_e7m4_template_roster_gate` |
|
||||
| `TryGetFreezeGateError` | `_prototype_e7m4_template_freeze_gate` |
|
||||
| `TryGetCrossRefError` | `_prototype_e7m4_cross_ref_gate` |
|
||||
| `TryGetBandCapGateError` | `_prototype_e7m4_band_cap_gate` |
|
||||
|
||||
Bundle normalization for freeze compare: mirror `_normalize_e7m3_completion_bundle` (item + skill XP + rep grants).
|
||||
|
||||
Cross-ref gate iterates **all loaded catalog rows** (not roster-only), matching NEO-144 review fix.
|
||||
|
||||
### 4. Path resolution and DI
|
||||
|
||||
- Add `ContractsDirectory`, `ContractTemplateSchemaPath` to `ContentPathsOptions`.
|
||||
- `ContractCatalogPathResolution` — discover `content/contracts`, resolve `contract-template.schema.json` sibling under `content/schemas/`.
|
||||
- `ContractCatalogServiceCollectionExtensions.AddContractTemplateCatalog` — singleton catalog + `IContractTemplateRegistry`.
|
||||
- **`Program.cs`:** register contract catalog **after** `AddQuestDefinitionCatalog`; eager-resolve `ContractTemplateCatalog` at boot (after quest catalog).
|
||||
- **`InMemoryWebApplicationFactory`:** pin repo `content/contracts` via `Content:ContractsDirectory`.
|
||||
|
||||
### 5. Tests
|
||||
|
||||
**Loader (`ContractTemplateCatalogLoaderTests`):**
|
||||
|
||||
- Happy path — repo `prototype_contract_templates.json` with real cross-ref catalogs.
|
||||
- Missing contracts directory / schema.
|
||||
- Duplicate template id.
|
||||
- Wrong roster count or unknown id.
|
||||
- Freeze table mismatch (display name, band, encounter, standing, bundle).
|
||||
- Unknown `encounterTemplateId` (primary AC negative).
|
||||
- Unknown bundle item/skill/faction ref; skill XP missing `mission_reward` allowlist.
|
||||
- Band-cap violation (local temp fixture or inline JSON — do not commit bad catalog).
|
||||
|
||||
**Registry (`ContractTemplateRegistryTests`):** `TryGetDefinition` hit/miss; id-order listing.
|
||||
|
||||
**Host boot:** `Host_ShouldResolveContractCatalogFromDi` — factory starts; `IContractTemplateRegistry` resolves `prototype_contract_clear_combat_pocket`.
|
||||
|
||||
### 6. Docs
|
||||
|
||||
- **`server/README.md`** — new contract catalog paragraph (config keys, gates, load order after quests).
|
||||
- Optional one-line E7_M4 module CI note when implementation lands.
|
||||
|
||||
### 7. Implementation order
|
||||
|
||||
1. DTOs, rules, path resolution, loader, catalog, registry, DI.
|
||||
2. `ContentPathsOptions`, `Program.cs` load order, `InMemoryWebApplicationFactory`.
|
||||
3. Tests (loader negatives → registry → host boot).
|
||||
4. `server/README.md`.
|
||||
5. Run `dotnet test`.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-145-implementation-plan.md` | This plan. |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs` | Load-time contract template row |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs` | In-memory catalog |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs` | Fail-fast disk load |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractCatalogPathResolution.cs` | Directory + schema path discovery |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs` | DI registration |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs` | Lookup contract |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs` | Registry adapter |
|
||||
| `server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs` | CI-parity E7M4 gates + band caps |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs` | Loader AAA tests |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs` | Registry AAA tests |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs` | Repo path discovery for tests |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `ContractsDirectory`, `ContractTemplateSchemaPath` |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register + eager-resolve contract catalog after quest catalog |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:ContractsDirectory` for host boot tests |
|
||||
| `server/README.md` | Contract catalog section (config, gates, load order) |
|
||||
|
||||
## Tests
|
||||
|
||||
| Layer | What |
|
||||
|-------|------|
|
||||
| `ContractTemplateCatalogLoaderTests` | Happy path, missing paths, duplicate id, roster/freeze/cross-ref/band-cap gates; broken encounter ref (AC) |
|
||||
| `ContractTemplateRegistryTests` | Lookup by id, ordered listing |
|
||||
| Host boot | `Host_ShouldResolveContractCatalogFromDi` via `InMemoryWebApplicationFactory` |
|
||||
| CI | Existing `dotnet test` + `validate_content.py` (no Python changes expected) |
|
||||
|
||||
Manual Godot QA: **none** (server infrastructure; capstone is NEO-154).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| Band-cap constants drift vs Python | Keep `PrototypeE7M4ContractCatalogRules.BandCapsByDifficultyBand` name-aligned with `PROTOTYPE_E7M4_BAND_CAPS`; add sync comment on both sides | `adopted` |
|
||||
| Shared bundle cross-ref logic with quests | Extract only if duplication is large; otherwise mirror quest item/skill checks in E7M4 rules with contract-specific error prefixes | `adopted` |
|
||||
| E7M4-07 issue-time validation | Loader gates suffice for E7M4-02 AC; port same `BandCapsByDifficultyBand` to `ContractEconomyValidation` in NEO-147 | `deferred` |
|
||||
|
||||
## Client counterpart
|
||||
|
||||
None for E7M4-02. Client contract HUD: **NEO-153** / capstone **NEO-154**.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Code review — NEO-144 (E7M4-01 contract catalog + schemas + CI)
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Scope:** Branch `NEO-144-e7m4-01-contracttemplate-catalog-schemas-ci` — commits `53122f9`…`2afd29e` (6 commits)
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-144 delivers the E7M4-01 content slice: `contract-template` and `contract-seed` JSON Schemas, a single-row `prototype_contract_templates.json` catalog, and ~320 lines of CI gates in `validate_content.py` (schema validation, roster/freeze, encounter/item/skill/faction cross-refs, band-1 economy caps). The work mirrors established NEO-133/NEO-124 quest and faction patterns, stays infrastructure-only (no server loader), and updates decomposition docs, `content/README.md`, and CT.M1 consistently. Risk is low: no runtime behavior change until NEO-145; the primary surface is `python3 scripts/validate_content.py`, which passes locally along with 823 server tests.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Alignment |
|
||||
|----------|-----------|
|
||||
| `docs/plans/NEO-144-implementation-plan.md` | **Matches** — schemas, catalog, gates, docs, and out-of-scope boundaries (no server/Godot) are all shipped; AC checklist marked complete. |
|
||||
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-01) | **Matches** — acceptance criteria satisfied; client counterpart correctly none. |
|
||||
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — freeze table, band policy, and CI landed note present. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — contract-catalog PR gate paragraph added. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row updated to In Progress with E7M4-01 catalog landed. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M4 status and NEO-144 spine note updated. |
|
||||
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — content-only; no authority boundary change. |
|
||||
| Full-stack epic decomposition | **N/A** — infrastructure-only; plan explicitly defers playable loop to NEO-154. |
|
||||
|
||||
Register/tracking tables were updated in this branch; no further alignment edits required for merge.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Freeze gate ignores `reputationGrants` today** — `_prototype_e7m4_template_freeze_gate` compares bundles via `_normalize_completion_bundle` (item + skill XP only), same as E7M2 quest bundles. Prototype row has no rep grants, so this is fine for E7M4-01. When rep grants land on contract templates (or a second template is added), extend normalization akin to `_normalize_e7m3_completion_bundle` and expand `PROTOTYPE_E7M4_TEMPLATE_FREEZE` so silent rep drift cannot pass CI.~~ **Done.** Freeze table includes **`reputationGrants: []`**; freeze gate uses **`_normalize_e7m3_completion_bundle`**.
|
||||
|
||||
2. ~~**`contract-seed.schema.json` is not exercised in CI** — intentional per plan (NEO-147+). When HTTP DTO wiring starts, add at least a schema parse/registry smoke check (or a tiny fixture) so `$ref` breakage is caught before server integration.~~ **Done.** **`_contract_seed_schema_smoke_gate`** validates a minimal fixture in **`validate_content.py`**.
|
||||
|
||||
3. ~~**Port band-cap constants to C# in NEO-145/NEO-147** — `PROTOTYPE_E7M4_BAND_CAPS` in Python should stay name-aligned with the future server `ContractEconomyValidation` (plan already flags this); call it out in NEO-145 kickoff so drift does not reappear at issue time.~~ **Done.** Comment on **`PROTOTYPE_E7M4_BAND_CAPS`** + NEO-145 kickoff note in plan open questions.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: The implementation plan §5 names `PROTOTYPE_E7M4_BAND1_*` constants; shipped code uses `PROTOTYPE_E7M4_BAND_CAPS` keyed by band — the dict form is better for E7M4-07; consider a one-line reconciliation in the plan (cosmetic only; plan reconciliation section already describes shipped shape).~~ **Done.** Plan §5 updated to **`PROTOTYPE_E7M4_BAND_CAPS`**.
|
||||
|
||||
- ~~Nit: `_prototype_e7m4_cross_ref_gate` iterates only `PROTOTYPE_E7M4_TEMPLATE_IDS`. Correct for the single-template roster; generalize to all catalog rows when the roster gate relaxes in a later story.~~ **Done.** Cross-ref and band-cap gates iterate all loaded catalog rows.
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run during review (all green):
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_content.py
|
||||
dotnet test
|
||||
```
|
||||
|
||||
Author should also confirm band-cap negative AC manually (plan documents this; not committed):
|
||||
|
||||
```bash
|
||||
# Temporarily set scrap_metal_bulk quantity to 11 in prototype_contract_templates.json
|
||||
python3 scripts/validate_content.py # expect non-zero exit
|
||||
```
|
||||
|
||||
Optional before merge: skim PR diff for doc-only commits (`E7M4-pre-production-backlog.md`, epic alignment) — no functional concern, but keeps reviewer focus on the three implementation commits (`58d2c32`, `7ecc874`, `2afd29e`).
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Code review — NEO-145 (E7M4-02 server contract template catalog load)
|
||||
|
||||
**Date:** 2026-06-20
|
||||
**Scope:** Branch `NEO-145-e7m4-02-server-contract-template-catalog-load-fail-fast` — commits `215493f`…`937412e` (3 commits)
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-145 delivers fail-fast startup loading of `content/contracts/*_contract_templates.json` with CI-parity gates ported to C# (`PrototypeE7M4ContractCatalogRules`), JSON Schema validation with quest/faction bundle `$ref`s, duplicate-id rejection, and cross-ref checks against encounter, faction, frozen prototype item/skill catalogs, and the E7M2 `mission_reward` allowlist. `IContractTemplateRegistry` + DI registration follow established faction/encounter patterns; `Program.cs` registers and eagerly resolves the catalog after the quest catalog. Tests cover loader happy/negative paths, registry lookups, and host boot via `InMemoryWebApplicationFactory`; `dotnet test` (839) and `validate_content.py` both pass. Risk is low: infrastructure-only, no player-facing HTTP yet; primary failure mode is startup exit with sorted `error:` lines — appropriate for this slice.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Alignment |
|
||||
|----------|-----------|
|
||||
| `docs/plans/NEO-145-implementation-plan.md` | **Matches** — AC checklist complete; reconciliation section reflects shipped loader, registry, DI, tests, README. |
|
||||
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-02) | **Matches** — loader, registry, cross-ref, tests, README in scope; instance store/HTTP/Godot correctly out of scope. |
|
||||
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | ~~**Partially matches** — freeze table and CI (NEO-144) documented; no note that server fail-fast load landed (NEO-145). Plan marked this optional.~~ **Done.** Server load (NEO-145) paragraph added. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | ~~**Partially matches** — Purpose mentions server-side smoke loading; contract paragraph still NEO-144/CI-only — should add NEO-145 server mirror.~~ **Done.** NEO-145 server startup mirror sentence added. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches** — E7.M4 row cites E7M4-01/NEO-144 only; should mention E7M4-02 server catalog load after merge.~~ **Done.** E7M4-02 / NEO-145 row extended. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | ~~**Partially matches** — E7.M4 In Progress spine unchanged; acceptable until alignment pass, but register note could cite NEO-145.~~ **Done.** E7.M4 note cites NEO-145. |
|
||||
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — read-only catalog load; no authority boundary change. |
|
||||
| Full-stack epic decomposition | **N/A** — infrastructure-only; client counterpart correctly none (NEO-154 capstone). |
|
||||
|
||||
Register/tracking tables were **not** updated for NEO-145 in this branch — ~~**should fix** (non-blocking) per plan §6 optional module note and alignment doc pattern from NEO-144.~~ **Done.**
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Update decomposition tracking for E7M4-02** — After merge, extend `documentation_and_implementation_alignment.md` E7.M4 row and optionally `module_dependency_register.md` / `E7_M4_ContractMissionGenerator.md` with one line: server fail-fast catalog load + `IContractTemplateRegistry` (NEO-145). Mirrors NEO-144 alignment follow-up.~~ **Done.**
|
||||
|
||||
2. ~~**CT_M1 server-load paragraph** — Add a sentence under the contract-catalog CI bullet that the host mirrors the same gates at startup via `ContractTemplateCatalogLoader` (NEO-145), matching faction/quest catalog precedent in that doc.~~ **Done.**
|
||||
|
||||
3. ~~**Band-cap negative through full loader** — Plan §5 lists band-cap violation via loader; shipped coverage tests `TryGetBandCapGateError` in isolation only. Consider one temp-fixture test that writes qty **11** and asserts `ContractTemplateCatalogLoader.Load` throws (same pattern as freeze/roster negatives) so the loader→rules wiring is exercised end-to-end.~~ **Done.** `Load_ShouldThrow_WhenItemQuantityExceedsPrototypeBundleLimits` exercises loader→freeze gate (qty 11 fails freeze before band-cap on the single-template roster); `TryGetBandCapGateError_ShouldReturnError_WhenItemQuantityExceedsBandCap` covers band-cap rule directly.
|
||||
|
||||
4. ~~**Missing schema path test** — Plan §5 lists missing schema; only missing **directory** is tested. A temp layout with contracts dir present but schema file removed would close the gap cheaply.~~ **Done.** `Load_ShouldThrow_WhenSchemaFileMissing` added.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: `Load_ShouldThrow_WhenBundleItemIdUnknown` calls `TryGetCrossRefError` directly rather than `Load`; rename to `TryGetCrossRefError_ShouldReturnError_WhenBundleItemIdUnknown` (or route through the loader) for clarity.~~ **Done.**
|
||||
|
||||
- ~~Nit: Bruno folder `bruno/neon-sprawl-server/contract-catalog/` (health smoke) is a nice addition but not listed in the plan — harmless; optional one-line in plan reconciliation if you want doc parity.~~ **Done.** Plan reconciliation notes Bruno health smoke.
|
||||
|
||||
- Nit: `AddContractTemplateCatalog` re-binds `ContentPathsOptions` (same as faction/quest extensions) — fine; no action needed.
|
||||
|
||||
## Verification
|
||||
|
||||
Commands run during review (all green):
|
||||
|
||||
```bash
|
||||
dotnet test NeonSprawl.sln
|
||||
python3 scripts/validate_content.py
|
||||
```
|
||||
|
||||
Author should confirm fail-fast startup manually once (optional):
|
||||
|
||||
```bash
|
||||
# Temporarily break encounterTemplateId in prototype_contract_templates.json
|
||||
dotnet run --project server/NeonSprawl.Server # expect startup InvalidOperationException
|
||||
```
|
||||
|
||||
Before merge: skim decomposition alignment suggestions above — functional code is merge-ready without them.
|
||||
|
|
@ -18,6 +18,9 @@ Validates:
|
|||
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)
|
||||
- contract template catalogs: content/contracts/*_contract_templates.json rows vs
|
||||
content/schemas/contract-template.schema.json (NEO-144); completionRewardBundle via quest-reward-bundle.schema.json
|
||||
- contract-seed schema smoke: content/schemas/contract-seed.schema.json minimal fixture parse (NEO-144)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -51,6 +54,8 @@ QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant.
|
|||
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"
|
||||
CONTRACT_TEMPLATE_SCHEMA = REPO_ROOT / "content/schemas/contract-template.schema.json"
|
||||
CONTRACT_SEED_SCHEMA = REPO_ROOT / "content/schemas/contract-seed.schema.json"
|
||||
SKILLS_DIR = REPO_ROOT / "content/skills"
|
||||
MASTERY_DIR = REPO_ROOT / "content/mastery"
|
||||
ITEMS_DIR = REPO_ROOT / "content/items"
|
||||
|
|
@ -62,6 +67,7 @@ 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"
|
||||
CONTRACTS_DIR = REPO_ROOT / "content/contracts"
|
||||
|
||||
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
|
||||
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
||||
|
|
@ -270,6 +276,35 @@ PROTOTYPE_E7M3_COMPLETION_BUNDLES: dict[str, dict] = {
|
|||
},
|
||||
}
|
||||
|
||||
# Epic 7 Slice 4 prototype lock (NEO-144): one contract template + band-1 economy caps.
|
||||
# Keep in sync with E7.M4 freeze table, future NEO-145 server loader, and NEO-147
|
||||
# ContractEconomyValidation (port PROTOTYPE_E7M4_BAND_CAPS keys to C# in E7M4-07).
|
||||
PROTOTYPE_E7M4_TEMPLATE_IDS = frozenset({"prototype_contract_clear_combat_pocket"})
|
||||
PROTOTYPE_E7M4_TEMPLATE_FREEZE: dict[str, dict] = {
|
||||
"prototype_contract_clear_combat_pocket": {
|
||||
"displayName": "Clear Combat Pocket (Repeat)",
|
||||
"zoneDifficultyBand": 1,
|
||||
"objectiveKind": "encounter_clear",
|
||||
"encounterTemplateId": "prototype_combat_pocket",
|
||||
"minFactionStanding": {
|
||||
"factionId": "prototype_faction_grid_operators",
|
||||
"minStanding": 0,
|
||||
},
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{"itemId": "scrap_metal_bulk", "quantity": 5}],
|
||||
"skillXpGrants": [{"skillId": "salvage", "amount": 15}],
|
||||
"reputationGrants": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
PROTOTYPE_E7M4_BAND_CAPS: dict[int, dict[str, int]] = {
|
||||
1: {
|
||||
"maxItemQuantity": 10,
|
||||
"maxSkillXpAmount": 25,
|
||||
"maxReputationAmount": 10,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_completion_bundle(bundle: dict) -> dict:
|
||||
"""Normalize grant rows for stable freeze-table comparison."""
|
||||
|
|
@ -1011,6 +1046,43 @@ def _quest_def_validator() -> Draft202012Validator:
|
|||
return Draft202012Validator(def_schema, registry=registry)
|
||||
|
||||
|
||||
def _contract_template_validator() -> Draft202012Validator:
|
||||
"""Build a validator that resolves contract-template $ref to bundle and gate schemas."""
|
||||
template_schema = json.loads(CONTRACT_TEMPLATE_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(
|
||||
[
|
||||
(template_schema["$id"], Resource.from_contents(template_schema)),
|
||||
(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(template_schema, registry=registry)
|
||||
|
||||
|
||||
def _contract_seed_schema_smoke_gate() -> str | None:
|
||||
"""Return a human-readable error if contract-seed.schema.json fails a minimal fixture parse, else None."""
|
||||
seed_schema = json.loads(CONTRACT_SEED_SCHEMA.read_text(encoding="utf-8"))
|
||||
validator = Draft202012Validator(seed_schema)
|
||||
fixture = {
|
||||
"playerId": "prototype_player",
|
||||
"templateId": "prototype_contract_clear_combat_pocket",
|
||||
"seedBucket": "2026-06-20",
|
||||
}
|
||||
errors = sorted(validator.iter_errors(fixture), key=lambda err: err.path)
|
||||
if errors:
|
||||
loc = ".".join(str(part) for part in errors[0].path) or "(root)"
|
||||
return f"error: contract-seed schema smoke fixture {loc}: {errors[0].message}"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_faction_catalogs(
|
||||
*,
|
||||
faction_files: list[Path],
|
||||
|
|
@ -1143,6 +1215,55 @@ def _validate_quest_catalogs(
|
|||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _validate_contract_catalogs(
|
||||
*,
|
||||
contract_files: list[Path],
|
||||
contract_validator: Draft202012Validator,
|
||||
) -> tuple[int, dict[str, str], dict[str, dict]]:
|
||||
"""Validate contract template 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 contract_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
|
||||
|
||||
contract_templates = data.get("contractTemplates")
|
||||
if not isinstance(contract_templates, list):
|
||||
print(f"error: {rel}: expected top-level 'contractTemplates' array", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
for i, row in enumerate(contract_templates):
|
||||
if not isinstance(row, dict):
|
||||
print(f"error: {rel}: contractTemplates[{i}] must be an object", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
row_schema_errors = 0
|
||||
for err in sorted(contract_validator.iter_errors(row), key=lambda e: e.path):
|
||||
loc = ".".join(str(p) for p in err.path) or "(root)"
|
||||
print(f"error: {rel} contractTemplates[{i}] {loc}: {err.message}", file=sys.stderr)
|
||||
row_schema_errors += 1
|
||||
errors += 1
|
||||
tid = row.get("id")
|
||||
if isinstance(tid, str) and row_schema_errors == 0:
|
||||
prev = seen_ids.get(tid)
|
||||
if prev:
|
||||
print(f"error: duplicate contract template id {tid!r} in {prev} and {rel}", file=sys.stderr)
|
||||
errors += 1
|
||||
else:
|
||||
seen_ids[tid] = rel
|
||||
id_to_row[tid] = row
|
||||
|
||||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _prototype_e7m3_quest_roster_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E7M3 five-quest roster fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
|
|
@ -1510,6 +1631,179 @@ def _prototype_e7m3_grid_contract_shape_gate(id_to_row: dict[str, dict]) -> str
|
|||
return None
|
||||
|
||||
|
||||
def _prototype_e7m4_template_roster_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E7M4 template roster fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
if ids != PROTOTYPE_E7M4_TEMPLATE_IDS:
|
||||
return (
|
||||
"error: prototype E7M4 expects exactly contract template ids "
|
||||
f"{sorted(PROTOTYPE_E7M4_TEMPLATE_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m4_template_freeze_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if template rows diverge from E7M4 freeze table, else None."""
|
||||
for tid, expected in PROTOTYPE_E7M4_TEMPLATE_FREEZE.items():
|
||||
row = id_to_row.get(tid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing contract template {tid!r}"
|
||||
for field in (
|
||||
"displayName",
|
||||
"zoneDifficultyBand",
|
||||
"objectiveKind",
|
||||
"encounterTemplateId",
|
||||
):
|
||||
if row.get(field) != expected[field]:
|
||||
return (
|
||||
f"error: contract template {tid!r} {field} must be "
|
||||
f"{expected[field]!r}, got {row.get(field)!r}"
|
||||
)
|
||||
min_standing = row.get("minFactionStanding")
|
||||
if min_standing != expected["minFactionStanding"]:
|
||||
return (
|
||||
f"error: contract template {tid!r} minFactionStanding must be "
|
||||
f"{expected['minFactionStanding']!r}, got {min_standing!r}"
|
||||
)
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if not isinstance(bundle, dict):
|
||||
return f"error: contract template {tid!r} must include completionRewardBundle object"
|
||||
actual_bundle = _normalize_e7m3_completion_bundle(bundle)
|
||||
expected_bundle = _normalize_e7m3_completion_bundle(expected["completionRewardBundle"])
|
||||
if actual_bundle != expected_bundle:
|
||||
return (
|
||||
f"error: contract template {tid!r} completionRewardBundle must match E7M4 freeze table "
|
||||
f"(expected {expected_bundle!r}, got {actual_bundle!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m4_cross_ref_gate(
|
||||
id_to_row: dict[str, dict],
|
||||
encounter_seen_ids: dict[str, str],
|
||||
faction_seen_ids: dict[str, str],
|
||||
skill_id_to_allowed_kinds: dict[str, list[str]],
|
||||
) -> str | None:
|
||||
"""Return a human-readable error if contract template refs fail cross-checks, else None."""
|
||||
for tid in sorted(id_to_row.keys()):
|
||||
row = id_to_row.get(tid)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
encounter_id = row.get("encounterTemplateId")
|
||||
if isinstance(encounter_id, str) and encounter_id not in encounter_seen_ids:
|
||||
return (
|
||||
f"error: contract template {tid!r} encounterTemplateId {encounter_id!r} "
|
||||
"is not in the loaded encounter catalog"
|
||||
)
|
||||
min_standing = row.get("minFactionStanding")
|
||||
if isinstance(min_standing, dict):
|
||||
faction_id = min_standing.get("factionId")
|
||||
if isinstance(faction_id, str) and faction_id not in faction_seen_ids:
|
||||
return (
|
||||
f"error: contract template {tid!r} minFactionStanding.factionId "
|
||||
f"{faction_id!r} is not in the frozen prototype faction catalog"
|
||||
)
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if not isinstance(bundle, dict):
|
||||
continue
|
||||
item_grants = bundle.get("itemGrants")
|
||||
if isinstance(item_grants, list):
|
||||
for gi, grant in enumerate(item_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
item_id = grant.get("itemId")
|
||||
if isinstance(item_id, str) and item_id not in PROTOTYPE_SLICE1_ITEM_IDS:
|
||||
return (
|
||||
f"error: contract template {tid!r} completionRewardBundle.itemGrants[{gi}]: "
|
||||
f"itemId {item_id!r} is not in the frozen prototype item catalog"
|
||||
)
|
||||
skill_xp_grants = bundle.get("skillXpGrants")
|
||||
if isinstance(skill_xp_grants, list):
|
||||
for gi, grant in enumerate(skill_xp_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
skill_id = grant.get("skillId")
|
||||
if isinstance(skill_id, str) and skill_id not in PROTOTYPE_SLICE1_SKILL_IDS:
|
||||
return (
|
||||
f"error: contract template {tid!r} completionRewardBundle.skillXpGrants[{gi}]: "
|
||||
f"skillId {skill_id!r} is not in the frozen prototype skill catalog"
|
||||
)
|
||||
allowed_kinds = skill_id_to_allowed_kinds.get(skill_id, [])
|
||||
if PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND not in allowed_kinds:
|
||||
return (
|
||||
f"error: contract template {tid!r} completionRewardBundle.skillXpGrants[{gi}]: "
|
||||
f"skillId {skill_id!r} must allow sourceKind "
|
||||
f"{PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND!r} in allowedXpSourceKinds "
|
||||
f"(got {allowed_kinds!r})"
|
||||
)
|
||||
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 faction_seen_ids:
|
||||
return (
|
||||
f"error: contract template {tid!r} completionRewardBundle.reputationGrants[{gi}]: "
|
||||
f"factionId {faction_id!r} is not in the frozen prototype faction catalog"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m4_band_cap_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if a template bundle exceeds band caps, else None."""
|
||||
for tid in sorted(id_to_row.keys()):
|
||||
row = id_to_row.get(tid)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
band = row.get("zoneDifficultyBand")
|
||||
if not isinstance(band, int):
|
||||
continue
|
||||
caps = PROTOTYPE_E7M4_BAND_CAPS.get(band)
|
||||
if caps is None:
|
||||
return (
|
||||
f"error: contract template {tid!r} zoneDifficultyBand {band} has no "
|
||||
"prototype economy cap policy"
|
||||
)
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if not isinstance(bundle, dict):
|
||||
continue
|
||||
item_grants = bundle.get("itemGrants")
|
||||
if isinstance(item_grants, list):
|
||||
for gi, grant in enumerate(item_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
quantity = grant.get("quantity")
|
||||
if isinstance(quantity, int) and quantity > caps["maxItemQuantity"]:
|
||||
return (
|
||||
f"error: contract template {tid!r} completionRewardBundle.itemGrants[{gi}] "
|
||||
f"quantity {quantity} exceeds band {band} cap {caps['maxItemQuantity']}"
|
||||
)
|
||||
skill_xp_grants = bundle.get("skillXpGrants")
|
||||
if isinstance(skill_xp_grants, list):
|
||||
for gi, grant in enumerate(skill_xp_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
amount = grant.get("amount")
|
||||
if isinstance(amount, int) and amount > caps["maxSkillXpAmount"]:
|
||||
return (
|
||||
f"error: contract template {tid!r} completionRewardBundle.skillXpGrants[{gi}] "
|
||||
f"amount {amount} exceeds band {band} cap {caps['maxSkillXpAmount']}"
|
||||
)
|
||||
reputation_grants = bundle.get("reputationGrants")
|
||||
if isinstance(reputation_grants, list):
|
||||
for gi, grant in enumerate(reputation_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
amount = grant.get("amount")
|
||||
if isinstance(amount, int) and abs(amount) > caps["maxReputationAmount"]:
|
||||
return (
|
||||
f"error: contract template {tid!r} completionRewardBundle.reputationGrants[{gi}] "
|
||||
f"amount {amount} exceeds band {band} rep cap {caps['maxReputationAmount']}"
|
||||
)
|
||||
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:
|
||||
|
|
@ -1891,6 +2185,7 @@ 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()
|
||||
contract_validator = _contract_template_validator()
|
||||
faction_schema = json.loads(FACTION_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
faction_validator = Draft202012Validator(faction_schema)
|
||||
|
||||
|
|
@ -2003,6 +2298,15 @@ def main() -> int:
|
|||
print(f"error: no *_factions.json files under {FACTIONS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not CONTRACTS_DIR.is_dir():
|
||||
print(f"error: missing directory {CONTRACTS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
contract_files = sorted(CONTRACTS_DIR.glob("*_contract_templates.json"))
|
||||
if not contract_files:
|
||||
print(f"error: no *_contract_templates.json files under {CONTRACTS_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]] = {}
|
||||
|
|
@ -2297,6 +2601,44 @@ def main() -> int:
|
|||
print(e7m3_faction_band_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
contract_errors, contract_seen_ids, contract_id_to_row = _validate_contract_catalogs(
|
||||
contract_files=contract_files,
|
||||
contract_validator=contract_validator,
|
||||
)
|
||||
if contract_errors:
|
||||
print(f"content validation failed with {contract_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
contract_seed_smoke_err = _contract_seed_schema_smoke_gate()
|
||||
if contract_seed_smoke_err:
|
||||
print(contract_seed_smoke_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m4_template_roster_err = _prototype_e7m4_template_roster_gate(contract_seen_ids)
|
||||
if e7m4_template_roster_err:
|
||||
print(e7m4_template_roster_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m4_template_freeze_err = _prototype_e7m4_template_freeze_gate(contract_id_to_row)
|
||||
if e7m4_template_freeze_err:
|
||||
print(e7m4_template_freeze_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m4_cross_ref_err = _prototype_e7m4_cross_ref_gate(
|
||||
contract_id_to_row,
|
||||
encounter_seen_ids,
|
||||
faction_seen_ids,
|
||||
skill_id_to_allowed_kinds,
|
||||
)
|
||||
if e7m4_cross_ref_err:
|
||||
print(e7m4_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m4_band_cap_err = _prototype_e7m4_band_cap_gate(contract_id_to_row)
|
||||
if e7m4_band_cap_err:
|
||||
print(e7m4_band_cap_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,
|
||||
|
|
@ -2375,6 +2717,7 @@ def main() -> int:
|
|||
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(contract_files)} contract template 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), "
|
||||
|
|
@ -2385,6 +2728,7 @@ def main() -> int:
|
|||
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(contract_seen_ids)} unique contract template id(s), "
|
||||
f"{len(quest_seen_ids)} unique quest id(s), "
|
||||
f"{len(track_skill_ids)} mastery track(s)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
using NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||
|
||||
internal static class ContractCatalogTestPaths
|
||||
{
|
||||
internal static string DiscoverRepoContractsDirectory() =>
|
||||
ContractCatalogPathResolution.TryDiscoverContractsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/contracts from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
|
||||
internal static string DiscoverRepoContractTemplateSchemaPath() =>
|
||||
ContractCatalogPathResolution.ResolveContractTemplateSchemaPath(
|
||||
DiscoverRepoContractsDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
|
||||
internal static string DiscoverRepoRewardGrantRowSchemaPath() =>
|
||||
ContractCatalogPathResolution.ResolveRewardGrantRowSchemaPath(DiscoverRepoContractsDirectory());
|
||||
|
||||
internal static string DiscoverRepoQuestSkillXpGrantSchemaPath() =>
|
||||
ContractCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(DiscoverRepoContractsDirectory());
|
||||
|
||||
internal static string DiscoverRepoQuestRewardBundleSchemaPath() =>
|
||||
ContractCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoContractsDirectory());
|
||||
|
||||
internal static string DiscoverRepoFactionGateRuleSchemaPath() =>
|
||||
ContractCatalogPathResolution.ResolveFactionGateRuleSchemaPath(DiscoverRepoContractsDirectory());
|
||||
|
||||
internal static string DiscoverRepoReputationGrantRowSchemaPath() =>
|
||||
ContractCatalogPathResolution.ResolveReputationGrantRowSchemaPath(DiscoverRepoContractsDirectory());
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
using System.Collections.Frozen;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests.Game.Skills;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||
|
||||
public class ContractTemplateCatalogLoaderTests
|
||||
{
|
||||
private static readonly FrozenSet<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
|
||||
|
||||
private static readonly FrozenSet<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, SkillDefRow> RepoSkillDefs = LoadRepoSkillDefs();
|
||||
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contractTemplates": [
|
||||
{
|
||||
"id": "prototype_contract_clear_combat_pocket",
|
||||
"displayName": "Clear Combat Pocket (Repeat)",
|
||||
"zoneDifficultyBand": 1,
|
||||
"objectiveKind": "encounter_clear",
|
||||
"encounterTemplateId": "prototype_combat_pocket",
|
||||
"minFactionStanding": {
|
||||
"factionId": "prototype_faction_grid_operators",
|
||||
"minStanding": 0
|
||||
},
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 15 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static IReadOnlyDictionary<string, SkillDefRow> LoadRepoSkillDefs()
|
||||
{
|
||||
var catalog = SkillDefinitionCatalogLoader.Load(
|
||||
SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(),
|
||||
SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(),
|
||||
NullLogger.Instance);
|
||||
return catalog.ById;
|
||||
}
|
||||
|
||||
private static ContractTemplateCatalog LoadCatalog(
|
||||
string contractsDir,
|
||||
string contractTemplateSchemaPath,
|
||||
IReadOnlySet<string>? knownEncounterIds = null,
|
||||
IReadOnlySet<string>? knownFactionIds = null,
|
||||
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null)
|
||||
{
|
||||
return ContractTemplateCatalogLoader.Load(
|
||||
contractsDir,
|
||||
contractTemplateSchemaPath,
|
||||
ContractCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(),
|
||||
ContractCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(),
|
||||
ContractCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(),
|
||||
ContractCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(),
|
||||
ContractCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(),
|
||||
knownEncounterIds ?? KnownEncounterIds,
|
||||
knownFactionIds ?? KnownFactionIds,
|
||||
skillDefsById ?? RepoSkillDefs,
|
||||
NullLogger.Instance);
|
||||
}
|
||||
|
||||
private static (string Root, string ContractsDir, string SchemaPath) CreateTempContentLayout()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-contractcat-");
|
||||
var contractsDir = Path.Combine(root.FullName, "content", "contracts");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(contractsDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "contract-template.schema.json");
|
||||
File.Copy(ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath(), schemaPath, overwrite: true);
|
||||
File.Copy(ContractCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
|
||||
File.Copy(ContractCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
|
||||
File.Copy(ContractCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
|
||||
File.Copy(ContractCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
|
||||
File.Copy(ContractCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
|
||||
return (root.FullName, contractsDir, schemaPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile()
|
||||
{
|
||||
// Arrange
|
||||
var contractsDir = ContractCatalogTestPaths.DiscoverRepoContractsDirectory();
|
||||
var schemaPath = ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath();
|
||||
// Act
|
||||
var catalog = LoadCatalog(contractsDir, schemaPath);
|
||||
// Assert
|
||||
Assert.Equal(PrototypeE7M4ContractCatalogRules.ExpectedTemplateIds.Count, catalog.DistinctTemplateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||
{
|
||||
// Arrange
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
||||
// Act
|
||||
var catalog = LoadCatalog(contractsDir, schemaPath);
|
||||
// Assert
|
||||
Assert.Equal(1, catalog.DistinctTemplateCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.ById.TryGetValue("prototype_contract_clear_combat_pocket", out var row));
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal("Clear Combat Pocket (Repeat)", row!.DisplayName);
|
||||
Assert.Equal(1, row.ZoneDifficultyBand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenContractTemplatesIsNotArray()
|
||||
{
|
||||
// Arrange
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(contractsDir, "bad_contract_templates.json"),
|
||||
"""{"schemaVersion": 1, "contractTemplates": "nope"}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_contract_templates.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("expected top-level 'contractTemplates' array", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(contractsDir, "a_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
||||
File.WriteAllText(Path.Combine(contractsDir, "b_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate contract template id", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRosterGateFails()
|
||||
{
|
||||
// Arrange
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
const string emptyCatalog = """{"schemaVersion": 1, "contractTemplates": []}""";
|
||||
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), emptyCatalog, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E7M4 expects exactly contract template ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenFreezeTableDiverges()
|
||||
{
|
||||
// Arrange
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
var badDisplay = ValidPrototypeCatalogJson.Replace("Clear Combat Pocket (Repeat)", "Wrong Name", StringComparison.Ordinal);
|
||||
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), badDisplay, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("displayName must be", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenEncounterTemplateIdUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
||||
var emptyEncounters = FrozenSet<string>.Empty;
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, knownEncounterIds: emptyEncounters));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("encounterTemplateId 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("is not in the loaded encounter catalog", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetCrossRefError_ShouldReturnError_WhenBundleItemIdUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||
var badItemRow = prototype with
|
||||
{
|
||||
CompletionRewardBundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("not_a_real_item", 5)],
|
||||
prototype.CompletionRewardBundle.SkillXpGrants,
|
||||
prototype.CompletionRewardBundle.ReputationGrants),
|
||||
};
|
||||
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[badItemRow.Id] = badItemRow,
|
||||
};
|
||||
// Act
|
||||
var error = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
|
||||
rows,
|
||||
KnownEncounterIds,
|
||||
KnownFactionIds,
|
||||
RepoSkillDefs);
|
||||
// Assert
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("itemId 'not_a_real_item'", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSkillXpMissingMissionRewardAllowlist()
|
||||
{
|
||||
// Arrange
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
||||
var salvageNoMission = new Dictionary<string, SkillDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
["salvage"] = new("salvage", "gather", "Salvage", ["activity"]),
|
||||
["refine"] = RepoSkillDefs["refine"],
|
||||
["intrusion"] = RepoSkillDefs["intrusion"],
|
||||
};
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, skillDefsById: salvageNoMission));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenItemQuantityExceedsPrototypeBundleLimits()
|
||||
{
|
||||
// Arrange — qty 11 fails freeze before band-cap gate (same CI tamper path as validate_content.py)
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
var overCap = ValidPrototypeCatalogJson.Replace("\"quantity\": 5", "\"quantity\": 11", StringComparison.Ordinal);
|
||||
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), overCap, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("completionRewardBundle must match E7M4 freeze table", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
||||
{
|
||||
// Arrange
|
||||
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
||||
File.Delete(schemaPath);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(schemaPath, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetBandCapGateError_ShouldReturnError_WhenItemQuantityExceedsBandCap()
|
||||
{
|
||||
// Arrange
|
||||
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||
var overCapRow = prototype with
|
||||
{
|
||||
CompletionRewardBundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("scrap_metal_bulk", 11)],
|
||||
prototype.CompletionRewardBundle.SkillXpGrants,
|
||||
prototype.CompletionRewardBundle.ReputationGrants),
|
||||
};
|
||||
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[overCapRow.Id] = overCapRow,
|
||||
};
|
||||
// Act
|
||||
var error = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows);
|
||||
// Assert
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("quantity 11 exceeds band 1 cap 10", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenMissingContractsDirectory()
|
||||
{
|
||||
// Arrange
|
||||
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-missing-contracts-" + Guid.NewGuid().ToString("N"));
|
||||
var schemaPath = ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath();
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(missingDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
using System.Collections.Frozen;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using NeonSprawl.Server.Tests.Game.Skills;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||
|
||||
public class ContractTemplateRegistryTests
|
||||
{
|
||||
private static ContractTemplateRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, ContractTemplateRow> byId)
|
||||
{
|
||||
var catalog = new ContractTemplateCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
|
||||
return new ContractTemplateRegistry(catalog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnTrue_WhenIdExists()
|
||||
{
|
||||
// Arrange
|
||||
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[expected.Id] = expected,
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition("prototype_contract_clear_combat_pocket", out var def);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(def);
|
||||
Assert.Equal("Clear Combat Pocket (Repeat)", def!.DisplayName);
|
||||
Assert.Equal("prototype_combat_pocket", def.EncounterTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnFalse_WhenTemplateIdIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[expected.Id] = expected,
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition(null, out var def);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[expected.Id] = expected,
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition("not_a_real_template", out var def);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleTemplates()
|
||||
{
|
||||
// Arrange
|
||||
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
|
||||
var other = prototype with { Id = "zzz_other_template" };
|
||||
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[other.Id] = other,
|
||||
[prototype.Id] = prototype,
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var list = registry.GetDefinitionsInIdOrder();
|
||||
// Assert
|
||||
Assert.Equal(2, list.Count);
|
||||
Assert.Equal("prototype_contract_clear_combat_pocket", list[0].Id);
|
||||
Assert.Equal("zzz_other_template", list[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveContractCatalogFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
// Act
|
||||
var registry = factory.Services.GetRequiredService<IContractTemplateRegistry>();
|
||||
var found = registry.TryGetDefinition("prototype_contract_clear_combat_pocket", out var template);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(template);
|
||||
Assert.Equal("Clear Combat Pocket (Repeat)", template!.DisplayName);
|
||||
Assert.Equal("encounter_clear", template.ObjectiveKind);
|
||||
Assert.Equal("prototype_combat_pocket", template.EncounterTemplateId);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
|
|||
using Microsoft.Extensions.Time.Testing;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
|
|
@ -66,6 +67,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var factionsDir = FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var contractsDir = ContractCatalogPathResolution.TryDiscoverContractsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/contracts from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
|
|
@ -77,6 +81,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
builder.UseSetting("Content:EncountersDirectory", encountersDir);
|
||||
builder.UseSetting("Content:FactionsDirectory", factionsDir);
|
||||
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
||||
builder.UseSetting("Content:ContractsDirectory", contractsDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
|
||||
builder.UseSetting("Game:EnableResourceNodeFixtureApi", "true");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
namespace NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
/// <summary>Resolves contract catalog paths for local dev, tests, and container layouts (NEO-145).</summary>
|
||||
public static class ContractCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/contracts</c> directory.</summary>
|
||||
public static string? TryDiscoverContractsDirectory(string startDirectory)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "content", "contracts");
|
||||
if (Directory.Exists(candidate))
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the contracts catalog directory.
|
||||
/// Empty <paramref name="configuredContractsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
public static string ResolveContractsDirectory(string? configuredContractsDirectory, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredContractsDirectory))
|
||||
{
|
||||
var discovered = TryDiscoverContractsDirectory(AppContext.BaseDirectory);
|
||||
if (discovered is not null)
|
||||
return discovered;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Content:ContractsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/contracts'). " +
|
||||
"Set Content:ContractsDirectory in configuration or environment (e.g. Content__ContractsDirectory).");
|
||||
}
|
||||
|
||||
var trimmed = configuredContractsDirectory.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single contract template row (Draft 2020-12).</summary>
|
||||
public static string ResolveContractTemplateSchemaPath(
|
||||
string contractsDirectory,
|
||||
string? configuredSchemaPath,
|
||||
string contentRootPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||
{
|
||||
var trimmed = configuredSchemaPath.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "contract-template.schema.json"));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a reward grant row (shared by quest bundles and contract templates).</summary>
|
||||
public static string ResolveRewardGrantRowSchemaPath(string contractsDirectory) =>
|
||||
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reward-grant-row.schema.json"));
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a quest skill XP grant row.</summary>
|
||||
public static string ResolveQuestSkillXpGrantSchemaPath(string contractsDirectory) =>
|
||||
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json"));
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a completion reward bundle.</summary>
|
||||
public static string ResolveQuestRewardBundleSchemaPath(string contractsDirectory) =>
|
||||
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-reward-bundle.schema.json"));
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a faction gate rule row.</summary>
|
||||
public static string ResolveFactionGateRuleSchemaPath(string contractsDirectory) =>
|
||||
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "faction-gate-rule.schema.json"));
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a reputation grant row.</summary>
|
||||
public static string ResolveReputationGrantRowSchemaPath(string contractsDirectory) =>
|
||||
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reputation-grant-row.schema.json"));
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
/// <summary>DI registration for the fail-fast contract template catalog (NEO-145).</summary>
|
||||
public static class ContractCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ContractTemplateCatalog"/> and <see cref="IContractTemplateRegistry"/> as singletons.</summary>
|
||||
public static IServiceCollection AddContractTemplateCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<ContractTemplateCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
|
||||
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
|
||||
var factionCatalog = sp.GetRequiredService<FactionDefinitionCatalog>();
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Contracts.ContractCatalog");
|
||||
|
||||
var contractsDir = ContractCatalogPathResolution.ResolveContractsDirectory(
|
||||
opts.ContractsDirectory,
|
||||
hostEnv.ContentRootPath);
|
||||
var contractTemplateSchemaPath = ContractCatalogPathResolution.ResolveContractTemplateSchemaPath(
|
||||
contractsDir,
|
||||
opts.ContractTemplateSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
var rewardGrantRowSchemaPath = ContractCatalogPathResolution.ResolveRewardGrantRowSchemaPath(contractsDir);
|
||||
var questSkillXpGrantSchemaPath = ContractCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(contractsDir);
|
||||
var questRewardBundleSchemaPath = ContractCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(contractsDir);
|
||||
var factionGateRuleSchemaPath = ContractCatalogPathResolution.ResolveFactionGateRuleSchemaPath(contractsDir);
|
||||
var reputationGrantRowSchemaPath = ContractCatalogPathResolution.ResolveReputationGrantRowSchemaPath(contractsDir);
|
||||
|
||||
var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return ContractTemplateCatalogLoader.Load(
|
||||
contractsDir,
|
||||
contractTemplateSchemaPath,
|
||||
rewardGrantRowSchemaPath,
|
||||
questSkillXpGrantSchemaPath,
|
||||
questRewardBundleSchemaPath,
|
||||
factionGateRuleSchemaPath,
|
||||
reputationGrantRowSchemaPath,
|
||||
knownEncounterIds,
|
||||
knownFactionIds,
|
||||
skillCatalog.ById,
|
||||
logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<IContractTemplateRegistry>(sp =>
|
||||
new ContractTemplateRegistry(sp.GetRequiredService<ContractTemplateCatalog>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
/// <summary>In-memory contract template catalog loaded at startup (NEO-145). Game code should prefer <see cref="IContractTemplateRegistry"/> for lookups.</summary>
|
||||
public sealed class ContractTemplateCatalog(
|
||||
string contractsDirectory,
|
||||
IReadOnlyDictionary<string, ContractTemplateRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*_contract_templates.json</c> catalogs.</summary>
|
||||
public string ContractsDirectory { get; } = contractsDirectory;
|
||||
|
||||
public IReadOnlyDictionary<string, ContractTemplateRow> ById { get; } =
|
||||
new ReadOnlyDictionary<string, ContractTemplateRow>(new Dictionary<string, ContractTemplateRow>(byId, StringComparer.Ordinal));
|
||||
|
||||
public int DistinctTemplateCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_contract_templates.json</c> files under <see cref="ContractsDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
/// <summary>Loads and validates <c>content/contracts/*_contract_templates.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-145).</summary>
|
||||
public static class ContractTemplateCatalogLoader
|
||||
{
|
||||
private static JsonSchema? _contractTemplateSchema;
|
||||
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static ContractTemplateCatalog Load(
|
||||
string contractsDirectory,
|
||||
string contractTemplateSchemaPath,
|
||||
string rewardGrantRowSchemaPath,
|
||||
string questSkillXpGrantSchemaPath,
|
||||
string questRewardBundleSchemaPath,
|
||||
string factionGateRuleSchemaPath,
|
||||
string reputationGrantRowSchemaPath,
|
||||
IReadOnlySet<string> knownEncounterIds,
|
||||
IReadOnlySet<string> knownFactionIds,
|
||||
IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
|
||||
ILogger logger)
|
||||
{
|
||||
contractsDirectory = Path.GetFullPath(contractsDirectory);
|
||||
contractTemplateSchemaPath = Path.GetFullPath(contractTemplateSchemaPath);
|
||||
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
|
||||
questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath);
|
||||
questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath);
|
||||
factionGateRuleSchemaPath = Path.GetFullPath(factionGateRuleSchemaPath);
|
||||
reputationGrantRowSchemaPath = Path.GetFullPath(reputationGrantRowSchemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(contractTemplateSchemaPath))
|
||||
errors.Add($"error: missing schema file {contractTemplateSchemaPath}");
|
||||
|
||||
if (!File.Exists(rewardGrantRowSchemaPath))
|
||||
errors.Add($"error: missing schema file {rewardGrantRowSchemaPath}");
|
||||
|
||||
if (!File.Exists(questSkillXpGrantSchemaPath))
|
||||
errors.Add($"error: missing schema file {questSkillXpGrantSchemaPath}");
|
||||
|
||||
if (!File.Exists(questRewardBundleSchemaPath))
|
||||
errors.Add($"error: missing schema file {questRewardBundleSchemaPath}");
|
||||
|
||||
if (!File.Exists(factionGateRuleSchemaPath))
|
||||
errors.Add($"error: missing schema file {factionGateRuleSchemaPath}");
|
||||
|
||||
if (!File.Exists(reputationGrantRowSchemaPath))
|
||||
errors.Add($"error: missing schema file {reputationGrantRowSchemaPath}");
|
||||
|
||||
if (!Directory.Exists(contractsDirectory))
|
||||
errors.Add($"error: missing directory {contractsDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(contractsDirectory, "*_contract_templates.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_contract_templates.json files under {contractsDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
EnsureContractSchemasLoaded(
|
||||
contractTemplateSchemaPath,
|
||||
rewardGrantRowSchemaPath,
|
||||
questSkillXpGrantSchemaPath,
|
||||
questRewardBundleSchemaPath,
|
||||
factionGateRuleSchemaPath,
|
||||
reputationGrantRowSchemaPath);
|
||||
var templateSchema = _contractTemplateSchema!;
|
||||
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
var templateIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in jsonFiles)
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(path));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root is not JsonObject rootObj)
|
||||
{
|
||||
errors.Add($"error: {path}: expected JSON object at root");
|
||||
continue;
|
||||
}
|
||||
|
||||
var schemaVersionNode = rootObj["schemaVersion"];
|
||||
if (schemaVersionNode is not JsonValue schemaVersionValue ||
|
||||
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
|
||||
schemaVersion != 1)
|
||||
{
|
||||
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var templatesNode = rootObj["contractTemplates"];
|
||||
if (templatesNode is not JsonArray templatesArray)
|
||||
{
|
||||
errors.Add($"error: {path}: expected top-level 'contractTemplates' array");
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < templatesArray.Count; i++)
|
||||
{
|
||||
var item = templatesArray[i];
|
||||
if (item is not JsonObject rowObj)
|
||||
{
|
||||
errors.Add($"error: {path}: contractTemplates[{i}] must be an object");
|
||||
continue;
|
||||
}
|
||||
|
||||
var eval = templateSchema.Evaluate(rowObj, evalOptions);
|
||||
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||
if (!eval.IsValid)
|
||||
{
|
||||
if (schemaMsgs.Count == 0)
|
||||
schemaMsgs.Add($"error: {path} contractTemplates[{i}] (root): schema validation failed");
|
||||
|
||||
errors.AddRange(schemaMsgs);
|
||||
}
|
||||
|
||||
var tid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||
if (tid is not null && eval.IsValid)
|
||||
{
|
||||
if (templateIdToSourceFile.TryGetValue(tid, out var prevPath))
|
||||
{
|
||||
errors.Add($"error: duplicate contract template id '{tid}' in {prevPath} and {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
templateIdToSourceFile[tid] = path;
|
||||
rows[tid] = ParseRow(rowObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var roster = PrototypeE7M4ContractCatalogRules.TryGetRosterGateError(templateIdToSourceFile);
|
||||
if (roster is not null)
|
||||
{
|
||||
errors.Add(roster);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
var freeze = PrototypeE7M4ContractCatalogRules.TryGetFreezeGateError(rows);
|
||||
if (freeze is not null)
|
||||
{
|
||||
errors.Add(freeze);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
var crossRef = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
|
||||
rows,
|
||||
knownEncounterIds,
|
||||
knownFactionIds,
|
||||
skillDefsById);
|
||||
if (crossRef is not null)
|
||||
{
|
||||
errors.Add(crossRef);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
var bandCap = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows);
|
||||
if (bandCap is not null)
|
||||
{
|
||||
errors.Add(bandCap);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Loaded contract template catalog from {ContractsDirectory}: {TemplateCount} template(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||
contractsDirectory,
|
||||
rows.Count,
|
||||
jsonFiles.Length);
|
||||
}
|
||||
|
||||
return new ContractTemplateCatalog(contractsDirectory, rows, jsonFiles.Length);
|
||||
}
|
||||
|
||||
private static ContractTemplateRow ParseRow(JsonObject rowObj)
|
||||
{
|
||||
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var zoneDifficultyBand = (rowObj["zoneDifficultyBand"] as JsonValue)!.GetValue<int>();
|
||||
var objectiveKind = (rowObj["objectiveKind"] as JsonValue)!.GetValue<string>();
|
||||
var encounterTemplateId = (rowObj["encounterTemplateId"] as JsonValue)!.GetValue<string>();
|
||||
var minStandingObj = rowObj["minFactionStanding"] as JsonObject;
|
||||
var factionId = (minStandingObj!["factionId"] as JsonValue)!.GetValue<string>();
|
||||
var minStanding = (minStandingObj["minStanding"] as JsonValue)!.GetValue<int>();
|
||||
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject)!;
|
||||
return new ContractTemplateRow(
|
||||
id,
|
||||
displayName,
|
||||
zoneDifficultyBand,
|
||||
objectiveKind,
|
||||
encounterTemplateId,
|
||||
new FactionGateRuleRow(factionId, minStanding),
|
||||
completionRewardBundle);
|
||||
}
|
||||
|
||||
private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj)
|
||||
{
|
||||
if (bundleObj is null)
|
||||
return null;
|
||||
|
||||
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray);
|
||||
var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray);
|
||||
var reputationGrants = ParseReputationGrantRows(bundleObj["reputationGrants"] as JsonArray);
|
||||
return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants);
|
||||
}
|
||||
|
||||
private static List<ReputationGrantRow> ParseReputationGrantRows(JsonArray? array)
|
||||
{
|
||||
if (array is null || array.Count == 0)
|
||||
return [];
|
||||
|
||||
var rows = new List<ReputationGrantRow>(array.Count);
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject grantObj)
|
||||
continue;
|
||||
|
||||
var factionId = (grantObj["factionId"] as JsonValue)!.GetValue<string>();
|
||||
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
|
||||
rows.Add(new ReputationGrantRow(factionId, amount));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<RewardGrantRow> ParseItemGrantRows(JsonArray? array)
|
||||
{
|
||||
if (array is null || array.Count == 0)
|
||||
return [];
|
||||
|
||||
var rows = new List<RewardGrantRow>(array.Count);
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject grantObj)
|
||||
continue;
|
||||
|
||||
var itemId = (grantObj["itemId"] as JsonValue)!.GetValue<string>();
|
||||
var quantity = (grantObj["quantity"] as JsonValue)!.GetValue<int>();
|
||||
rows.Add(new RewardGrantRow(itemId, quantity));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<QuestSkillXpGrantRow> ParseSkillXpGrantRows(JsonArray? array)
|
||||
{
|
||||
if (array is null || array.Count == 0)
|
||||
return [];
|
||||
|
||||
var rows = new List<QuestSkillXpGrantRow>(array.Count);
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject grantObj)
|
||||
continue;
|
||||
|
||||
var skillId = (grantObj["skillId"] as JsonValue)!.GetValue<string>();
|
||||
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
|
||||
rows.Add(new QuestSkillXpGrantRow(skillId, amount));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||
{
|
||||
var sink = new List<string>();
|
||||
AppendSchemaMessages(eval, filePath, index, sink);
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||
{
|
||||
if (r.HasDetails)
|
||||
{
|
||||
foreach (var d in r.Details!)
|
||||
AppendSchemaMessages(d, filePath, index, sink);
|
||||
}
|
||||
|
||||
if (!r.HasErrors)
|
||||
return;
|
||||
|
||||
foreach (var kv in r.Errors!)
|
||||
{
|
||||
var loc = r.InstanceLocation?.ToString();
|
||||
if (string.IsNullOrEmpty(loc) || loc == "#")
|
||||
loc = "(root)";
|
||||
|
||||
sink.Add($"error: {filePath} contractTemplates[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registers bundle + gate schemas in <see cref="SchemaRegistry.Global"/> once per process (NEO-145).</summary>
|
||||
private static void EnsureContractSchemasLoaded(
|
||||
string contractTemplateSchemaPath,
|
||||
string rewardGrantRowSchemaPath,
|
||||
string questSkillXpGrantSchemaPath,
|
||||
string questRewardBundleSchemaPath,
|
||||
string factionGateRuleSchemaPath,
|
||||
string reputationGrantRowSchemaPath)
|
||||
{
|
||||
if (_contractTemplateSchema is not null)
|
||||
return;
|
||||
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(factionGateRuleSchemaPath);
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(reputationGrantRowSchemaPath);
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
|
||||
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);
|
||||
_contractTemplateSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(contractTemplateSchemaPath);
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> errors)
|
||||
{
|
||||
if (errors.Count == 0)
|
||||
return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Contract template catalog validation failed:");
|
||||
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||
sb.AppendLine(e);
|
||||
|
||||
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
/// <summary>Adapter over <see cref="ContractTemplateCatalog"/> (NEO-145).</summary>
|
||||
public sealed class ContractTemplateRegistry(ContractTemplateCatalog catalog) : IContractTemplateRegistry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition)
|
||||
{
|
||||
if (templateId is null)
|
||||
{
|
||||
definition = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (catalog.ById.TryGetValue(templateId, out var row))
|
||||
{
|
||||
definition = row;
|
||||
return true;
|
||||
}
|
||||
|
||||
definition = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ContractTemplateRow> GetDefinitionsInIdOrder()
|
||||
{
|
||||
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var list = new List<ContractTemplateRow>(ids.Length);
|
||||
foreach (var id in ids)
|
||||
list.Add(catalog.ById[id]);
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using NeonSprawl.Server.Game.Quests;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
/// <summary>Load-time contract template row (NEO-145).</summary>
|
||||
public sealed record ContractTemplateRow(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
int ZoneDifficultyBand,
|
||||
string ObjectiveKind,
|
||||
string EncounterTemplateId,
|
||||
FactionGateRuleRow MinFactionStanding,
|
||||
QuestRewardBundleRow CompletionRewardBundle);
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to validated <see cref="ContractTemplateRow"/> entries loaded at startup (<see cref="ContractTemplateCatalog"/>).
|
||||
/// </summary>
|
||||
public interface IContractTemplateRegistry
|
||||
{
|
||||
/// <summary>Attempts to resolve a contract template by stable <c>id</c>. Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
|
||||
bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition);
|
||||
|
||||
/// <summary>Every loaded definition, ordered by <see cref="ContractTemplateRow.Id"/> (ordinal).</summary>
|
||||
IReadOnlyList<ContractTemplateRow> GetDefinitionsInIdOrder();
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
using System.Collections.Frozen;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E7M4 contract template catalog gates (NEO-145), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_*</c> and <c>_prototype_e7m4_*</c> gate functions.
|
||||
/// </summary>
|
||||
public static class PrototypeE7M4ContractCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_TEMPLATE_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedTemplateIds = FrozenSet.ToFrozenSet(
|
||||
["prototype_contract_clear_combat_pocket"],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_BAND_CAPS</c>; NEO-147 ports to issue-time validation.</summary>
|
||||
public static readonly FrozenDictionary<int, BandCapPolicy> BandCapsByDifficultyBand =
|
||||
new Dictionary<int, BandCapPolicy>
|
||||
{
|
||||
[1] = new BandCapPolicy(MaxItemQuantity: 10, MaxSkillXpAmount: 25, MaxReputationAmount: 10),
|
||||
}.ToFrozenDictionary();
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_TEMPLATE_FREEZE</c>.</summary>
|
||||
public static readonly FrozenDictionary<string, ContractTemplateRow> ExpectedTemplateFreeze =
|
||||
new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
|
||||
{
|
||||
["prototype_contract_clear_combat_pocket"] = new(
|
||||
"prototype_contract_clear_combat_pocket",
|
||||
"Clear Combat Pocket (Repeat)",
|
||||
1,
|
||||
"encounter_clear",
|
||||
"prototype_combat_pocket",
|
||||
new FactionGateRuleRow("prototype_faction_grid_operators", 0),
|
||||
new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("scrap_metal_bulk", 5)],
|
||||
[new QuestSkillXpGrantRow("salvage", 15)],
|
||||
[])),
|
||||
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if the prototype template roster fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetRosterGateError(IReadOnlyDictionary<string, string> templateIdToSourceFile)
|
||||
{
|
||||
var ids = templateIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedTemplateIds))
|
||||
{
|
||||
return
|
||||
"error: prototype E7M4 expects exactly contract template ids " +
|
||||
$"[{string.Join(", ", ExpectedTemplateIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error if template rows diverge from the E7M4 freeze table, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetFreezeGateError(IReadOnlyDictionary<string, ContractTemplateRow> rowsById)
|
||||
{
|
||||
foreach (var (tid, expected) in ExpectedTemplateFreeze)
|
||||
{
|
||||
if (!rowsById.TryGetValue(tid, out var actual))
|
||||
return $"error: missing contract template '{tid}'";
|
||||
|
||||
if (actual.DisplayName != expected.DisplayName)
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' displayName must be '{expected.DisplayName}', got '{actual.DisplayName}'";
|
||||
}
|
||||
|
||||
if (actual.ZoneDifficultyBand != expected.ZoneDifficultyBand)
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' zoneDifficultyBand must be {expected.ZoneDifficultyBand}, got {actual.ZoneDifficultyBand}";
|
||||
}
|
||||
|
||||
if (actual.ObjectiveKind != expected.ObjectiveKind)
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' objectiveKind must be '{expected.ObjectiveKind}', got '{actual.ObjectiveKind}'";
|
||||
}
|
||||
|
||||
if (actual.EncounterTemplateId != expected.EncounterTemplateId)
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' encounterTemplateId must be '{expected.EncounterTemplateId}', got '{actual.EncounterTemplateId}'";
|
||||
}
|
||||
|
||||
if (actual.MinFactionStanding.FactionId != expected.MinFactionStanding.FactionId ||
|
||||
actual.MinFactionStanding.MinStanding != expected.MinFactionStanding.MinStanding)
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' minFactionStanding must be {{ factionId: '{expected.MinFactionStanding.FactionId}', minStanding: {expected.MinFactionStanding.MinStanding} }}, " +
|
||||
$"got {{ factionId: '{actual.MinFactionStanding.FactionId}', minStanding: {actual.MinFactionStanding.MinStanding} }}";
|
||||
}
|
||||
|
||||
var actualBundle = NormalizeBundle(actual.CompletionRewardBundle);
|
||||
var expectedBundle = NormalizeBundle(expected.CompletionRewardBundle);
|
||||
if (!BundlesEqual(actualBundle, expectedBundle))
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' completionRewardBundle must match E7M4 freeze table " +
|
||||
$"(expected {FormatBundle(expectedBundle)}, got {FormatBundle(actualBundle)})";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error when cross-refs fail, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetCrossRefError(
|
||||
IReadOnlyDictionary<string, ContractTemplateRow> rowsById,
|
||||
IReadOnlySet<string> knownEncounterIds,
|
||||
IReadOnlySet<string> knownFactionIds,
|
||||
IReadOnlyDictionary<string, SkillDefRow> skillDefsById)
|
||||
{
|
||||
foreach (var tid in rowsById.Keys.Order(StringComparer.Ordinal))
|
||||
{
|
||||
var row = rowsById[tid];
|
||||
if (!knownEncounterIds.Contains(row.EncounterTemplateId))
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' encounterTemplateId '{row.EncounterTemplateId}' " +
|
||||
"is not in the loaded encounter catalog";
|
||||
}
|
||||
|
||||
if (!knownFactionIds.Contains(row.MinFactionStanding.FactionId))
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' minFactionStanding.factionId " +
|
||||
$"'{row.MinFactionStanding.FactionId}' is not in the frozen prototype faction catalog";
|
||||
}
|
||||
|
||||
var bundle = row.CompletionRewardBundle;
|
||||
for (var gi = 0; gi < bundle.ItemGrants.Count; gi++)
|
||||
{
|
||||
var grant = bundle.ItemGrants[gi];
|
||||
if (!PrototypeSlice1ItemCatalogRules.ExpectedItemIds.Contains(grant.ItemId))
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' completionRewardBundle.itemGrants[{gi}]: itemId " +
|
||||
$"'{grant.ItemId}' is not in the frozen prototype item catalog";
|
||||
}
|
||||
}
|
||||
|
||||
for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++)
|
||||
{
|
||||
var grant = bundle.SkillXpGrants[gi];
|
||||
if (!PrototypeSlice1SkillCatalogRules.ExpectedSkillIds.Contains(grant.SkillId))
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
|
||||
$"'{grant.SkillId}' is not in the frozen prototype skill catalog";
|
||||
}
|
||||
|
||||
skillDefsById.TryGetValue(grant.SkillId, out var skill);
|
||||
var allowedKinds = skill?.AllowedXpSourceKinds ?? [];
|
||||
if (!allowedKinds.Contains(PrototypeE7M2QuestCatalogRules.MissionRewardSourceKind, StringComparer.Ordinal))
|
||||
{
|
||||
var kindsDisplay = allowedKinds.Count == 0
|
||||
? "[]"
|
||||
: $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]";
|
||||
return
|
||||
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
|
||||
$"'{grant.SkillId}' must allow sourceKind '{PrototypeE7M2QuestCatalogRules.MissionRewardSourceKind}' " +
|
||||
$"in allowedXpSourceKinds (got {kindsDisplay})";
|
||||
}
|
||||
}
|
||||
|
||||
for (var gi = 0; gi < bundle.ReputationGrants.Count; gi++)
|
||||
{
|
||||
var grant = bundle.ReputationGrants[gi];
|
||||
if (!knownFactionIds.Contains(grant.FactionId))
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' completionRewardBundle.reputationGrants[{gi}]: factionId " +
|
||||
$"'{grant.FactionId}' is not in the frozen prototype faction catalog";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error if a template bundle exceeds band caps, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetBandCapGateError(IReadOnlyDictionary<string, ContractTemplateRow> rowsById)
|
||||
{
|
||||
foreach (var tid in rowsById.Keys.Order(StringComparer.Ordinal))
|
||||
{
|
||||
var row = rowsById[tid];
|
||||
if (!BandCapsByDifficultyBand.TryGetValue(row.ZoneDifficultyBand, out var caps))
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' zoneDifficultyBand {row.ZoneDifficultyBand} has no " +
|
||||
"prototype economy cap policy";
|
||||
}
|
||||
|
||||
var bundle = row.CompletionRewardBundle;
|
||||
for (var gi = 0; gi < bundle.ItemGrants.Count; gi++)
|
||||
{
|
||||
var grant = bundle.ItemGrants[gi];
|
||||
if (grant.Quantity > caps.MaxItemQuantity)
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' completionRewardBundle.itemGrants[{gi}] " +
|
||||
$"quantity {grant.Quantity} exceeds band {row.ZoneDifficultyBand} cap {caps.MaxItemQuantity}";
|
||||
}
|
||||
}
|
||||
|
||||
for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++)
|
||||
{
|
||||
var grant = bundle.SkillXpGrants[gi];
|
||||
if (grant.Amount > caps.MaxSkillXpAmount)
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}] " +
|
||||
$"amount {grant.Amount} exceeds band {row.ZoneDifficultyBand} cap {caps.MaxSkillXpAmount}";
|
||||
}
|
||||
}
|
||||
|
||||
for (var gi = 0; gi < bundle.ReputationGrants.Count; gi++)
|
||||
{
|
||||
var grant = bundle.ReputationGrants[gi];
|
||||
if (Math.Abs(grant.Amount) > caps.MaxReputationAmount)
|
||||
{
|
||||
return
|
||||
$"error: contract template '{tid}' completionRewardBundle.reputationGrants[{gi}] " +
|
||||
$"amount {grant.Amount} exceeds band {row.ZoneDifficultyBand} rep cap {caps.MaxReputationAmount}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) =>
|
||||
new(
|
||||
[.. bundle.ItemGrants.OrderBy(g => g.ItemId, StringComparer.Ordinal).ThenBy(g => g.Quantity)],
|
||||
[.. bundle.SkillXpGrants.OrderBy(g => g.SkillId, StringComparer.Ordinal).ThenBy(g => g.Amount)],
|
||||
[.. bundle.ReputationGrants.OrderBy(g => g.FactionId, StringComparer.Ordinal).ThenBy(g => g.Amount)]);
|
||||
|
||||
private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right)
|
||||
{
|
||||
if (left.ItemGrants.Length != right.ItemGrants.Length ||
|
||||
left.SkillXpGrants.Length != right.SkillXpGrants.Length ||
|
||||
left.ReputationGrants.Length != right.ReputationGrants.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < left.ItemGrants.Length; i++)
|
||||
{
|
||||
if (left.ItemGrants[i].ItemId != right.ItemGrants[i].ItemId ||
|
||||
left.ItemGrants[i].Quantity != right.ItemGrants[i].Quantity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < left.SkillXpGrants.Length; i++)
|
||||
{
|
||||
if (left.SkillXpGrants[i].SkillId != right.SkillXpGrants[i].SkillId ||
|
||||
left.SkillXpGrants[i].Amount != right.SkillXpGrants[i].Amount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < left.ReputationGrants.Length; i++)
|
||||
{
|
||||
if (left.ReputationGrants[i].FactionId != right.ReputationGrants[i].FactionId ||
|
||||
left.ReputationGrants[i].Amount != right.ReputationGrants[i].Amount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatBundle(NormalizedBundle bundle)
|
||||
{
|
||||
var itemGrants = string.Join(
|
||||
", ",
|
||||
bundle.ItemGrants.Select(g => $"{{ itemId: '{g.ItemId}', quantity: {g.Quantity} }}"));
|
||||
var skillXpGrants = string.Join(
|
||||
", ",
|
||||
bundle.SkillXpGrants.Select(g => $"{{ skillId: '{g.SkillId}', amount: {g.Amount} }}"));
|
||||
var reputationGrants = string.Join(
|
||||
", ",
|
||||
bundle.ReputationGrants.Select(g => $"{{ factionId: '{g.FactionId}', amount: {g.Amount} }}"));
|
||||
return
|
||||
$"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}], reputationGrants: [{reputationGrants}] }}";
|
||||
}
|
||||
|
||||
private sealed record NormalizedBundle(
|
||||
RewardGrantRow[] ItemGrants,
|
||||
QuestSkillXpGrantRow[] SkillXpGrants,
|
||||
ReputationGrantRow[] ReputationGrants);
|
||||
}
|
||||
|
||||
/// <summary>Prototype economy caps keyed by zone difficulty band (NEO-145).</summary>
|
||||
public sealed record BandCapPolicy(int MaxItemQuantity, int MaxSkillXpAmount, int MaxReputationAmount);
|
||||
|
|
@ -166,4 +166,16 @@ public sealed class ContentPathsOptions
|
|||
/// When unset, resolved as <c>{parent of factions directory}/schemas/faction-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? FactionDefSchemaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/contracts</c> directory.
|
||||
/// </summary>
|
||||
public string? ContractsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>contract-template.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of contracts directory}/schemas/contract-template.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? ContractTemplateSchemaPath { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
|
|
@ -32,6 +33,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
|||
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
||||
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddContractTemplateCatalog(builder.Configuration);
|
||||
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
|
||||
builder.Services.AddRewardDeliveryStore();
|
||||
builder.Services.AddFactionStandingStores(builder.Configuration);
|
||||
|
|
@ -51,6 +53,7 @@ _ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
|
|||
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<QuestDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<ContractTemplateCatalog>();
|
||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||
|
||||
|
|
|
|||
|
|
@ -254,6 +254,21 @@ Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp
|
|||
|
||||
On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.
|
||||
|
||||
## Contract template catalog (`content/contracts`, NEO-145)
|
||||
|
||||
On startup the host loads every **`*_contract_templates.json`** under the contracts directory **after** the item, recipe, encounter, skill, faction, and quest catalogs, validates each row against **`content/schemas/contract-template.schema.json`** (with **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**, **`faction-gate-rule.schema.json`**, and **`reputation-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate template `id`** values across files, cross-checks **`encounterTemplateId`** against the loaded encounter catalog and bundle item/skill/faction refs against frozen prototype catalogs, enforces the **prototype E7M4** one-template roster and freeze table (including empty **`reputationGrants`**), and enforces **band-1 economy caps** (item qty ≤ **10**, skill XP ≤ **25**, rep ≤ **10** per grant row — same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||||
|
||||
| Config | Meaning |
|
||||
|--------|---------|
|
||||
| **`Content:ContractsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/contracts`**. |
|
||||
| **`Content:ContractTemplateSchemaPath`** | Optional override for **`contract-template.schema.json`**. When unset, **`{parent of contracts directory}/schemas/contract-template.schema.json`**. |
|
||||
|
||||
Bundle- and gate-related schemas resolve as siblings under **`{parent of contracts directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those files next to the contract template schema (NEO-145).
|
||||
|
||||
**Docker / CI:** include **`content/contracts`**, upstream catalogs (**`content/encounters`**, **`content/skills`**, **`content/factions`**, **`content/items`**), and contract/bundle/faction schemas in the mounted **`content/`** tree; set **`Content__ContractsDirectory`** when layout differs.
|
||||
|
||||
On success, **Information** logs include the resolved contracts directory path, distinct template count, and catalog file count. Game code should use **`IContractTemplateRegistry`** for lookups (`TryGetDefinition`, `GetDefinitionsInIdOrder`; NEO-145). The catalog singleton remains for fail-fast startup only; do not inject **`ContractTemplateCatalog`** in new game code.
|
||||
|
||||
## Quest definitions (NEO-115)
|
||||
|
||||
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue