# NEO-57 — Implementation plan

## Story reference

| Field | Value |
|--------|--------|
| **Key** | NEO-57 |
| **Title** | E3.M1: Prototype ResourceNodeDef + yield tables + schemas + CI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-57/e3m1-prototype-resourcenodedef-yield-tables-schemas-ci |
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-01** |
| **Branch** | `NEO-57-e3m1-prototype-resourcenodedef-yield-schemas-ci` |
| **Precursor** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) — frozen six-item catalog (`scrap_metal_bulk`); [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) — prototype `resource_node` interact XP anchor (unchanged this story) |
| **Pattern** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) — row JSON Schema + catalog envelope + `scripts/validate_content.py` Slice gate |

## Kickoff clarifications

| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Catalog layout** | One file vs two files / schemas? | **Two files + two row schemas** — `prototype_resource_nodes.json` + `prototype_resource_yields.json`; mirrors backlog paths and E3M1-02 loader split. | **User:** two files + two row schemas. |
| **`maxGathers` in content** | Include on `ResourceNodeDef` now? | **Yes** — depletion contract is documented in module freeze; avoids schema churn before E3M1-04. | **User:** yes now. |
| **Yield quantities** | Same vs per-node quantities? | **Differentiated** (e.g. 1 / 2 / 3 / 5) — proves per-node yield tables without new item ids. | **User:** differentiated. |
| **Decomposition docs** | How to land E3M1 backlog + freeze table? | **Merge `chore/e3m1-slice2-decomposition`** into story branch first. | **User:** merge chore branch. |

## Goal, scope, and out-of-scope

**Goal:** Lock content shape and CI validation for **four** frozen `ResourceNodeDef` rows and **fixed** per-node yield rows before any server catalog load (E3M1-02).

