neon-sprawl/docs/plans/NEO-144-implementation-plan.md

183 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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-145NEO-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** |