Merge pull request #92 from ViPro-Technologies/NEO-57-e3m1-prototype-resourcenodedef-yield-schemas-ci

NEO-57: E3.M1 prototype ResourceNodeDef + yield tables + schemas + CI
pull/93/head
VinPropane 2026-05-24 14:01:09 -04:00 committed by GitHub
commit 8c1e0552b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 873 additions and 17 deletions

View File

@ -8,9 +8,12 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
| [`skills/`](skills/) | Skill catalogs; each row matches [`schemas/skill-def.schema.json`](schemas/skill-def.schema.json) — **stable `id`**, **`allowedXpSourceKinds`** for [E2.M1](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md) / [E2.M2](../docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
| [`mastery/`](mastery/) | Mastery catalogs; each file matches [`schemas/mastery-catalog.schema.json`](schemas/mastery-catalog.schema.json) — **stable `branchId` / `perkId`**, tier gates via skill level ([E2.M3](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md)) |
| [`items/`](items/) | Item catalogs; each row matches [`schemas/item-def.schema.json`](schemas/item-def.schema.json) — **stable `id`**, **`stackMax`**, **`inventorySlotKind`** for [E3.M3](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.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.
**Prototype Slice 2 — resource nodes (NEO-57):** CI expects **exactly four** `nodeDefId` values aligned to [E3.M1 gather lens freeze](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md#prototype-slice-2-freeze-e3m1-01) — one row per **`gatherLens`**; exactly one yield row per node referencing **`scrap_metal_bulk`** only. **Do not rename** `nodeDefId` after ship. See [E3M1-prototype-backlog.md](../docs/plans/E3M1-prototype-backlog.md) and [NEO-57 plan](../docs/plans/NEO-57-implementation-plan.md).
**Prototype Slice 1 — items (NEO-50):** CI expects **exactly six** item rows with ids **`scrap_metal_bulk`**, **`refined_plate_stock`**, **`field_stim_mk0`**, **`survey_drone_kit`**, **`contract_handoff_token`**, **`prototype_armor_shell`** — one row per **`prototypeRole`** (`material` through `equip_stub`). **Do not rename** ids after ship—change **`displayName`** only. See [E3.M3 — Designer note](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#designer-note-stack-slot-and-v1-scope) and [freeze table](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#prototype-slice-1-freeze-neo-50).
**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).

View File

@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"nodes": [
{
"nodeDefId": "prototype_resource_node_alpha",
"displayName": "Prototype Salvage Heap",
"gatherLens": "consumer_salvage",
"maxGathers": 10
},
{
"nodeDefId": "prototype_subsurface_vein_beta",
"displayName": "Prototype Subsurface Vein",
"gatherLens": "subsurface",
"maxGathers": 10
},
{
"nodeDefId": "prototype_bio_mat_gamma",
"displayName": "Prototype Bio Mat",
"gatherLens": "bio",
"maxGathers": 10
},
{
"nodeDefId": "prototype_urban_bulk_delta",
"displayName": "Prototype Urban Bulk",
"gatherLens": "urban_bulk",
"maxGathers": 10
}
]
}

View File

@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"yields": [
{
"nodeDefId": "prototype_resource_node_alpha",
"itemId": "scrap_metal_bulk",
"quantity": 1
},
{
"nodeDefId": "prototype_subsurface_vein_beta",
"itemId": "scrap_metal_bulk",
"quantity": 2
},
{
"nodeDefId": "prototype_bio_mat_gamma",
"itemId": "scrap_metal_bulk",
"quantity": 3
},
{
"nodeDefId": "prototype_urban_bulk_delta",
"itemId": "scrap_metal_bulk",
"quantity": 5
}
]
}

View File

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/resource-node-def.json",
"title": "ResourceNodeDef",
"description": "Single resource node row for catalogs (e.g. content/resource-nodes/*_resource_nodes.json). nodeDefId is stable forever—rename displayName only. See docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md.",
"type": "object",
"additionalProperties": false,
"required": ["nodeDefId", "displayName", "gatherLens", "maxGathers"],
"properties": {
"nodeDefId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable node key; must match interactableId for prototype wiring."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating state."
},
"gatherLens": {
"type": "string",
"description": "Prototype Slice 2 gather lens (one per frozen node). CI requires exactly one row per lens.",
"enum": ["consumer_salvage", "subsurface", "bio", "urban_bulk"]
},
"maxGathers": {
"type": "integer",
"minimum": 1,
"description": "Successful gathers before node instance is depleted (prototype Slice 2)."
}
}
}

View File

@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/resource-yield-row.json",
"title": "ResourceYieldRow",
"description": "Fixed yield row for a resource node (content/resource-nodes/*_resource_yields.json). One row per nodeDefId in prototype Slice 2.",
"type": "object",
"additionalProperties": false,
"required": ["nodeDefId", "itemId", "quantity"],
"properties": {
"nodeDefId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Resource node this yield applies to."
},
"itemId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Item def id from content/items (prototype Slice 2: scrap_metal_bulk only)."
},
"quantity": {
"type": "integer",
"minimum": 1,
"description": "Items granted per successful gather."
}
}
}

View File