**In scope (from Linear + [E3M1-01](E3M1-prototype-backlog.md#e3m1-01--prototype-resourcenodedef--yield-tables--schemas--ci)):**

- JSON Schemas: `resource-node-def.schema.json`, `resource-yield-row.schema.json` (single-row contracts).
- `content/resource-nodes/prototype_resource_nodes.json` — four frozen `nodeDefId` values aligned to interactable ids ([freeze table](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md#prototype-slice-2-freeze-e3m1-01)).
- `content/resource-nodes/prototype_resource_yields.json` — one yield row per node; **`itemId`** only **`scrap_metal_bulk`**; differentiated **`quantity`** per node.
- `scripts/validate_content.py` — schema validation, duplicate `nodeDefId`, exact four-node allowlist, **`gatherLens`** coverage (one per lens), cross-check yield **`itemId`** against loaded item catalog, exactly one yield row per known `nodeDefId`.
- Designer note already in E3.M1 module doc + `content/README.md` Slice 2 paragraph (from merged decomposition).

**Out of scope (from Linear):**

- Server loader, depletion store, inventory grants, HTTP, `PrototypeInteractableRegistry` new anchors (E3M1-05+), Bruno.

## Acceptance criteria checklist

- [x] PR gate validates resource-node JSON against schema.
- [x] Exactly four prototype node ids; duplicate `nodeDefId` fails CI.
- [x] Every yield references a valid frozen item id (`scrap_metal_bulk`).
- [x] Stable id list documented in module doc freeze box.

## Technical approach

1. **Row schema — `ResourceNodeDef` (`resource-node-def.schema.json`):** Draft 2020-12 object; `additionalProperties: false`. Required: `nodeDefId`, `displayName`, `gatherLens`, `maxGathers`. `nodeDefId` pattern `^[a-z][a-z0-9_]*$`. `gatherLens` enum: `consumer_salvage` \| `subsurface` \| `bio` \| `urban_bulk` (matches [gathering.md](../game-design/gathering.md#node-lenses-design-facing)). `maxGathers` integer ≥ 1.

2. **Row schema — yield row (`resource-yield-row.schema.json`):** Required: `nodeDefId`, `itemId`, `quantity`. `quantity` integer ≥ 1. `itemId` uses same id pattern as items.

3. **Catalog envelopes (mirror items):**
   - `prototype_resource_nodes.json`: `{ "schemaVersion": 1, "nodes": [ … ] }` — four rows from freeze table; placeholder `displayName` strings OK.
   - `prototype_resource_yields.json`: `{ "schemaVersion": 1, "yields": [ … ] }` — four rows, one per `nodeDefId`.

4. **Frozen content values (prototype):**

   | `nodeDefId` | `gatherLens` | `maxGathers` | yield `quantity` |
   |-------------|--------------|--------------|------------------|
   | `prototype_resource_node_alpha` | `consumer_salvage` | `10` | `1` |
   | `prototype_subsurface_vein_beta` | `subsurface` | `10` | `2` |
   | `prototype_bio_mat_gamma` | `bio` | `10` | `3` |
   | `prototype_urban_bulk_delta` | `urban_bulk` | `10` | `5` |

   All yields: `itemId` **`scrap_metal_bulk`**. `maxGathers` is uniform **10** for prototype (depletion behavior lands in E3M1-04; value is content-only until then).

5. **`validate_content.py`:**
   - Discover `content/resource-nodes/*_resource_nodes.json` and `*_resource_yields.json` (or fixed filenames above).
   - Validate each node row / yield row against respective schemas.
   - Track `seen_node_ids`; reject duplicates across node files.
   - **`_prototype_slice2_resource_node_gate`:** ids must equal `PROTOTYPE_SLICE2_NODE_IDS`; `gatherLens` set must equal four lens enums exactly once each.
   - Yield pass: reject duplicate `nodeDefId` in yields; every yield `nodeDefId` must exist in loaded nodes; every yield `itemId` must exist in item catalog (`PROTOTYPE_SLICE1_ITEM_IDS` / loaded item ids); prototype gate requires **only** `scrap_metal_bulk` as yield item id in Slice 2.
   - Require **exactly one** yield row per frozen `nodeDefId`.
   - Run resource-node validation **after** item catalogs succeed (reuse item id set from `_validate_item_catalogs`).
   - Extend success summary line with resource-node file counts.

6. **Docs (minimal delta beyond merged decomposition):**
   - `content/README.md` — add `resource-nodes/` row to table + Slice 2 freeze pointer (if not already complete post-merge).
   - `CT_M1_ContentValidationPipeline.md` — PR gate paragraph for resource-node catalogs.
   - `documentation_and_implementation_alignment.md` — E3.M1 row note NEO-57 content landed when implementation completes.

7. **No server/C#/client/Bruno changes** in this story.

## Files to add

| Path | Purpose |
|------|---------|
| `content/schemas/resource-node-def.schema.json` | JSON Schema for a single `ResourceNodeDef` row. |
| `content/schemas/resource-yield-row.schema.json` | JSON Schema for a single fixed-yield row. |
| `content/resource-nodes/prototype_resource_nodes.json` | Four-node prototype catalog (`schemaVersion` + `nodes`). |
| `content/resource-nodes/prototype_resource_yields.json` | Four yield rows (`schemaVersion` + `yields`). |

## Files to modify

| Path | Rationale |
|------|-----------|
| `scripts/validate_content.py` | Load/validate resource-node catalogs; duplicate `nodeDefId`; Slice 2 four-id + four-lens gates; yield↔item and yield↔node cross-checks; summary line. |
| `content/README.md` | Document `content/resource-nodes/`, schema paths, Slice 2 freeze (if merge left gaps). |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for resource-node + yield validation. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 / NEO-57 status after catalog lands. |

**Already on branch (merge `chore/e3m1-slice2-decomposition`):** `docs/plans/E3M1-prototype-backlog.md`, `docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md` (freeze box), `docs/decomposition/modules/module_dependency_register.md` (E3.M1 note), `content/README.md` Slice 2 pointer.

## Tests

| Item | Coverage |
|------|----------|
| **CI / PR gate** | `.github/workflows/pr-gate.yml` runs `python scripts/validate_content.py` — extended script is the regression signal. |
| **Manual negative cases** | From repo root: (1) duplicate `nodeDefId` in nodes file → non-zero exit; (2) remove one frozen id → gate error; (3) invalid `gatherLens` → schema error; (4) yield references `not-a-prototype-item` → cross-check error; (5) second yield row for same `nodeDefId` → duplicate error; (6) wrong yield item id (not `scrap_metal_bulk`) → Slice 2 item gate error. |
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports resource-node catalogs + existing catalogs OK. |

No dedicated pytest module (same as NEO-50 / NEO-33). No C# tests until E3M1-02 server loader.

## Open questions / risks

| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Interactable registry anchors** for beta/gamma/delta | **Defer to E3M1-05** — content ids frozen now; world X/Z added when interactables list grows. | **deferred** |
| **C# vs Python drift** | None until E3M1-02; duplicate `PROTOTYPE_SLICE2_*` constants in loader + script with “keep in sync” comment (NEO-50 pattern). | **adopted** |
| **Catalog growth beyond four nodes** | New Linear issue to relax `PROTOTYPE_SLICE2_NODE_IDS` and lens gate. | **adopted** |
| **Per-lens skill XP** | Stay **`salvage`** + **`activity`** until content pass; not in JSON this story. | **adopted** |

## Decisions (kickoff)

- **Two-file layout** with separate row schemas for nodes and yields.
- **`maxGathers`** on node defs in content now (`10` for all prototype rows).
- **Differentiated yield quantities** 1 / 2 / 3 / 5 by lens order in freeze table.
- **Decomposition docs** merged from `chore/e3m1-slice2-decomposition` on this branch before implementation commits.
