183 lines
13 KiB
Markdown
183 lines
13 KiB
Markdown
# 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** |
|