@ -45,7 +45,9 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
**Mastery catalogs ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45)):** the same script validates `content/mastery/*_mastery.json` against [`content/schemas/mastery-catalog.schema.json`](../../../content/schemas/mastery-catalog.schema.json), cross-checks track `skillId` against loaded `SkillDef` ids, rejects **duplicate `perkId`**, enforces **branch integrity** (tier branch sets match; `perkIds` reference defined perks), and (Slice 4) requires **exactly one** track for **`salvage`** only.
**Item catalogs ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)):** the same script validates each row in `content/items/*_items.json` against [`content/schemas/item-def.schema.json`](../../../content/schemas/item-def.schema.json), rejects **duplicate `id`** across files, and (Slice 1) enforces the **frozen six-item** id set plus **exactly one row per `prototypeRole`** (`material` through `equip_stub`). Extend the script when additional catalogs and schemas ship.
**Item catalogs ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)):** the same script validates each row in `content/items/*_items.json` against [`content/schemas/item-def.schema.json`](../../../content/schemas/item-def.schema.json), rejects **duplicate `id`** across files, and (Slice 1) enforces the **frozen six-item** id set plus **exactly one row per `prototypeRole`** (`material` through `equip_stub`).
**Resource node catalogs ([NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)):** the same script validates `content/resource-nodes/*_resource_nodes.json` rows against [`content/schemas/resource-node-def.schema.json`](../../../content/schemas/resource-node-def.schema.json) and `*_resource_yields.json` rows against [`content/schemas/resource-yield-row.schema.json`](../../../content/schemas/resource-yield-row.schema.json); rejects **duplicate `nodeDefId`**; (Slice 2) enforces the **frozen four-node** id set, **one row per `gatherLens`**, exactly **one yield row per node**, yield **`itemId`** cross-check against item catalogs, and **only `scrap_metal_bulk`** as yield item in prototype. Extend the script when additional catalogs and schemas ship.
**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.

View File

