diff --git a/content/README.md b/content/README.md index 51f0360..5f448d0 100644 --- a/content/README.md +++ b/content/README.md @@ -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. diff --git a/content/contracts/prototype_contract_templates.json b/content/contracts/prototype_contract_templates.json new file mode 100644 index 0000000..521dc5d --- /dev/null +++ b/content/contracts/prototype_contract_templates.json @@ -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 }] + } + } + ] +} diff --git a/content/schemas/contract-seed.schema.json b/content/schemas/contract-seed.schema.json new file mode 100644 index 0000000..c98ba9c --- /dev/null +++ b/content/schemas/contract-seed.schema.json @@ -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." + } + } +} diff --git a/content/schemas/contract-template.schema.json b/content/schemas/contract-template.schema.json new file mode 100644 index 0000000..ea26782 --- /dev/null +++ b/content/schemas/contract-template.schema.json @@ -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." + } + } +} diff --git a/docs/decomposition/epics/epic_07_quest_faction.md b/docs/decomposition/epics/epic_07_quest_faction.md index 99f2c5d..21acf38 100644 --- a/docs/decomposition/epics/epic_07_quest_faction.md +++ b/docs/decomposition/epics/epic_07_quest_faction.md @@ -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. diff --git a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md index 924485c..d1f0ee5 100644 --- a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md +++ b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md @@ -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). + **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 diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index a11e87b..56efe22 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -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,21 @@ 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). ## Source anchors diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 2233b43..302b7db 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -62,6 +62,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | | E7.M3 | Ready | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09 telemetry landed ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)):** comment-only **`reputation_delta`** + **`faction_gate_blocked`** hook sites in ops layers ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **E7M3-10 client faction HUD landed ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)):** `faction_standing_client.gd`, standing label + gate deny + rep reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). **E7M3-11 client capstone landed ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)):** playable faction reputation + gate capstone — [`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.** Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**. Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [NEO-141 plan](../../plans/NEO-141-implementation-plan.md), [NEO-142 plan](../../plans/NEO-142-implementation-plan.md), [NEO-143 plan](../../plans/NEO-143-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | +| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **Decomposition landed:** Epic 7 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); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 76a0790..c287c36 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -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). 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 diff --git a/docs/plans/E7M3-pre-production-backlog.md b/docs/plans/E7M3-pre-production-backlog.md index d59d569..65a9646 100644 --- a/docs/plans/E7M3-pre-production-backlog.md +++ b/docs/plans/E7M3-pre-production-backlog.md @@ -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. --- diff --git a/docs/plans/E7M4-pre-production-backlog.md b/docs/plans/E7M4-pre-production-backlog.md new file mode 100644 index 0000000..23d9fa0 --- /dev/null +++ b/docs/plans/E7M4-pre-production-backlog.md @@ -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** + +- [ ] Host exits on invalid contract catalog or broken encounter refs at startup. +- [ ] 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) diff --git a/docs/plans/NEO-144-implementation-plan.md b/docs/plans/NEO-144-implementation-plan.md new file mode 100644 index 0000000..c974436 --- /dev/null +++ b/docs/plans/NEO-144-implementation-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** | diff --git a/docs/reviews/2026-06-20-NEO-144.md b/docs/reviews/2026-06-20-NEO-144.md new file mode 100644 index 0000000..a33e85c --- /dev/null +++ b/docs/reviews/2026-06-20-NEO-144.md @@ -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`). diff --git a/scripts/validate_content.py b/scripts/validate_content.py index 2e48a60..1c2fba2 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -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)" )