Merge pull request #85 from ViPro-Technologies/NEO-50-itemdef-starter-set-schemas-ci
NEO-50: Lock prototype ItemDef starter set + schemas + CIpull/87/head
commit
afa39c48af
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue