diff --git a/content/README.md b/content/README.md index d88f5d3..eafc903 100644 --- a/content/README.md +++ b/content/README.md @@ -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). diff --git a/content/items/prototype_items.json b/content/items/prototype_items.json new file mode 100644 index 0000000..7632f7c --- /dev/null +++ b/content/items/prototype_items.json @@ -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" + } + ] +} diff --git a/content/schemas/item-def.schema.json b/content/schemas/item-def.schema.json new file mode 100644 index 0000000..1c0e271 --- /dev/null +++ b/content/schemas/item-def.schema.json @@ -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)." + } + } +} diff --git a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md index 0a0dd9a..f3f91b2 100644 --- a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md +++ b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md @@ -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. diff --git a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md index bc7442e..cb68857 100644 --- a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md +++ b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md @@ -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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 05193da..0a3cd17 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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-37–NEO-41, NEO-42–NEO-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) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 215310d..10796c5 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -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 | diff --git a/docs/plans/E3M3-prototype-backlog.md b/docs/plans/E3M3-prototype-backlog.md index 8eb0b90..2146690 100644 --- a/docs/plans/E3M3-prototype-backlog.md +++ b/docs/plans/E3M3-prototype-backlog.md @@ -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). --- diff --git a/docs/plans/NEO-50-implementation-plan.md b/docs/plans/NEO-50-implementation-plan.md new file mode 100644 index 0000000..1c3ad1e --- /dev/null +++ b/docs/plans/NEO-50-implementation-plan.md @@ -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. | diff --git a/docs/reviews/2026-05-17-NEO-50.md b/docs/reviews/2026-05-17-NEO-50.md new file mode 100644 index 0000000..4c257f2 --- /dev/null +++ b/docs/reviews/2026-05-17-NEO-50.md @@ -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). diff --git a/scripts/validate_content.py b/scripts/validate_content.py index 97b9db4..60d3760 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -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