@ -7,41 +7,97 @@
| **Module ID** | E3.M1 |
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
| **Stage target** | Prototype |
| **Status** | In Progress (see [dependency register](module_dependency_register.md); prototype gather XP anchor [NEO-41](../../plans/NEO-41-implementation-plan.md)) |
| **Status** | In Progress — Slice 2 backlog [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)[NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md); NEO-41 anchor below; see [dependency register](module_dependency_register.md)) |
| **Linear** | Label **`E3.M1`** per [decomposition README — Linear alignment](../README.md#linear-alignment) |
## Purpose
World resource nodes, gather interactions, and yield outputs feeding inventory ([E3.M3](E3_M3_ItemizationAndInventorySchema.md)) and XP ([E2.M2](E2_M2_XpAwardAndLevelEngine.md)).
**Authority:** [Client vs server authority](client_server_authority.md) — the server resolves **whether** a gather succeeds, **what** items are granted, **remaining node capacity**, and **skill XP** from gather; the client sends **interact intent** via [E1.M3 — InteractionAndTargetingLayer](E1_M3_InteractionAndTargetingLayer.md) (`POST …/interact`) and displays outcomes.
World resource nodes, gather interactions, and yield outputs feeding inventory ([E3.M3](E3_M3_ItemizationAndInventorySchema.md)) and skill XP ([E2.M2](E2_M2_XpAwardAndLevelEngine.md)). Ecology / respawn pacing per zone defers to [E4.M2 — SpawnEcologyController](E4_M2_SpawnEcologyController.md).
## Boundary: E1.M3 vs E3.M1
| Topic | E1.M3 (today) | E3.M1 (this module) |
|--------|----------------|---------------------|
| **Primary job** | Interactable eligibility (range, known id, kind) | **Gather resolution**: yields, depletion, inventory + XP side effects |
| **Wire / HTTP** | `POST …/interact` + `InteractionResponse` | No separate gather route in prototype — E3.M1 hooks **after** E1.M3 allows interact |
| **Registry** | `PrototypeInteractableRegistry` / `GET …/interactables` | **`ResourceNodeDef`** + **`ResourceYieldTable`** content; instance capacity store |
| **Denials** | `out_of_range`, `unknown_interactable` | Adds **`node_depleted`**, `inventory_full`, etc. via gather engine |
**Shared policy:** horizontal reach on **X/Z** matches [NEO-9](../../plans/NEO-9-implementation-plan.md) / E1.M3 — gather does not redefine range math.
## Responsibilities
- Spawn/manage nodes per `ResourceNodeDef`; resolve `GatherResult` via `ResourceYieldTable`; integrate interaction range via [E1.M3](E1_M3_InteractionAndTargetingLayer.md).
- Author **`ResourceNodeDef`** and **`ResourceYieldTable`** content; load and expose defs to server subsystems.
- Track per-instance **remaining gathers** (prototype capacity/depletion); deny when exhausted.
- Resolve **`GatherResult`**: item grants into [E3.M3](E3_M3_ItemizationAndInventorySchema.md) inventory, **`salvage`** skill XP via [E2.M2](E2_M2_XpAwardAndLevelEngine.md) grant operations.
- Integrate with **`resource_node`** interactables in `PrototypeInteractableRegistry` (world anchors until [E4.M1](E4_M1_ZoneGraphAndTravelRules.md)).
**Deferred:** zone spawn profiles, regen timers, probabilistic yields, cross-skill unlock nodes — [gathering.md](../../game-design/gathering.md) open questions.
## Key contracts
| Contract | Role |
|----------|------|
| `ResourceNodeDef` | Node type, capacity, regen. |
| `GatherResult` | Items and depletion state. |
| `ResourceYieldTable` | Probabilities or fixed yields. |
| Contract | Kind | Role |
|----------|------|------|
| `ResourceNodeDef` | Content | Node type, max gathers, gather lens, link to yield table. |
| `ResourceYieldTable` | Content | Fixed item outputs per gather (`itemId`, quantity). |
| `GatherResult` | Server-internal (prototype) | Success/deny, granted items, depletion snapshot, reason codes. |
### Contract notes ([contracts.md](contracts.md))
- **Content** defs live under `content/resource-nodes/` with JSON Schema in CI ([E3M1-01](../../plans/E3M1-prototype-backlog.md#e3m1-01--prototype-resourcenodedef--yield-tables--schemas--ci)).
- **`GatherResult`** may promote to **wire** JSON or Protobuf when the client needs explicit gather payloads; until then, prototype uses **`InteractionResponse`** + inventory GET for verification.
- **Denials:** stable **`reasonCode`** strings on interact deny (`node_depleted`, `inventory_full`, …) consistent with [E1.M3](E1_M3_InteractionAndTargetingLayer.md) patterns.
**Logical fields (illustrative):**
| Contract | Illustrative fields |
|----------|---------------------|
| `ResourceNodeDef` | `nodeDefId`; `displayName`; `gatherLens` (`consumer_salvage`, `subsurface`, `bio`, `urban_bulk`); `maxGathers` — see `content/resource-nodes/*_resource_nodes.json` |
| `ResourceYieldTable` | `nodeDefId`; `itemId`; `quantity` — prototype: one row per node in `*_resource_yields.json` |
| `GatherResult` | `success`; `reasonCode`; `grantsApplied[]`; `remainingGathers`; optional `xpGrantSummary` |
## Module dependencies
- **E1.M3** — InteractionAndTargetingLayer.
- **E2.M2** — XpAwardAndLevelEngine.
- **E1.M3** — InteractionAndTargetingLayer (`POST …/interact`, interactable descriptors).
- **E2.M2** — XpAwardAndLevelEngine (skill XP grant operations).
- **E3.M3** — ItemizationAndInventorySchema (inventory grants) — logical dependency for Slice 2 yields; register lists E1.M3 + E2.M2 only so gather XP anchor ([NEO-41](../../plans/NEO-41-implementation-plan.md)) could land before inventory.
## Dependents (by design)
- **E3.M2** — RefinementAndRecipeExecution (inputs).
- **E4.M2** — SpawnEcologyController.
- **E3.M2** — RefinementAndRecipeExecution (crafted inputs from gathered materials).
- **E4.M2** — SpawnEcologyController (respawn / ecology).
## Designer note: gather lenses and yields
Prototype Slice 2 uses **four** nodes — one per [gather lens](../../game-design/gathering.md#node-lenses-design-facing). All prototype yields grant **`scrap_metal_bulk`** (different quantities per node are allowed) so the gather→refine loop can start without expanding the item catalog. **Skill XP** stays **`salvage`** + **`activity`** (10 XP) on every node until per-lens skill mapping is a deliberate content pass.
**Depletion:** each interactable instance has **`maxGathers`** successful collects; at zero, interact returns **`node_depleted`**. Regen on timer is **out of scope** for Slice 2 ([E4.M2](E4_M2_SpawnEcologyController.md)).
## Prototype Slice 2 freeze (E3M1-01)
The **first shipped four-node spine** is **frozen** for downstream references (recipes, ecology, quests). **`nodeDefId`** must match **`interactableId`** in `PrototypeInteractableRegistry` for prototype wiring.
| `nodeDefId` / `interactableId` | `gatherLens` | Notes |
|-------------------------------|--------------|--------|
| **`prototype_resource_node_alpha`** | `consumer_salvage` | Existing NEO-9/NEO-41 anchor (12, 6) |
| **`prototype_subsurface_vein_beta`** | `subsurface` | New anchor — plan PR sets X/Z |
| **`prototype_bio_mat_gamma`** | `bio` | New anchor — plan PR sets X/Z |
| **`prototype_urban_bulk_delta`** | `urban_bulk` | New anchor — plan PR sets X/Z |
**Rules:** Do **not** rename these ids after ship. CI enforces **exactly four** node defs and valid yield item references. Relaxing the gate belongs in a new Linear issue when the roster grows.
## Related implementation slices
Epic 3 **Slice 2** — 46 node types; `resource_gathered`, `gather_node_depleted`.
Epic 3 **Slice 2**[gather nodes and outputs](../epics/epic_03_crafting_economy.md#epic-3-slice-2): 46 node types, yield tables, `resource_gathered`, `gather_node_depleted`.
**Prototype implementation anchor (NEO-41):** until **`GatherResult`** / yield tables exist, the repo treats a **successful** **`POST …/interact`** on an entry with **`kind: resource_node`** (`prototype_resource_node_alpha`) as **gather completion** for **skill XP** only — **`salvage`** + **`activity`** via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) (same rules as NEO-38). Migrate this hook when a dedicated gather pipeline lands.
**Prototype implementation anchor (NEO-41):** until **`GatherResult`** lands, a **successful** **`POST …/interact`** on **`kind: resource_node`** grants **`salvage`** skill XP only (`activity`, 10 XP) via `SkillProgressionGrantOperations`**no** inventory or depletion. **E3M1-07** migrates XP into the gather engine and adds item + capacity behavior ([E3M1-prototype-backlog](../../plans/E3M1-prototype-backlog.md)).
**Linear backlog (decomposed):** [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md) — **E3M1-01** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) through **E3M1-08** [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 3.
- [gathering.md](../../game-design/gathering.md)
- [Module dependency register](module_dependency_register.md)

View File

@ -53,7 +53,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) |
| E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/``MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) |
| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-41, NEO-42NEO-44 |
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **NEO-57 landed:** frozen four-node catalog in [`content/resource-nodes/`](../../../content/resource-nodes/); [`resource-node-def.schema.json`](../../../content/schemas/resource-node-def.schema.json), [`resource-yield-row.schema.json`](../../../content/schemas/resource-yield-row.schema.json); CI gates in [`validate_content.py`](../../../scripts/validate_content.py). **Still planned:** server load, `GatherResult`, depletion, inventory grants per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-57](../../plans/NEO-57-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `content/resource-nodes/`, `server/NeonSprawl.Server/Game/Interaction/` |
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
---

View File

@ -48,6 +48,8 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
| E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned |
| E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned |
**E3.M1 note:** Epic 3 **Slice 2** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) → [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64); label **`E3.M1`**. See [E3M1-prototype-backlog.md](../../plans/E3M1-prototype-backlog.md), [E3_M1_ResourceNodeAndGatherLoop.md](E3_M1_ResourceNodeAndGatherLoop.md). **NEO-41** (prototype gather XP on `resource_node` interact) remains until **E3M1-07** migrates into the gather engine. Slice 2 adds **`ResourceNodeDef`**, **`ResourceYieldTable`**, **`GatherResult`**, depletion store, inventory grants, and telemetry hooks **`resource_gathered`** / **`gather_node_depleted`**.
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), **NEO-55** (`GET`/`POST /game/players/{id}/inventory`), and **NEO-56** (comment-only `item_created` / `inventory_transfer_denied` telemetry hook sites in `PlayerInventoryOperations`) — Epic 3 Slice 1 backlog **E3M3-01****E3M3-07** complete; register row **In Progress** until Slice 2+; later slices update the alignment table as they land.
### Epic 4 — World Topology

View File

@ -0,0 +1,257 @@
# E3.M1 — Prototype story backlog (ResourceNodeAndGatherLoop)
Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-2)). Decomposition and contracts: [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md).
**Labels (Linear):** every issue **`E3.M1`**; add **`server`** / **`Story`** as listed per issue.
**Precursor (do not re-scope):** [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41) grants **`salvage`** skill XP on successful **`POST …/interact`** when interactable **`kind`** is **`resource_node`** — **no** `GatherResult`, yields, inventory, or depletion yet. Slice 2 **migrates** XP into the gather engine and adds item grants + node capacity.
**Upstream (must be landed):** [E3.M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) Slice 1 — frozen item catalog, **`IPlayerInventoryStore`**, inventory operations ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)[NEO-56](https://linear.app/neon-sprawl/issue/NEO-56)). **E1.M3** interact + range ([NEO-9](https://linear.app/neon-sprawl/issue/NEO-9), [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25)). **E2.M2** grant path ([NEO-38](https://linear.app/neon-sprawl/issue/NEO-38)).
**Prototype gather spine (frozen in E3M1-01):** **four** `ResourceNodeDef` rows — one per [gather lens](../game-design/gathering.md#node-lenses-design-facing) — each with a fixed **`ResourceYieldTable`** entry awarding **`scrap_metal_bulk`** (primary material from [E3.M3 freeze](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#prototype-slice-1-freeze-neo-50)). World placement stays on **`PrototypeInteractableRegistry`** until zone graph ([E4.M1](../decomposition/modules/E4_M1_ZoneGraphAndTravelRules.md)) ships.
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
| Slug | Linear |
|------|--------|
| E3M1-01 | [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) |
| E3M1-02 | [NEO-58](https://linear.app/neon-sprawl/issue/NEO-58) |
| E3M1-03 | [NEO-59](https://linear.app/neon-sprawl/issue/NEO-59) |
| E3M1-04 | [NEO-60](https://linear.app/neon-sprawl/issue/NEO-60) |
| E3M1-05 | [NEO-61](https://linear.app/neon-sprawl/issue/NEO-61) |
| E3M1-06 | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) |
| E3M1-07 | [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) |
| E3M1-08 | [NEO-64](https://linear.app/neon-sprawl/issue/NEO-64) |
**Dependency graph in Linear:** E3M1-02 blocked by E3M1-01. E3M1-03 blocked by E3M1-02. E3M1-04 blocked by E3M1-03. E3M1-05 blocked by E3M1-03. E3M1-06 blocked by E3M1-05 and E3M1-03 (inventory from E3.M3). E3M1-07 blocked by E3M1-06. E3M1-08 blocked by E3M1-06 (may parallel E3M1-07).
---
## Story order (recommended)
| Order | Slug | Depends on |
|-------|------|------------|
| 1 | **E3M1-01** | E3.M3 item catalog (NEO-50) |
| 2 | **E3M1-02** | E3M1-01 |
| 3 | **E3M1-03** | E3M1-02 |
| 4 | **E3M1-04** | E3M1-03 |
| 5 | **E3M1-05** | E3M1-03 |
| 6 | **E3M1-06** | E3M1-05, E3.M3 inventory store (NEO-54+) |
| 7 | **E3M1-07** | E3M1-06 |
| 8 | **E3M1-08** | E3M1-06 |
**Downstream (separate modules):** [E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) recipes consume gathered materials; [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) refine XP hook runs after craft success; [E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md) owns respawn/ecology pacing later.
---
## Kickoff decisions (decomposition defaults)
| Topic | Decision | Rationale |
|-------|----------|-----------|
| Node count | **4** frozen defs (epic allows 46) | One per gather lens; enough variety without bloating prototype registry |
| Gather wire surface | Keep **`POST …/interact`** ([E1.M3](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md)) | No separate gather HTTP in prototype; E3.M1 owns resolution **after** interact eligibility |
| Yields | **Fixed** quantities per gather (no RNG) | Deterministic tests; probability tables are pre-production |
| Depleted node | **`Allowed: false`** + **`reasonCode: node_depleted`** | Prevents “free” taps; clearer than silent no-op |
| XP | **Migrate** NEO-41 inline grant into gather engine on successful yield | Single call site for gather completion side effects |
| Skill per node | All prototype nodes award **`salvage`** + **`activity`** (10 XP) | Matches NEO-41; per-lens skill mapping is content follow-up |
---
### E3M1-01 — Prototype ResourceNodeDef + yield tables + schemas + CI
**Goal:** Lock content shape and CI validation for **four** frozen resource nodes and their **fixed** yield tables before server load.
**In scope**
- `content/schemas/resource-node-def.schema.json` and `content/schemas/resource-yield-row.schema.json` (two row schemas; catalogs in `prototype_resource_nodes.json` + `prototype_resource_yields.json` — [NEO-57](NEO-57-implementation-plan.md)).
- `content/resource-nodes/prototype_resource_nodes.json` with stable **`nodeDefId`** values aligned to interactable ids (see module doc freeze table).
- `content/resource-nodes/prototype_resource_yields.json` — one yield row per `nodeDefId`, referencing only **`scrap_metal_bulk`** from the E3.M3 freeze.
- `scripts/validate_content.py`: schema validation, duplicate ids, exact four-node allowlist, **`gatherLens`** coverage, cross-check that every yield **`itemId`** exists in `content/items/`.
- Designer note in [E3_M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) + `content/README.md`.
**Out of scope**
- Server loader, depletion store, inventory grants, HTTP.
**Acceptance criteria**
- [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.
- [x] Stable id list documented in module doc freeze box.
**Landed:** [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) — content + CI only; server load is E3M1-02.
---
### E3M1-02 — Server resource-node catalog load (fail-fast)
**Goal:** Disk → host: startup load of `content/resource-nodes/*.json` with CI-parity validation.
**In scope**
- Loader + catalog types under `server/NeonSprawl.Server/Game/Gathering/` (path TBD in plan).
- Fail-fast on malformed files, duplicate ids, unknown yield item ids (cross-check **`IItemDefinitionRegistry`** at startup).
- Unit tests (AAA) for loader happy path and failure modes.
**Out of scope**
- Per-instance depletion, HTTP, interact wiring.
**Acceptance criteria**
- [ ] Server refuses boot when resource-node catalog invalid (mirror E3.M3 / E2.M1 catalog behavior).
- [ ] Catalog resolves `ResourceNodeDef` and yield row by `nodeDefId`.
---
### E3M1-03 — Resource node definition registry + DI
**Goal:** Injectable `IResourceNodeDefinitionRegistry` consumed by gather engine and interact integration.
**In scope**
- `ResourceNodeDefinitionRegistry` implementation + DI registration.
- Unit tests (AAA): lookup, unknown id, yield metadata, capacity fields.
**Out of scope**
- HTTP, persistence of depletion state.
**Acceptance criteria**
- [ ] Host resolves registry from DI; unknown `nodeDefId` distinguishable from valid ids in tests.
---
### E3M1-04 — GET world resource-node-definitions
**Goal:** Versioned read-only catalog for Bruno, client preview, and tooling — mirror [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) item-definitions pattern.
**In scope**
- `GET /game/world/resource-node-definitions` + DTOs + API tests (AAA).
- Bruno folder `bruno/neon-sprawl-server/resource-node-definitions/`.
- `server/README.md` section.
**Out of scope**
- Per-instance remaining capacity (instance state is E3M1-05).
**Acceptance criteria**
- [ ] GET returns all four prototype defs with schema version field and yield summary fields needed for QA.
- [ ] Bruno happy path documented.
---
### E3M1-05 — World node instance depletion store
**Goal:** Server-authoritative **remaining gathers** per node instance (`interactableId`), with structured deny when depleted.
**In scope**
- `IResourceNodeInstanceStore` (or equivalent) keyed by **`interactableId`** (matches [NEO-25](https://linear.app/neon-sprawl/issue/NEO-25) descriptor ids).
- Initialize capacity from `ResourceNodeDef.maxGathers` (or equivalent field name frozen in E3M1-01).
- Decrement on successful gather commit; **`node_depleted`** when zero.
- Persistence: in-memory + Postgres + migration (mirror NEO-54 / NEO-8 policy).
- Unit + integration tests (AAA).
**Out of scope**
- Item grants, skill XP, HTTP beyond store tests.
**Acceptance criteria**
- [ ] First gather on a fresh node succeeds; repeated gathers until capacity returns **`node_depleted`** without going negative.
- [ ] Postgres + in-memory parity tests.
---
### E3M1-06 — GatherResult engine (yields + inventory + skill XP)
**Goal:** Single server operation that resolves **`GatherResult`**: validate node + capacity, grant items via **`PlayerInventoryOperations`**, award **`salvage`** XP via **`SkillProgressionGrantOperations`**, commit depletion.
**In scope**
- `GatherOperations` / `GatherResult` types in `server/NeonSprawl.Server/Game/Gathering/`.
- Stable deny reason codes: `node_depleted`, `inventory_full`, `unknown_node`, etc.
- Unit tests (AAA) with stub inventory + progression stores.
- Document **`GatherResult`** shape in module doc (server-internal until promoted to wire).
**Out of scope**
- `InteractionApi` wiring (E3M1-07).
- Client HUD.
- Regen / respawn timers ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
**Acceptance criteria**
- [ ] Successful gather adds configured **`scrap_metal_bulk`** quantity to player bag.
- [ ] Full bag returns stable `reasonCode` without partial silent loss.
- [ ] Skill XP grant uses same rules as NEO-38 / NEO-41 (`salvage`, `activity`, 10 XP).
- [ ] Depletion store decrements atomically with successful grant.
---
### E3M1-07 — Interact path integration + registry expansion + Bruno
**Goal:** Replace NEO-41 inline XP block with gather engine; register **four** prototype resource nodes in **`PrototypeInteractableRegistry`**; extend Bruno/manual QA for gather → inventory.
**In scope**
- `InteractionApi`: on `resource_node` kind, call gather engine; map engine deny → `InteractionResponse` with **`Allowed: false`** + `reasonCode` (including **`node_depleted`**).
- Remove duplicate XP grant path once gather engine covers it.
- Add three new interactable anchors (positions TBD in plan; keep **`prototype_resource_node_alpha`** anchor).
- Bruno: move near node → interact → GET inventory shows **`scrap_metal_bulk`**; depleted node deny.
- `server/README.md`; integration tests (AAA).
**Out of scope**
- Godot gather HUD (optional follow-up).
- New gather-specific HTTP route.
**Acceptance criteria**
- [ ] In-range interact on fresh node increases inventory quantity and **`salvage`** XP.
- [ ] Depleted node returns **`node_depleted`** without inventory or XP change.
- [ ] Terminal interact unchanged (no gather side effects).
- [ ] Bruno documents happy path + depletion deny for at least one node.
---
### E3M1-08 — `resource_gathered` / `gather_node_depleted` telemetry hook sites
**Goal:** Comment-only hooks on gather success and depletion for future E9.M1 catalog events.
**In scope**
- Hook placement in gather engine (and interact deny branch if applicable).
- `TODO(E9.M1)` comments; no production logging.
- README + module doc pointer.
**Out of scope**
- Telemetry ingest, dashboards.
**Acceptance criteria**
- [ ] Hook sites documented in code and `server/README.md`.
- [ ] Matches Epic 3 Slice 2 telemetry vocabulary in [epic_03](../decomposition/epics/epic_03_crafting_economy.md#epic-3-slice-2).
---
## After this backlog
- Decompose **[E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md)** Slice 3 (recipes + craft HTTP) once E3M1-06+ land and inventory holds gathered materials.
- Track delivery in Linear; keep `blockedBy` links synchronized if scope changes.
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
- Add `docs/manual-qa/{NEO-XX}.md` when interact + inventory surfaces are testable (E3M1-07+).
## Related docs
- [E3_M1_ResourceNodeAndGatherLoop.md](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)
- [E3M3-prototype-backlog.md](E3M3-prototype-backlog.md)
- [epic_03_crafting_economy.md](../decomposition/epics/epic_03_crafting_economy.md)
- [gathering.md](../game-design/gathering.md)
- [NEO-41 implementation plan](NEO-41-implementation-plan.md)

View File

@ -196,7 +196,7 @@ Working backlog for **Epic 3 — Slice 1** ([items and inventory MVP](../decompo
## After this backlog
- Decompose **[E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)** Slice 2 (gather yields → inventory) once E3M3-05+ land.
- **[E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)** Slice 2 decomposed — [E3M1-prototype-backlog.md](E3M1-prototype-backlog.md) ([NEO-57](https://linear.app/neon-sprawl/issue/NEO-57)[NEO-64](https://linear.app/neon-sprawl/issue/NEO-64)).
- Track delivery in Linear; keep `blockedBy` links synchronized if scope changes.
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
- Add `docs/manual-qa/{NEO-XX}.md` when HTTP surfaces land (E3M3-06+).

View File

@ -0,0 +1,129 @@
# 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.

View File

@ -0,0 +1,61 @@
# Code review — NEO-57 prototype resource node catalogs + CI
**Date:** 2026-05-24
**Scope:** Branch `NEO-57-e3m1-prototype-resourcenodedef-yield-schemas-ci` · commits `17daf9a``ca1a15f` vs `origin/main` (decomposition merge + plan + implementation + review follow-ups)
**Base:** `origin/main`
## Verdict
**Approve**
## Summary
NEO-57 lands **content-only** Slice 2 gather spine: two JSON Schemas (`resource-node-def`, `resource-yield-row`), four frozen `ResourceNodeDef` rows and matching yield rows under `content/resource-nodes/`, and extended `scripts/validate_content.py` with duplicate `nodeDefId` detection, frozen four-id / four-lens gates, yield↔node and yield↔item cross-checks, and `scrap_metal_bulk`-only yield item gate. The implementation mirrors the NEO-50 item-catalog pattern (row schema + `schemaVersion` envelope + prototype allowlists). Docs (E3.M1 module freeze, CT.M1 PR gate, alignment table, `content/README.md`, E3M1 backlog) align with the implementation plan. No server, client, or Bruno changes — appropriate for E3M1-01. Risk is low; CI script is the primary regression surface.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-57-implementation-plan.md`](../plans/NEO-57-implementation-plan.md) | **Matches** — two-file layout, `maxGathers`, differentiated quantities 1/2/3/5, acceptance checklist complete; no server scope. |
| [`docs/plans/E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) | **Matches** — E3M1-01 acceptance checked; yield row schema name aligned (post-review). |
| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — Slice 2 freeze table, designer note, contracts, E1.M3 boundary; content ids match JSON. |
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — NEO-57 PR gate paragraph for resource-node + yield validation. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-57 content + CI; server load still planned. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M1 note for NEO-57NEO-64 Slice 2 backlog. |
| [`content/README.md`](../../content/README.md) | **Matches**`resource-nodes/` table row + Slice 2 freeze pointer. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — no runtime authority change this story (content + CI only). |
Register/tracking: alignment table and CT.M1 updated; E3.M1 module status reflects Slice 2 backlog in progress.
## Blocking issues
None.
## Suggestions
None (resolved in `ca1a15f` and follow-up backlog edit).
1. ~~**E3M1 backlog checkboxes** — In [`E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) § E3M1-01, flip acceptance criteria to `[x]` (or add a “NEO-57 landed” note) so the backlog matches [`NEO-57-implementation-plan.md`](../plans/NEO-57-implementation-plan.md).~~ **Done.**
2. ~~**Backlog schema name** — Update E3M1-01 bullet from `resource-yield-table.schema.json` to **`resource-yield-row.schema.json`** (kickoff chose row schema + `prototype_resource_yields.json`).~~ **Done.**
3. ~~**Backlog yield file wording** — Remove “or embedded yields — plan decides” from E3M1-01 in-scope list; two-file layout is landed.~~ **Done.**
## Nits
- Nit: `_prototype_slice2_resource_node_gate` enforces lens **set** equality, not a per-lens duplicate error message; duplicate `gatherLens` on two nodes still fails with a clear set-mismatch line — acceptable for prototype.
- Nit: CI does not freeze yield **quantities** (1/2/3/5) — only schema `quantity >= 1` and `scrap_metal_bulk` only; intentional per plan (differentiation is content, not gate).
- Nit: No `docs/manual-qa/NEO-57.md` — fine for content-only story; manual negative cases live in the implementation plan **Tests** table.
## Verification
```bash
pip install -r scripts/requirements-content.txt
python3 scripts/validate_content.py
```
Manual negatives (from plan): duplicate `nodeDefId`; remove a frozen id; invalid `gatherLens`; unknown yield `itemId`; duplicate yield `nodeDefId`; non-`scrap_metal_bulk` yield item — each should exit non-zero.
PR gate: `.github/workflows/pr-gate.yml` already invokes `validate_content.py` (no workflow change required).

View File

@ -7,6 +7,8 @@ Validates:
- mastery catalogs: content/mastery/*_mastery.json vs content/schemas/mastery-catalog.schema.json
(post-schema: tierIndex unique and sequential 1..N per track, aligned with server boot NEO-46)
- item catalogs: content/items/*_items.json rows vs content/schemas/item-def.schema.json (NEO-50)
- resource node catalogs: content/resource-nodes/*_resource_nodes.json vs resource-node-def.schema.json (NEO-57)
- resource yield catalogs: content/resource-nodes/*_resource_yields.json vs resource-yield-row.schema.json (NEO-57)
"""
from __future__ import annotations
@ -22,9 +24,12 @@ SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json"
LEVEL_CURVE_SCHEMA = REPO_ROOT / "content/schemas/level-curve.schema.json"
MASTERY_SCHEMA = REPO_ROOT / "content/schemas/mastery-catalog.schema.json"
ITEM_SCHEMA = REPO_ROOT / "content/schemas/item-def.schema.json"
RESOURCE_NODE_SCHEMA = REPO_ROOT / "content/schemas/resource-node-def.schema.json"
RESOURCE_YIELD_ROW_SCHEMA = REPO_ROOT / "content/schemas/resource-yield-row.schema.json"
SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery"
ITEMS_DIR = REPO_ROOT / "content/items"
RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes"
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
@ -47,6 +52,21 @@ PROTOTYPE_SLICE1_ITEM_ROLES = frozenset(
# Slice 4 prototype lock (NEO-45): exactly one salvage track across all mastery files.
PROTOTYPE_SLICE4_SALVAGE_SKILL_ID = "salvage"
# Slice 2 prototype lock (NEO-57): exact nodeDefIds + gatherLens coverage after schema passes.
# Keep in sync with server loader when E3M1-02 lands.
PROTOTYPE_SLICE2_NODE_IDS = frozenset(
{
"prototype_resource_node_alpha",
"prototype_subsurface_vein_beta",
"prototype_bio_mat_gamma",
"prototype_urban_bulk_delta",
}
)
PROTOTYPE_SLICE2_GATHER_LENSES = frozenset(
{"consumer_salvage", "subsurface", "bio", "urban_bulk"}
)
PROTOTYPE_SLICE2_YIELD_ITEM_IDS = frozenset({"scrap_metal_bulk"})
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
"""Return a human-readable error if Slice 1 contract fails, else None."""
@ -371,6 +391,166 @@ def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
return None
def _prototype_slice2_resource_node_gate(
seen_ids: dict[str, str],
id_to_lens: dict[str, str],
) -> str | None:
"""Return a human-readable error if Slice 2 resource-node contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_SLICE2_NODE_IDS:
return (
"error: prototype Slice 2 expects exactly nodeDefIds "
f"{sorted(PROTOTYPE_SLICE2_NODE_IDS)!r}, got {sorted(ids)!r}"
)
lenses = set(id_to_lens.values())
if lenses != PROTOTYPE_SLICE2_GATHER_LENSES:
return (
"error: prototype Slice 2 requires exactly one row per gatherLens "
f"{sorted(PROTOTYPE_SLICE2_GATHER_LENSES)!r}, lenses seen: {sorted(lenses)!r}"
)
return None
def _prototype_slice2_yield_gate(
yield_node_ids: frozenset[str],
yield_item_ids: frozenset[str],
) -> str | None:
"""Return a human-readable error if Slice 2 yield contract fails, else None."""
if yield_node_ids != PROTOTYPE_SLICE2_NODE_IDS:
return (
"error: prototype Slice 2 expects exactly one yield row per nodeDefId "
f"{sorted(PROTOTYPE_SLICE2_NODE_IDS)!r}, yield nodeDefIds: {sorted(yield_node_ids)!r}"
)
if yield_item_ids != PROTOTYPE_SLICE2_YIELD_ITEM_IDS:
return (
"error: prototype Slice 2 expects yield itemIds "
f"{sorted(PROTOTYPE_SLICE2_YIELD_ITEM_IDS)!r} only, got {sorted(yield_item_ids)!r}"
)
return None
def _validate_resource_node_catalogs(
*,
node_files: list[Path],
node_validator: Draft202012Validator,
) -> tuple[int, dict[str, str], dict[str, str]]:
"""Validate resource node JSON files. Returns (error_count, seen_ids, id_to_lens)."""
errors = 0
seen_ids: dict[str, str] = {}
id_to_lens: dict[str, str] = {}
for path in node_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
nodes = data.get("nodes")
if not isinstance(nodes, list):
print(f"error: {rel}: expected top-level 'nodes' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(nodes):
if not isinstance(row, dict):
print(f"error: {rel}: nodes[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(node_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} nodes[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
nid = row.get("nodeDefId")
if isinstance(nid, str) and row_schema_errors == 0:
prev = seen_ids.get(nid)
if prev:
print(
f"error: duplicate nodeDefId {nid!r} in {prev} and {rel}",
file=sys.stderr,
)
errors += 1
else:
seen_ids[nid] = rel
gather_lens = row.get("gatherLens")
if isinstance(gather_lens, str):
id_to_lens[nid] = gather_lens
return errors, seen_ids, id_to_lens
def _validate_resource_yield_catalogs(
*,
yield_files: list[Path],
yield_validator: Draft202012Validator,
known_node_ids: frozenset[str],
known_item_ids: frozenset[str],
) -> tuple[int, frozenset[str], frozenset[str]]:
"""Validate yield JSON files. Returns (error_count, yield_node_ids, yield_item_ids)."""
errors = 0
seen_node_ids: dict[str, str] = {}
yield_item_ids: set[str] = set()
for path in yield_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
yields = data.get("yields")
if not isinstance(yields, list):
print(f"error: {rel}: expected top-level 'yields' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(yields):
if not isinstance(row, dict):
print(f"error: {rel}: yields[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(yield_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} yields[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
nid = row.get("nodeDefId")
item_id = row.get("itemId")
if isinstance(nid, str) and row_schema_errors == 0:
prev = seen_node_ids.get(nid)
if prev:
print(
f"error: duplicate yield nodeDefId {nid!r} in {prev} and {rel}",
file=sys.stderr,
)
errors += 1
else:
seen_node_ids[nid] = rel
if nid not in known_node_ids:
print(
f"error: {rel} yields[{i}]: nodeDefId {nid!r} is not defined in resource node catalogs",
file=sys.stderr,
)
errors += 1
if isinstance(item_id, str) and row_schema_errors == 0:
yield_item_ids.add(item_id)
if item_id not in known_item_ids:
print(
f"error: {rel} yields[{i}]: itemId {item_id!r} is not in item catalogs",
file=sys.stderr,
)
errors += 1
return errors, frozenset(seen_node_ids.keys()), frozenset(yield_item_ids)
def main() -> int:
if not SKILL_SCHEMA.is_file():
print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr)
@ -384,6 +564,12 @@ def main() -> int:
if not ITEM_SCHEMA.is_file():
print(f"error: missing schema {ITEM_SCHEMA}", file=sys.stderr)
return 1
if not RESOURCE_NODE_SCHEMA.is_file():
print(f"error: missing schema {RESOURCE_NODE_SCHEMA}", file=sys.stderr)
return 1
if not RESOURCE_YIELD_ROW_SCHEMA.is_file():
print(f"error: missing schema {RESOURCE_YIELD_ROW_SCHEMA}", file=sys.stderr)
return 1
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema)
@ -393,6 +579,10 @@ def main() -> int:
mastery_validator = Draft202012Validator(mastery_schema)
item_schema = json.loads(ITEM_SCHEMA.read_text(encoding="utf-8"))
item_validator = Draft202012Validator(item_schema)
resource_node_schema = json.loads(RESOURCE_NODE_SCHEMA.read_text(encoding="utf-8"))
resource_node_validator = Draft202012Validator(resource_node_schema)
resource_yield_schema = json.loads(RESOURCE_YIELD_ROW_SCHEMA.read_text(encoding="utf-8"))
resource_yield_validator = Draft202012Validator(resource_yield_schema)
if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
@ -426,6 +616,20 @@ def main() -> int:
print(f"error: no *_items.json files under {ITEMS_DIR}", file=sys.stderr)
return 1
if not RESOURCE_NODES_DIR.is_dir():
print(f"error: missing directory {RESOURCE_NODES_DIR}", file=sys.stderr)
return 1
node_files = sorted(RESOURCE_NODES_DIR.glob("*_resource_nodes.json"))
if not node_files:
print(f"error: no *_resource_nodes.json files under {RESOURCE_NODES_DIR}", file=sys.stderr)
return 1
yield_files = sorted(RESOURCE_NODES_DIR.glob("*_resource_yields.json"))
if not yield_files:
print(f"error: no *_resource_yields.json files under {RESOURCE_NODES_DIR}", file=sys.stderr)
return 1
seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {}
errors = 0
@ -571,14 +775,45 @@ def main() -> int:
print(slice1_item_err, file=sys.stderr)
return 1
node_errors, node_seen_ids, id_to_lens = _validate_resource_node_catalogs(
node_files=node_files,
node_validator=resource_node_validator,
)
if node_errors:
print(f"content validation failed with {node_errors} error(s)", file=sys.stderr)
return 1
slice2_node_err = _prototype_slice2_resource_node_gate(node_seen_ids, id_to_lens)
if slice2_node_err:
print(slice2_node_err, file=sys.stderr)
return 1
yield_errors, yield_node_ids, yield_item_ids = _validate_resource_yield_catalogs(
yield_files=yield_files,
yield_validator=resource_yield_validator,
known_node_ids=frozenset(node_seen_ids.keys()),
known_item_ids=frozenset(item_seen_ids.keys()),
)
if yield_errors:
print(f"content validation failed with {yield_errors} error(s)", file=sys.stderr)
return 1
slice2_yield_err = _prototype_slice2_yield_gate(yield_node_ids, yield_item_ids)
if slice2_yield_err:
print(slice2_yield_err, file=sys.stderr)
return 1
print(
"content OK: "
f"{len(skill_files)} skill catalog file(s), "
f"{len(curve_files)} level curve file(s), "
f"{len(mastery_files)} mastery catalog file(s), "
f"{len(item_files)} item catalog file(s), "
f"{len(node_files)} resource node catalog file(s), "
f"{len(yield_files)} resource yield catalog file(s), "
f"{len(seen_ids)} unique skill id(s), "
f"{len(item_seen_ids)} unique item id(s), "
f"{len(node_seen_ids)} unique nodeDefId(s), "
f"{len(track_skill_ids)} mastery track(s)"
)
return 0