Merge pull request #85 from ViPro-Technologies/NEO-50-itemdef-starter-set-schemas-ci

NEO-50: Lock prototype ItemDef starter set + schemas + CI
pull/87/head
VinPropane 2026-05-17 21:45:53 -04:00 committed by GitHub
commit afa39c48af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 454 additions and 10 deletions

View File

@ -7,11 +7,11 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
| [`schemas/`](schemas/) | JSON Schema files (`*.schema.json`) |
| [`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 (planned); each row matches `schemas/item-def.schema.json`**stable `id`**, stack/slot metadata for [E3.M3](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.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) |
**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 1 — items (NEO-50, planned):** CI will expect **exactly six** item rows with ids **`scrap_metal_bulk`**, **`refined_plate_stock`**, **`field_stim_mk0`**, **`survey_drone_kit`**, **`contract_handoff_token`**, **`prototype_armor_shell`** — see [E3M3-prototype-backlog](../docs/plans/E3M3-prototype-backlog.md) and [E3.M3 freeze note](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#related-implementation-slices).
**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,47 @@
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "Scrap Metal (Bulk)",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "refined_plate_stock",
"displayName": "Refined Plate Stock",
"prototypeRole": "intermediate",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "field_stim_mk0",
"displayName": "Field Stim Mk0",
"prototypeRole": "consumable",
"stackMax": 20,
"inventorySlotKind": "bag"
},
{
"id": "survey_drone_kit",
"displayName": "Survey Drone Kit",
"prototypeRole": "utility",
"stackMax": 1,
"inventorySlotKind": "bag"
},
{
"id": "contract_handoff_token",
"displayName": "Contract Handoff Token",
"prototypeRole": "quest_token",
"stackMax": 1,
"inventorySlotKind": "bag"
},
{
"id": "prototype_armor_shell",
"displayName": "Prototype Armor Shell",
"prototypeRole": "equip_stub",
"stackMax": 1,
"inventorySlotKind": "equipment"
}
]
}

View File

@ -0,0 +1,58 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/item-def.json",
"title": "ItemDef",
"description": "Single item row for catalogs (e.g. content/items/*_items.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md.",
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName", "prototypeRole", "stackMax", "inventorySlotKind"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable item key for saves, recipes, quests, telemetry, and inventory grants. Never change after content ships; add a new id if the item splits."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"prototypeRole": {
"type": "string",
"description": "Prototype Slice 1 archetype (material through equip_stub). CI requires exactly one row per role.",
"enum": [
"material",
"intermediate",
"consumable",
"utility",
"quest_token",
"equip_stub"
]
},
"stackMax": {
"type": "integer",
"minimum": 1,
"description": "Maximum stack size per inventory slot for this item def."
},
"inventorySlotKind": {
"type": "string",
"description": "Where the item may be stored: general bag vs equipment slot (prototype equip stub).",
"enum": ["bag", "equipment"]
},
"rarity": {
"type": "string",
"description": "Optional rarity band (unused in prototype Slice 1 rows).",
"enum": ["common", "uncommon", "rare", "epic"]
},
"bindPolicy": {
"type": "string",
"description": "Optional bind rule stub (unused in prototype Slice 1 rows).",
"enum": ["none", "bind_on_acquire", "bind_on_equip"]
},
"durabilityMax": {
"type": "integer",
"minimum": 0,
"description": "Optional durability ceiling stub (unused in prototype Slice 1; no durability mutation in v1)."
}
}
}

View File

@ -43,7 +43,9 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
**Skill catalogs:** the repo **PR gate** workflow ([`.github/workflows/pr-gate.yml`](../../../.github/workflows/pr-gate.yml)) runs [`scripts/validate_content.py`](../../../scripts/validate_content.py) on every push and pull request. It validates each object in `content/skills/*.json` under the top-level `skills` array against [`content/schemas/skill-def.schema.json`](../../../content/schemas/skill-def.schema.json) (Python **jsonschema**, Draft 2020-12), rejects **duplicate `id`** across files, and (Slice 1 / [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33)) enforces the **frozen prototype trio** ids plus **gather + tech + (process or make)** category coverage.
**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. Extend the script when additional catalogs and schemas ship.
**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.
**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,7 +7,7 @@
| **Module ID** | E3.M3 |
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
| **Stage target** | Prototype |
| **Status** | Planned — Slice 1 backlog [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)[NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) ([E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md); see [dependency register](module_dependency_register.md)) |
| **Status** | In Progress — Slice 1 backlog [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)[NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) ([E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md); see [dependency register](module_dependency_register.md)) |
## Purpose
@ -33,11 +33,36 @@ Item definitions, rarity/quality, stackability, and equipment metadata plus `Inv
- **E3.M2**, **E3.M4**, **E5.M3**, **E7.M2**, **E8.M3**, and any system moving items.
## Designer note: stack, slot, and v1 scope
Each **`ItemDef`** row under `content/items/*.json` declares:
- **`stackMax`** — maximum units per inventory slot for that def (inventory engine in E3M3-05+ enforces this on add).
- **`inventorySlotKind`** — **`bag`** (general inventory) vs **`equipment`** (equip-stub slot only in prototype; full loadout rules are later).
- **`prototypeRole`** — Slice 1 archetype for CI coverage (`material` → `equip_stub`); not a player-facing bucket label (see [items.md](../../game-design/items.md) design lenses).
**Prototype v1:** optional schema fields **`rarity`**, **`bindPolicy`**, **`durabilityMax`** exist for forward compatibility but are **omitted** on all six shipped rows. **No durability mutation** or repair sinks in Slice 1—those belong to [E3.M4](E3_M4_SinkAndDurabilityLifecycle.md).
## Prototype Slice 1 freeze (NEO-50)
The **first shipped six-item spine** under `content/items/*.json` is **frozen** for downstream references (gather yields, recipes, inventory grants) until a deliberate migration or follow-up issue expands the roster.
| `ItemDef.id` | `prototypeRole` | `inventorySlotKind` | `stackMax` |
|--------------|-----------------|---------------------|------------|
| **`scrap_metal_bulk`** | `material` | `bag` | 999 |
| **`refined_plate_stock`** | `intermediate` | `bag` | 999 |
| **`field_stim_mk0`** | `consumable` | `bag` | 20 |
| **`survey_drone_kit`** | `utility` | `bag` | 1 |
| **`contract_handoff_token`** | `quest_token` | `bag` | 1 |
| **`prototype_armor_shell`** | `equip_stub` | `equipment` | 1 |
**Rules:** Do **not** rename these `id` values—change **`displayName`** only, or add a new `id` in a follow-up issue. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) enforces **exactly** these six ids, **one row per `prototypeRole`**, and duplicate-`id` rejection across all item JSON files. Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows.
## Related implementation slices
Epic 3 **Slice 1** — MVP inventory; `item_created`, transfer failures.
**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks). Prototype **frozen item ids:** `scrap_metal_bulk`, `refined_plate_stock`, `field_stim_mk0`, `survey_drone_kit`, `contract_handoff_token`, `prototype_armor_shell`.
**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks).
## Source anchors

View File

@ -54,6 +54,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| 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.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) enforce schema, duplicate `id`, exact six ids, and **`prototypeRole`** coverage; designer note in [E3_M3](E3_M3_ItemizationAndInventorySchema.md) + [`content/README.md`](../../../content/README.md). **Still planned:** server load (NEO-51+), inventory store, HTTP. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
---

View File

@ -44,7 +44,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|---|---|---|---|---|---|
| E3.M1 | ResourceNodeAndGatherLoop | E1.M3, E2.M2 | ResourceNodeDef, GatherResult, ResourceYieldTable | Prototype | In Progress |
| E3.M2 | RefinementAndRecipeExecution | E3.M1, E3.M3 | RecipeDef, CraftRequest, CraftResult | Prototype | Planned |
| E3.M3 | ItemizationAndInventorySchema | None | ItemDef, ItemInstance, InventorySlot | Prototype | Planned |
| E3.M3 | ItemizationAndInventorySchema | None | ItemDef, ItemInstance, InventorySlot | Prototype | In Progress |
| 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 |

View File

@ -48,7 +48,7 @@ Working backlog for **Epic 3 — Slice 1** ([items and inventory MVP](../decompo
- `content/schemas/item-def.schema.json` (or equivalent single-catalog schema).
- `content/items/prototype_items.json` with stable ids: `scrap_metal_bulk`, `refined_plate_stock`, `field_stim_mk0`, `survey_drone_kit`, `contract_handoff_token`, `prototype_armor_shell` (names/display may change; **ids frozen**).
- `scripts/validate_content.py`: schema validation, duplicate `id`, required bucket/tag fields, exact six-id allowlist for prototype.
- `scripts/validate_content.py`: schema validation, duplicate `id`, required `prototypeRole` coverage (one row per archetype), exact six-id allowlist for prototype.
- Designer note in [E3_M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) + `content/README.md`: stack rules, slot kinds (bag vs equip stub), v1 scope (no durability mutation).
**Out of scope**
@ -57,9 +57,11 @@ Working backlog for **Epic 3 — Slice 1** ([items and inventory MVP](../decompo
**Acceptance criteria**
- [ ] PR gate validates item JSON against schema.
- [ ] Exactly six prototype item ids; duplicate `id` fails CI.
- [ ] Stable id list documented in module doc freeze box.
- [x] PR gate validates item JSON against schema.
- [x] Exactly six prototype item ids; duplicate `id` fails CI.
- [x] Stable id list documented in module doc freeze box.
**Landed ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)):** [`content/items/prototype_items.json`](../../content/items/prototype_items.json), [`item-def.schema.json`](../../content/schemas/item-def.schema.json), CI gates in [`validate_content.py`](../../scripts/validate_content.py); plan [NEO-50-implementation-plan.md](NEO-50-implementation-plan.md).
---

View File

@ -0,0 +1,109 @@
# NEO-50 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-50 |
| **Title** | E3.M3: Lock prototype ItemDef starter set + schemas + CI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-50/e3m3-lock-prototype-itemdef-starter-set-schemas-ci |
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 · backlog **E3M3-01** |
| **Branch** | `NEO-50-itemdef-starter-set-schemas-ci` |
## Kickoff clarifications
| Topic | Question | Answer |
|--------|----------|--------|
| **Prototype role field** | `prototypeRole` vs `designBucket` vs `tags` only? | **Agent recommendation (accepted):** `prototypeRole` enum — one value per frozen archetype (`material` \| `intermediate` \| `consumable` \| `utility` \| `quest_token` \| `equip_stub`). CI requires **exactly one row per role** (same pattern as skill category coverage). User asked for suggestion; design buckets in [items.md](../game-design/items.md) overlap and are not 1:1 with the six-id spine. |
| **Stack + slot metadata** | Explicit fields vs deferred? | **Agent recommendation (accepted):** **Explicit per row:** `stackMax` (int ≥ 1) and `inventorySlotKind` (`bag` \| `equipment`). Locks stack/slot rules in content before NEO-51 server load and E3M3-05 inventory engine. |
| **Forward-compat stubs** | Optional `rarity` / `bindPolicy` / `durabilityMax` on schema? | **Yes** — optional fields on `ItemDef`; omit on all six prototype rows (documented unused in v1). User confirmed. |
| **Catalog envelope** | File shape? | **Mirror skills:** `{ "schemaVersion": 1, "items": [ … ] }` in `content/items/prototype_items.json`; each row validates against `content/schemas/item-def.schema.json`. |
| **Base branch** | Branch from `main` after PR #84? | **Yes**`main` includes [E3M3-prototype-backlog](E3M3-prototype-backlog.md) and README freeze pointers. |
### Frozen prototype ids (Slice 1 — item spine)
| `id` | `prototypeRole` | `inventorySlotKind` | `stackMax` (initial) |
|------|-----------------|---------------------|----------------------|
| `scrap_metal_bulk` | `material` | `bag` | `999` |
| `refined_plate_stock` | `intermediate` | `bag` | `999` |
| `field_stim_mk0` | `consumable` | `bag` | `20` |
| `survey_drone_kit` | `utility` | `bag` | `1` |
| `contract_handoff_token` | `quest_token` | `bag` | `1` |
| `prototype_armor_shell` | `equip_stub` | `equipment` | `1` |
**Rules:** Do **not** rename `id` values after ship — change `displayName` only, or add a new `id` in a follow-up issue. Relaxing the six-id or six-role CI gate requires a new Linear issue.
## Goal, scope, and out-of-scope
**Goal:** Lock the **six-item** prototype catalog under `content/items/` with JSON Schema and CI validation (`scripts/validate_content.py`) so ids, stack rules, and slot metadata are stable before server load (NEO-51+).
**In scope (from Linear / [E3M3-prototype-backlog](E3M3-prototype-backlog.md#e3m3-01--prototype-itemdef-starter-set--schemas--ci)):**
- `content/schemas/item-def.schema.json` — single-row `ItemDef` contract.
- `content/items/prototype_items.json` — six frozen ids (table above).
- `scripts/validate_content.py` — schema validation, duplicate `id`, `prototypeRole` coverage, exact six-id allowlist.
- Designer note in **E3.M3** module doc + `content/README.md`: stack rules, bag vs equipment slot, v1 scope (no durability mutation).
**Out of scope (from Linear):**
- Server runtime load, inventory store, HTTP, client HUD (NEO-51+).
## Acceptance criteria checklist
- [x] PR gate / `validate_content.py` fails on invalid rows, schema mismatch, or duplicate item `id`.
- [x] Exactly **six** prototype item ids; wrong id set fails CI.
- [x] Each `prototypeRole` appears **exactly once** across the catalog.
- [x] Six prototype ids documented as **frozen** in E3.M3 module doc + `content/README.md`.
## Technical approach
1. **Row schema (`item-def.schema.json`):** Draft 2020-12 object; `additionalProperties: false`. Required: `id`, `displayName`, `prototypeRole`, `stackMax`, `inventorySlotKind`. Enums: `prototypeRole` (six values above), `inventorySlotKind` (`bag` \| `equipment`). `id` pattern `^[a-z][a-z0-9_]*$` (same as skills). Optional stubs: `rarity` (`common` \| `uncommon` \| `rare` \| `epic`), `bindPolicy` (`none` \| `bind_on_acquire` \| `bind_on_equip`), `durabilityMax` (integer ≥ 0) — all optional, unused in prototype rows.
2. **Catalog file:** `content/items/prototype_items.json` with `schemaVersion: 1` and six rows matching the freeze table; placeholder `displayName` strings acceptable.
3. **`validate_content.py`:** After existing skill / level-curve / mastery validation (or in parallel before final success):
- Discover `content/items/*_items.json`.
- Validate each row against `item-def.schema.json`.
- Track `seen_ids` across files; reject duplicates.
- **`_prototype_slice1_item_gate`:** ids must equal `PROTOTYPE_SLICE1_ITEM_IDS`; roles must cover each `prototypeRole` exactly once; `equip_stub` row must use `inventorySlotKind: equipment` and `stackMax: 1` (post-schema sanity).
4. **Docs:** E3.M3 — **Prototype Slice 1 freeze** subsection + designer note (bag vs equipment, stackMax, no durability mutation in v1). `content/README.md` — promote items path from “planned” to active with freeze list. CT.M1 — add item catalog paragraph. `documentation_and_implementation_alignment.md` — E3.M3 row → **In Progress** / NEO-50.
5. **No server/C#/client changes** in this story.
## Files to add
| Path | Purpose |
|------|---------|
| `content/schemas/item-def.schema.json` | JSON Schema for a single `ItemDef` row. |
| `content/items/prototype_items.json` | Prototype six-item catalog (`schemaVersion` + `items` array). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `scripts/validate_content.py` | Load/validate item catalogs; duplicate `id`; Slice 1 six-id + six-role gates; update success summary line. |
| `content/README.md` | Document `content/items/`, schema path, Slice 1 freeze (active, not “planned”). |
| `docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md` | Freeze table, designer note (stack/slot/v1 scope), link to plan. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for item catalog validation. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 / NEO-50 status after catalog lands. |
## Tests
| Item | Coverage |
|------|----------|
| **CI / PR gate** | `.github/workflows/pr-gate.yml` already runs `python scripts/validate_content.py` — extended script is the regression signal. |
| **Manual negative cases** | From repo root after implementation: (1) duplicate `id` in catalog → non-zero exit; (2) remove one frozen id → gate error; (3) break schema (invalid `stackMax`) → schema error; (4) swap two `prototypeRole` values → role coverage error. |
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports item catalog + existing catalogs OK. |
No dedicated pytest module in-repo today (same as NEO-33 / NEO-45).
## Open questions / risks
- **Catalog growth:** Slice 1 gate requires **exactly** the six frozen ids; expanding the roster needs a follow-up issue to change `PROTOTYPE_SLICE1_ITEM_IDS` and role gate.
- **C# vs Python drift:** None until NEO-51; document “keep in sync” constant names in both places when server load lands.
- **None** otherwise at kickoff.
## Decisions
| Decision | Rationale |
|----------|-----------|
| `prototypeRole` enum + per-role CI | User requested agent recommendation; maps 1:1 to six-id vertical slice. |
| Explicit `stackMax` + `inventorySlotKind` | User requested agent recommendation; backlog requires stack/slot metadata before server load. |
| Optional `rarity` / `bindPolicy` / `durabilityMax` on schema | User chose **yes_stubs** for forward-compat without populating prototype rows. |

View File

@ -0,0 +1,58 @@
# Code review — NEO-50 prototype ItemDef catalog + CI
**Date:** 2026-05-17
**Scope:** Branch `NEO-50-itemdef-starter-set-schemas-ci` · commits `6616bfb``bbc6b20` · full branch vs `main`
**Base:** `main`
## Verdict
**Approve**
**Follow-up:** Review suggestions below are **done** (strikethrough + **Done.**).
## Summary
NEO-50 delivers a **content-only** Slice 1 spine: `item-def.schema.json`, six-row `prototype_items.json`, and extended `validate_content.py` with per-row schema validation, cross-file duplicate `id` detection, frozen six-id allowlist, one-row-per-`prototypeRole` coverage, and post-schema checks that `prototype_armor_shell` uses `equipment` + `stackMax: 1` when it carries `equip_stub`. Documentation (E3.M3 freeze table, `content/README.md`, CT.M1 CI paragraph, alignment register, implementation plan) matches kickoff decisions (`prototypeRole`, explicit `stackMax` / `inventorySlotKind`, optional forward-compat stubs). No server/C#/client surface — risk is low; PR gate already runs the script. One **should fix** tightens the equip-stub slot gate so a role/id swap cannot bypass equipment rules; backlog wording still mentions bucket/tags.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-50-implementation-plan.md`](../plans/NEO-50-implementation-plan.md) | **Matches** — acceptance checklist, freeze table, schema fields, and validation gates implemented as specified. |
| [`docs/plans/E3M3-prototype-backlog.md`](../plans/E3M3-prototype-backlog.md) | **Partially matches** — E3M3-01 scope and ids align; line 51 still says “required bucket/tag fields” (kickoff chose `prototypeRole` instead); acceptance checkboxes not ticked in backlog (plan checklist is ticked). |
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — designer note, freeze table, CI pointers, v1 scope (no durability mutation). |
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — item catalog paragraph added; module **Status** in summary table still **Planned** (CT.M1 row not promoted — optional follow-up). |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row → In Progress / NEO-50 content landed (appropriate on branch pre-merge). |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 → **In Progress** with NEO-50 note. |
| [`content/README.md`](../../content/README.md) | **Matches** — items path active with six-id freeze pointer. |
## Blocking issues
None.
## Suggestions
1. ~~**Equip-stub slot gate by role, not id** — `_prototype_slice1_item_gate` only enforces `inventorySlotKind: equipment` and `stackMax: 1` when `prototype_armor_shell` has `prototypeRole: equip_stub`. Swapping `prototypeRole` between frozen ids (e.g. `survey_drone_kit` → `equip_stub` with `bag`) would pass CI while violating the designer contract. Resolve the row where `id_to_role[*] == "equip_stub"` (there is exactly one) and assert slot/stack on that id.~~ **Done.** Gate now resolves the row with `prototypeRole == "equip_stub"` and asserts `equipment` + `stackMax: 1` on that id (`scripts/validate_content.py`).
2. ~~**Backlog doc sync** — Update [`E3M3-prototype-backlog.md`](../plans/E3M3-prototype-backlog.md) E3M3-01 bullet from “bucket/tag fields” to `prototypeRole` (+ optional tick acceptance criteria) so backlog matches the implementation plan and shipped validator.~~ **Done.** E3M3-01 in-scope text, ticked acceptance criteria, and NEO-50 landed note.
## Nits
- Nit: Add a `// Keep in sync with scripts/validate_content.py` comment placeholder in NEO-51 server loader constants when C# load lands (plan risk table already calls this out).
- Nit: `CT_M1` module summary **Status** remains **Planned** while skill/mastery/item CI is live — consider **In Progress** when promoting CT.M1 is intentional scope.
## Verification
```bash
# From repo root
pip install -r scripts/requirements-content.txt
python3 scripts/validate_content.py
python3 scripts/check_decomposition_language.py
# Manual negatives (expect non-zero exit + stderr message)
# 1. Duplicate id in prototype_items.json
# 2. Remove one frozen id
# 3. Invalid stackMax (0) on a row
# 4. Duplicate prototypeRole on two rows
```
Confirm `.github/workflows/pr-gate.yml` passes on the PR (content validation step unchanged except script behavior).

View File

@ -6,6 +6,7 @@ Validates:
- level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json
- 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)
"""
from __future__ import annotations
@ -20,12 +21,29 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
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"
SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery"
ITEMS_DIR = REPO_ROOT / "content/items"
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
# Slice 1 prototype lock (NEO-50): exact item ids + prototypeRole coverage after schema passes.
PROTOTYPE_SLICE1_ITEM_IDS = frozenset(
{
"scrap_metal_bulk",
"refined_plate_stock",
"field_stim_mk0",
"survey_drone_kit",
"contract_handoff_token",
"prototype_armor_shell",
}
)
PROTOTYPE_SLICE1_ITEM_ROLES = frozenset(
{"material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"}
)
# Slice 4 prototype lock (NEO-45): exactly one salvage track across all mastery files.
PROTOTYPE_SLICE4_SALVAGE_SKILL_ID = "salvage"
@ -245,6 +263,99 @@ def _validate_mastery_catalogs(
return errors, all_track_skill_ids
def _prototype_slice1_item_gate(
seen_ids: dict[str, str],
id_to_role: dict[str, str],
id_to_slot_kind: dict[str, str],
id_to_stack_max: dict[str, int],
) -> str | None:
"""Return a human-readable error if Slice 1 item contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_SLICE1_ITEM_IDS:
return (
"error: prototype Slice 1 expects exactly item ids "
f"{sorted(PROTOTYPE_SLICE1_ITEM_IDS)!r}, got {sorted(ids)!r}"
)
roles = set(id_to_role.values())
if roles != PROTOTYPE_SLICE1_ITEM_ROLES:
return (
"error: prototype Slice 1 requires exactly one row per prototypeRole "
f"{sorted(PROTOTYPE_SLICE1_ITEM_ROLES)!r}, roles seen: {sorted(roles)!r}"
)
equip_stub_id = next((iid for iid, role in id_to_role.items() if role == "equip_stub"), None)
if equip_stub_id is not None:
if id_to_slot_kind.get(equip_stub_id) != "equipment":
return (
f"error: {equip_stub_id!r} (equip_stub) must use inventorySlotKind 'equipment', "
f"got {id_to_slot_kind.get(equip_stub_id)!r}"
)
if id_to_stack_max.get(equip_stub_id) != 1:
return (
f"error: {equip_stub_id!r} (equip_stub) must use stackMax 1, "
f"got {id_to_stack_max.get(equip_stub_id)!r}"
)
return None
def _validate_item_catalogs(
*,
item_files: list[Path],
item_validator: Draft202012Validator,
) -> tuple[int, dict[str, str], dict[str, str], dict[str, str], dict[str, int]]:
"""Validate item JSON files. Returns (error_count, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max)."""
errors = 0
seen_ids: dict[str, str] = {}
id_to_role: dict[str, str] = {}
id_to_slot_kind: dict[str, str] = {}
id_to_stack_max: dict[str, int] = {}
for path in item_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
items = data.get("items")
if not isinstance(items, list):
print(f"error: {rel}: expected top-level 'items' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(items):
if not isinstance(row, dict):
print(f"error: {rel}: items[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(item_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} items[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
iid = row.get("id")
if isinstance(iid, str) and row_schema_errors == 0:
prev = seen_ids.get(iid)
if prev:
print(f"error: duplicate item id {iid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
seen_ids[iid] = rel
role = row.get("prototypeRole")
if isinstance(role, str):
id_to_role[iid] = role
slot_kind = row.get("inventorySlotKind")
if isinstance(slot_kind, str):
id_to_slot_kind[iid] = slot_kind
stack_max = row.get("stackMax")
if isinstance(stack_max, int):
id_to_stack_max[iid] = stack_max
return errors, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
"""Return a human-readable error if Slice 4 contract fails, else None."""
if len(track_skill_ids) != 1:
@ -270,6 +381,9 @@ def main() -> int:
if not MASTERY_SCHEMA.is_file():
print(f"error: missing schema {MASTERY_SCHEMA}", file=sys.stderr)
return 1
if not ITEM_SCHEMA.is_file():
print(f"error: missing schema {ITEM_SCHEMA}", file=sys.stderr)
return 1
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema)
@ -277,6 +391,8 @@ def main() -> int:
level_curve_validator = Draft202012Validator(level_curve_schema)
mastery_schema = json.loads(MASTERY_SCHEMA.read_text(encoding="utf-8"))
mastery_validator = Draft202012Validator(mastery_schema)
item_schema = json.loads(ITEM_SCHEMA.read_text(encoding="utf-8"))
item_validator = Draft202012Validator(item_schema)
if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
@ -301,6 +417,15 @@ def main() -> int:
print(f"error: no *_mastery.json files under {MASTERY_DIR}", file=sys.stderr)
return 1
if not ITEMS_DIR.is_dir():
print(f"error: missing directory {ITEMS_DIR}", file=sys.stderr)
return 1
item_files = sorted(ITEMS_DIR.glob("*_items.json"))
if not item_files:
print(f"error: no *_items.json files under {ITEMS_DIR}", file=sys.stderr)
return 1
seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {}
errors = 0
@ -431,12 +556,29 @@ def main() -> int:
print(slice4_err, file=sys.stderr)
return 1
item_errors, item_seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max = _validate_item_catalogs(
item_files=item_files,
item_validator=item_validator,
)
if item_errors:
print(f"content validation failed with {item_errors} error(s)", file=sys.stderr)
return 1
slice1_item_err = _prototype_slice1_item_gate(
item_seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max
)
if slice1_item_err:
print(slice1_item_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(seen_ids)} unique skill id(s), "
f"{len(item_seen_ids)} unique item id(s), "
f"{len(track_skill_ids)} mastery track(s)"
)
return 0