Merge pull request #139 from ViPro-Technologies/NEO-100-e5m3-encounter-reward-catalog-schemas-ci
NEO-100: E5M3-01 encounter + reward-table catalogs and CIpull/140/head
commit
7c0e2e8cd6
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"encounters": [
|
||||
{
|
||||
"id": "prototype_combat_pocket",
|
||||
"displayName": "Prototype Combat Pocket",
|
||||
"completionCriteria": { "kind": "defeat_all_targets" },
|
||||
"requiredNpcInstanceIds": [
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
"prototype_npc_elite"
|
||||
],
|
||||
"rewardTableId": "prototype_combat_pocket_clear"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"rewardTables": [
|
||||
{
|
||||
"id": "prototype_combat_pocket_clear",
|
||||
"displayName": "Prototype Combat Pocket Clear",
|
||||
"fixedGrants": [
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 10 },
|
||||
{ "itemId": "contract_handoff_token", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/encounter-def.json",
|
||||
"title": "EncounterDef",
|
||||
"description": "Single encounter template row for catalogs (e.g. content/encounters/*_encounters.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"displayName",
|
||||
"completionCriteria",
|
||||
"requiredNpcInstanceIds",
|
||||
"rewardTableId"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Immutable encounter key for progress tracking and completion events."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating character data."
|
||||
},
|
||||
"completionCriteria": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Prototype Slice 3: defeat all listed NPC instances.",
|
||||
"enum": ["defeat_all_targets"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requiredNpcInstanceIds": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"description": "NPC instance ids from server PrototypeNpcRegistry (CI cross-ref set gate)."
|
||||
},
|
||||
"rewardTableId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "RewardTable id granted once on encounter complete."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/reward-grant-row.json",
|
||||
"title": "RewardGrantRow",
|
||||
"description": "Single fixed-grant row on a RewardTable (content/reward-tables/*_reward_tables.json).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["itemId", "quantity"],
|
||||
"properties": {
|
||||
"itemId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Item def id from content/items (prototype Slice 1: frozen six-item catalog)."
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Units granted on encounter complete."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/reward-table.json",
|
||||
"title": "RewardTable",
|
||||
"description": "Single reward table row for catalogs (e.g. content/reward-tables/*_reward_tables.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "displayName", "fixedGrants"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Immutable reward table key for encounter binding and loot routing."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating character data."
|
||||
},
|
||||
"fixedGrants": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/reward-grant-row.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,10 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
|
|||
|
||||
**NPC behavior catalogs ([NEO-87](https://linear.app/neon-sprawl/issue/NEO-87)):** the same script validates each row in `content/npc-behaviors/*_npc_behaviors.json` against [`content/schemas/npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json), rejects **duplicate `id`** across files, requires **`leashRadius` > `aggroRadius`** per row, and (E5 Slice 2) enforces the **frozen three-behavior** id set (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`).
|
||||
|
||||
**Reward table catalogs ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** the same script validates each row in `content/reward-tables/*_reward_tables.json` against [`content/schemas/reward-table.schema.json`](../../../content/schemas/reward-table.schema.json) (grant rows via [`reward-grant-row.schema.json`](../../../content/schemas/reward-grant-row.schema.json)), rejects **duplicate `id`** across files and **duplicate `itemId`** within a table's **`fixedGrants`**, cross-checks every **`fixedGrants[].itemId`** against item catalogs, and (E5 Slice 3) enforces the **frozen one-table** id set (`prototype_combat_pocket_clear`) with fixed grant quantities.
|
||||
|
||||
**Encounter catalogs ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** the same script validates each row in `content/encounters/*_encounters.json` against [`content/schemas/encounter-def.schema.json`](../../../content/schemas/encounter-def.schema.json), rejects **duplicate `id`** across files, cross-checks **`rewardTableId`** against reward table catalogs, and (E5 Slice 3) enforces the **frozen one-encounter** id set (`prototype_combat_pocket`) with **`requiredNpcInstanceIds`** matching the three E5.M2 NPC instance ids.
|
||||
|
||||
**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.
|
||||
|
||||
## Source anchors
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin
|
|||
|------------------|---------------|
|
||||
| **`prototype_combat_pocket_clear`** | **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** |
|
||||
|
||||
**CI enforcement (NEO-100):** `scripts/validate_content.py` requires **exactly** encounter id **`prototype_combat_pocket`** and reward table id **`prototype_combat_pocket_clear`**; **`requiredNpcInstanceIds`** set must match server **`PrototypeNpcRegistry`** (`prototype_npc_melee`, `prototype_npc_ranged`, `prototype_npc_elite`); **`fixedGrants`** item ids from frozen item catalog with **no duplicate `itemId` per table**, quantities **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**; encounter **`rewardTableId`** cross-ref. Keep **`PROTOTYPE_E5M3_*`** and **`PROTOTYPE_E5M2_NPC_INSTANCE_IDS`** in sync with this table and [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server loader. Plan: [NEO-100 implementation plan](../../plans/NEO-100-implementation-plan.md).
|
||||
|
||||
**Payout policy:** Per-defeat **gig XP** stays on [NEO-44](../../plans/NEO-44-implementation-plan.md). Encounter complete grants **loot + quest token** **once** per player per encounter id. Full **E7.M2** quest credit routing consumes **`EncounterCompleteEvent`** (E5M3-09 hook stub).
|
||||
|
||||
## Source anchors
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
|
||||
**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92–NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)–[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
|
||||
|
||||
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **Backlog decomposed** — implementation not started.
|
||||
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **Server load/runtime (NEO-101+) not started.**
|
||||
|
||||
### Epic 6 — PvP Security
|
||||
|
||||
|
|
|
|||
|
|
@ -92,9 +92,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] PR gate validates encounter + reward-table JSON against schema.
|
||||
- [ ] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI.
|
||||
- [ ] Stable id list documented in module doc freeze box.
|
||||
- [x] PR gate validates encounter + reward-table JSON against schema.
|
||||
- [x] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
**Landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates in `validate_content.py`; plan [NEO-100-implementation-plan.md](NEO-100-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
# NEO-100 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-100 |
|
||||
| **Title** | E5M3-01: Prototype EncounterDef + RewardTable catalog + schemas + CI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-100/e5m3-01-prototype-encounterdef-rewardtable-catalog-schemas-ci |
|
||||
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-01** |
|
||||
| **Branch** | `NEO-100-e5m3-encounter-reward-catalog-schemas-ci` |
|
||||
| **Precursor** | [E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) **Ready** (three NPC instance ids) · [E3.M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) **Ready** (frozen item catalog incl. **`contract_handoff_token`**) |
|
||||
| **Pattern** | [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) — row JSON Schema + catalog envelope + `scripts/validate_content.py` gate |
|
||||
| **Blocks** | [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) — server encounter + reward catalog load |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No clarifications needed.** Scope, frozen ids, loot grants, completion rule, and cross-ref targets are fully specified in [E5M3 kickoff decisions](E5M3-prototype-backlog.md#kickoff-decisions-decomposition-defaults), [E5.M3 freeze table](../decomposition/modules/E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01), and `content/README.md` (NEO-100 paragraph already present from E5M3 decomposition). Catalog envelope and CI constant naming follow NEO-87 / NEO-76 precedent without ambiguity.
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| — | — | — | **No blocking decisions** (see above). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **one** frozen `EncounterDef` + **one** frozen `RewardTable` before server load (NEO-101+).
|
||||
|
||||
**In scope (from Linear + [E5M3-01](E5M3-prototype-backlog.md#e5m3-01--prototype-encounterdef--rewardtable-catalog--schemas--ci)):**
|
||||
|
||||
- `content/schemas/encounter-def.schema.json`, `content/schemas/reward-table.schema.json`, `content/schemas/reward-grant-row.schema.json`.
|
||||
- `content/encounters/prototype_encounters.json` — one row **`prototype_combat_pocket`**.
|
||||
- `content/reward-tables/prototype_reward_tables.json` — one row **`prototype_combat_pocket_clear`**.
|
||||
- `scripts/validate_content.py` — schema validation, duplicate `id`, one-encounter / one-table allowlists, cross-ref **`itemId`** → frozen item catalog, **`requiredNpcInstanceIds`** → three E5.M2 NPC instance ids, **`rewardTableId`** → reward table ids.
|
||||
- Designer note / CI rules line in [E5_M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) + **CT.M1** PR gate paragraph.
|
||||
- `documentation_and_implementation_alignment.md` E5.M3 row → note NEO-100 catalog in progress / landed.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Server loader, runtime engine, HTTP, Godot (NEO-101+).
|
||||
- **`IEncounterProgressStore`**, completion events, inventory grant (E5M3-04+).
|
||||
- **Client counterpart:** none — server-only content + CI; player-visible work starts at E5M3-08+ / [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) / [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] PR gate validates encounter + reward-table JSON against schema.
|
||||
- [x] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Schemas:** [`encounter-def.schema.json`](../../content/schemas/encounter-def.schema.json), [`reward-table.schema.json`](../../content/schemas/reward-table.schema.json), [`reward-grant-row.schema.json`](../../content/schemas/reward-grant-row.schema.json).
|
||||
- **Catalogs:** [`prototype_encounters.json`](../../content/encounters/prototype_encounters.json), [`prototype_reward_tables.json`](../../content/reward-tables/prototype_reward_tables.json).
|
||||
- **CI:** `PROTOTYPE_E5M3_ENCOUNTER_IDS`, `PROTOTYPE_E5M3_REWARD_TABLE_IDS`, `PROTOTYPE_E5M2_NPC_INSTANCE_IDS`, `PROTOTYPE_E5M3_REWARD_GRANTS` gates in [`validate_content.py`](../../scripts/validate_content.py); duplicate **`fixedGrants[].itemId`** rejected per reward table; **`requiredNpcInstanceIds`** schema **`uniqueItems`**.
|
||||
- **Docs:** E5.M3 CI enforcement line, CT.M1, alignment register, module dependency register, E5M3-01 backlog checkboxes.
|
||||
- **`content/README.md`:** verified against shipped ids and cross-ref rules — no edit required (paragraph pre-landed in E5M3 decomposition).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`reward-grant-row.schema.json`:** Mirror [`recipe-io-row.schema.json`](../../content/schemas/recipe-io-row.schema.json) — required **`itemId`** (pattern `^[a-z][a-z0-9_]*$`), **`quantity`** integer ≥ 1.
|
||||
|
||||
2. **`reward-table.schema.json`:** Draft 2020-12 object; `additionalProperties: false`. Required: **`id`**, **`displayName`**, **`fixedGrants`** (non-empty array of `$ref` reward-grant-row). `id` pattern same as other catalogs.
|
||||
|
||||
3. **`encounter-def.schema.json`:** Required: **`id`**, **`displayName`**, **`completionCriteria`**, **`requiredNpcInstanceIds`**, **`rewardTableId`**. **`completionCriteria`:** object with required **`kind`** enum **`defeat_all_targets`** only (prototype). **`requiredNpcInstanceIds`:** array of 3 strings (pattern), **`minItems`/`maxItems`: 3**. **`rewardTableId`:** string pattern.
|
||||
|
||||
4. **Catalog files:**
|
||||
|
||||
**`content/reward-tables/prototype_reward_tables.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rewardTables": [
|
||||
{
|
||||
"id": "prototype_combat_pocket_clear",
|
||||
"displayName": "Prototype Combat Pocket Clear",
|
||||
"fixedGrants": [
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 10 },
|
||||
{ "itemId": "contract_handoff_token", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`content/encounters/prototype_encounters.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"encounters": [
|
||||
{
|
||||
"id": "prototype_combat_pocket",
|
||||
"displayName": "Prototype Combat Pocket",
|
||||
"completionCriteria": { "kind": "defeat_all_targets" },
|
||||
"requiredNpcInstanceIds": [
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
"prototype_npc_elite"
|
||||
],
|
||||
"rewardTableId": "prototype_combat_pocket_clear"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**`requiredNpcInstanceIds`** order matches [E5.M3 freeze table](../decomposition/modules/E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01) (melee → ranged → elite); CI enforces **set equality** with `PROTOTYPE_E5M2_NPC_INSTANCE_IDS`, not order-sensitive beyond schema min/max 3.
|
||||
|
||||
5. **`validate_content.py` constants (keep in sync with E5.M3 module doc + future NEO-101 loader):**
|
||||
|
||||
| Constant | Value |
|
||||
|----------|--------|
|
||||
| `PROTOTYPE_E5M3_ENCOUNTER_IDS` | `{ "prototype_combat_pocket" }` |
|
||||
| `PROTOTYPE_E5M3_REWARD_TABLE_IDS` | `{ "prototype_combat_pocket_clear" }` |
|
||||
| `PROTOTYPE_E5M2_NPC_INSTANCE_IDS` | `{ "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" }` |
|
||||
|
||||
6. **`validate_content.py` validators:**
|
||||
|
||||
- Add schema paths + dirs: `ENCOUNTER_DEF_SCHEMA`, `REWARD_TABLE_SCHEMA`, `REWARD_GRANT_ROW_SCHEMA`, `ENCOUNTERS_DIR`, `REWARD_TABLES_DIR`.
|
||||
- **`_validate_reward_table_catalogs`:** envelope `schemaVersion: 1`, top-level **`rewardTables`** array; validate each row; track `seen_ids`; reject duplicates across files.
|
||||
- **`_prototype_e5m3_reward_table_gate`:** ids must equal `PROTOTYPE_E5M3_REWARD_TABLE_IDS`.
|
||||
- **`_prototype_e5m3_reward_grant_item_gate`:** each `fixedGrants[].itemId` ∈ `PROTOTYPE_SLICE1_ITEM_IDS`; quantities ≥ 1 (schema) — spot-check **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** in gate or integration-style assert in docstring test notes.
|
||||
- **`_validate_encounter_catalogs`:** envelope `schemaVersion: 1`, top-level **`encounters`** array; validate rows; duplicate `id` rejection.
|
||||
- **`_prototype_e5m3_encounter_gate`:** ids must equal `PROTOTYPE_E5M3_ENCOUNTER_IDS`.
|
||||
- **`_prototype_e5m3_encounter_cross_ref_gate`:** each row's **`requiredNpcInstanceIds`** set must equal `PROTOTYPE_E5M2_NPC_INSTANCE_IDS`; **`rewardTableId`** must exist in reward-table `seen_ids`.
|
||||
- Run **reward tables before encounters** in `main()` (encounters reference reward table ids).
|
||||
- Extend module docstring, startup schema file checks, success summary with encounter + reward-table file/id counts.
|
||||
|
||||
7. **Docs:**
|
||||
|
||||
- `E5_M3_EncounterAndRewardTables.md` — add CI enforcement line under freeze table (ids + cross-ref rules); link to this plan when complete.
|
||||
- `CT_M1_ContentValidationPipeline.md` — encounter + reward-table PR gate paragraph.
|
||||
- `documentation_and_implementation_alignment.md` — E5.M3 row notes NEO-100 catalog when complete.
|
||||
- `content/README.md` — table rows for `encounters/` and `reward-tables/` already present; verify paragraph matches shipped constants after implementation.
|
||||
- `E5M3-prototype-backlog.md` — E5M3-01 checkboxes when implementation completes.
|
||||
|
||||
8. **No server/C#/client changes** in this story.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/reward-grant-row.schema.json` | JSON Schema for a single fixed-grant row on a `RewardTable`. |
|
||||
| `content/schemas/reward-table.schema.json` | JSON Schema for a single `RewardTable` row (uses reward-grant-row). |
|
||||
| `content/schemas/encounter-def.schema.json` | JSON Schema for a single `EncounterDef` row. |
|
||||
| `content/reward-tables/prototype_reward_tables.json` | Prototype one-table catalog (`schemaVersion` + `rewardTables` array). |
|
||||
| `content/encounters/prototype_encounters.json` | Prototype one-encounter catalog (`schemaVersion` + `encounters` array). |
|
||||
| `docs/plans/NEO-100-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `scripts/validate_content.py` | Load/validate encounter + reward-table catalogs; duplicate `id`; E5M3 one-id gates; NPC instance + item + rewardTableId cross-refs; success summary. |
|
||||
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | CI enforcement note under freeze table; link to plan when complete. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for encounter + reward-table catalog validation. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 / NEO-100 status after catalog lands. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Item | Coverage |
|
||||
|------|----------|
|
||||
| **CI / PR gate** | `.github/workflows/pr-gate.yml` runs `python scripts/validate_content.py` — extended script is the primary regression signal. |
|
||||
| **Manual negative cases** | From repo root after implementation: (1) duplicate encounter or reward-table `id` → non-zero exit; (2) remove frozen id → gate error; (3) unknown `itemId` in `fixedGrants` → cross-ref error; (4) wrong NPC instance id set → cross-ref error; (5) broken `rewardTableId` on encounter → cross-ref error; (6) empty `displayName` or invalid `completionCriteria.kind` → schema error. |
|
||||
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports encounter + reward-table catalogs + existing catalogs OK. |
|
||||
|
||||
No dedicated pytest module in-repo today (same as NEO-87 / NEO-76).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| NPC instance ids are server-registry constants, not content rows | **`PROTOTYPE_E5M2_NPC_INSTANCE_IDS` frozenset in CI** — mirror server `PrototypeNpcRegistry`; document sync obligation in module doc (same pattern as behavior ids without a content file). | **adopted** |
|
||||
| `content/README.md` pre-documents NEO-100 before catalogs land | **Keep paragraph; verify constants match after ship** — decomposition already merged. | **adopted** |
|
||||
| Cursor markdown `#anchor` deep links | Unrelated to this story; GitHub + stable ids remain source of truth. | **deferred** |
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# Code review — NEO-100 (E5M3-01)
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Scope:** Branch `NEO-100-e5m3-encounter-reward-catalog-schemas-ci` vs `cb86645` (merge-base on `main`) — commits `4f16c84` … `ee5e1a0`
|
||||
**Base:** `cb86645` (main after PR #138)
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve** — review follow-up applied (2026-05-30).
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-100 delivers the **E5M3-01** content spine: three JSON Schemas (`encounter-def`, `reward-table`, `reward-grant-row`), prototype catalog files with the frozen **`prototype_combat_pocket`** encounter and **`prototype_combat_pocket_clear`** reward table, and **`scripts/validate_content.py`** extensions mirroring the NEO-87 / NEO-76 catalog pattern (schema validation, duplicate `id` rejection, prototype id allowlists, item / NPC instance / reward-table cross-refs, reward-table-before-encounter ordering). Documentation updates match the shipped artifacts. Review follow-ups (dependency register sync, duplicate `fixedGrants` rejection, `uniqueItems` on NPC ids, plan audit note) are **Done.**
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-100-implementation-plan.md` | **Matches** — scope, constants, file list, acceptance checklist, and implementation reconciliation align with the diff. |
|
||||
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-01) | **Matches** — three AC items checked; landed note cites schemas, catalogs, and CI gates. |
|
||||
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — CI enforcement paragraph under freeze table; ids and cross-ref rules match `validate_content.py` constants. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — reward-table and encounter catalog paragraphs added with correct schema paths and gate semantics. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-100 catalog landed**; server load still outstanding. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-100 catalog landed**; server load still outstanding. |
|
||||
| `content/README.md` | **Matches** — NEO-100 paragraph and table rows for `encounters/` / `reward-tables/` match shipped ids and cross-ref rules (plan “verify only”). |
|
||||
| Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until E5M3-08+ / NEO-110 / NEO-111; no player-visible claim in this story. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/PrototypeNpcRegistry.cs` | **Matches** (cross-ref) — CI `PROTOTYPE_E5M2_NPC_INSTANCE_IDS` matches server constants (`prototype_npc_melee`, `prototype_npc_ranged`, `prototype_npc_elite`). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Update `module_dependency_register.md` E5.M3 note** — Replace “implementation not started” with **E5M3-01 / NEO-100 catalog landed** (schemas + CI); server load/runtime still NEO-101+. Keeps register consistent with `documentation_and_implementation_alignment.md` and E5.M3 module doc.~~ **Done.**
|
||||
|
||||
2. ~~**Duplicate `fixedGrants` rows** — `_prototype_e5m3_reward_grant_content_gate` folds grants into a dict (last row wins). Duplicate `itemId` rows that merge to the frozen quantities **pass CI** (verified locally). For NEO-101 loader clarity, consider rejecting duplicate `itemId` in `fixedGrants` (schema `uniqueItems` on a composite key is awkward; a small loop in the grant gate matches mastery `perkId` duplicate rejection). Same pattern gap exists on recipe I/O rows — optional to defer until roster expansion.~~ **Done.** `_validate_reward_table_catalogs` rejects duplicate **`itemId`** per table; E5.M3 + CT.M1 docs updated. Recipe I/O duplicate deferral unchanged.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`requiredNpcInstanceIds`** schema omits `uniqueItems: true`; the set-equality gate catches wrong sets for the one-row prototype, but adding `uniqueItems` would fail fast on duplicate ids before the custom gate (and documents intent for NEO-101).~~ **Done.**
|
||||
|
||||
- ~~Nit: Plan item 7 lists **`content/README.md` verify** — reconciliation section could explicitly note “verified, no edit required” for audit trail (optional).~~ **Done.**
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Primary regression (also runs in PR gate)
|
||||
python3 scripts/validate_content.py
|
||||
|
||||
# Manual negative spot-checks (from plan)
|
||||
# 1. Duplicate encounter or reward-table id → non-zero exit
|
||||
# 2. Remove frozen id from catalog → gate error
|
||||
# 3. Unknown itemId in fixedGrants → cross-ref error
|
||||
# 4. Wrong requiredNpcInstanceIds set → cross-ref error
|
||||
# 5. Broken rewardTableId → cross-ref error
|
||||
# 6. Invalid completionCriteria.kind or empty displayName → schema error
|
||||
```
|
||||
|
||||
**Before merge:** Confirm PR gate green on the branch (`validate_content.py` is the sole automated test surface for this story).
|
||||
|
|
@ -12,6 +12,8 @@ Validates:
|
|||
- recipe catalogs: content/recipes/*_recipes.json rows vs content/schemas/recipe-def.schema.json (NEO-65)
|
||||
- ability catalogs: content/abilities/*_abilities.json rows vs content/schemas/ability-def.schema.json (NEO-76)
|
||||
- npc behavior catalogs: content/npc-behaviors/*_npc_behaviors.json rows vs content/schemas/npc-behavior-def.schema.json (NEO-87)
|
||||
- reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100)
|
||||
- encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -34,6 +36,9 @@ RECIPE_IO_ROW_SCHEMA = REPO_ROOT / "content/schemas/recipe-io-row.schema.json"
|
|||
RECIPE_SCHEMA = REPO_ROOT / "content/schemas/recipe-def.schema.json"
|
||||
ABILITY_SCHEMA = REPO_ROOT / "content/schemas/ability-def.schema.json"
|
||||
NPC_BEHAVIOR_SCHEMA = REPO_ROOT / "content/schemas/npc-behavior-def.schema.json"
|
||||
REWARD_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reward-grant-row.schema.json"
|
||||
REWARD_TABLE_SCHEMA = REPO_ROOT / "content/schemas/reward-table.schema.json"
|
||||
ENCOUNTER_DEF_SCHEMA = REPO_ROOT / "content/schemas/encounter-def.schema.json"
|
||||
SKILLS_DIR = REPO_ROOT / "content/skills"
|
||||
MASTERY_DIR = REPO_ROOT / "content/mastery"
|
||||
ITEMS_DIR = REPO_ROOT / "content/items"
|
||||
|
|
@ -41,6 +46,8 @@ RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes"
|
|||
RECIPES_DIR = REPO_ROOT / "content/recipes"
|
||||
ABILITIES_DIR = REPO_ROOT / "content/abilities"
|
||||
NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"
|
||||
REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
|
||||
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
|
||||
|
||||
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
|
||||
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
||||
|
|
@ -126,6 +133,25 @@ PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
# Epic 5 Slice 2 NPC instance ids (server PrototypeNpcRegistry); CI cross-ref only (NEO-100).
|
||||
PROTOTYPE_E5M2_NPC_INSTANCE_IDS = frozenset(
|
||||
{
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
"prototype_npc_elite",
|
||||
}
|
||||
)
|
||||
|
||||
# Epic 5 Slice 3 prototype lock (NEO-100): exact encounter + reward table ids after schema passes.
|
||||
PROTOTYPE_E5M3_ENCOUNTER_IDS = frozenset({"prototype_combat_pocket"})
|
||||
PROTOTYPE_E5M3_REWARD_TABLE_IDS = frozenset({"prototype_combat_pocket_clear"})
|
||||
|
||||
# Frozen fixed grants for prototype_combat_pocket_clear (keep in sync with E5.M3 freeze table).
|
||||
PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
|
||||
"scrap_metal_bulk": 10,
|
||||
"contract_handoff_token": 1,
|
||||
}
|
||||
|
||||
|
||||
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 1 contract fails, else None."""
|
||||
|
|
@ -592,6 +618,211 @@ def _prototype_e5m2_npc_behavior_attack_ability_gate(
|
|||
return None
|
||||
|
||||
|
||||
def _reward_table_def_validator() -> Draft202012Validator:
|
||||
"""Build a validator that resolves reward-table $ref to reward-grant-row schema."""
|
||||
grant_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
|
||||
table_schema = json.loads(REWARD_TABLE_SCHEMA.read_text(encoding="utf-8"))
|
||||
registry = Registry().with_resources(
|
||||
[
|
||||
(table_schema["$id"], Resource.from_contents(table_schema)),
|
||||
(grant_schema["$id"], Resource.from_contents(grant_schema)),
|
||||
]
|
||||
)
|
||||
return Draft202012Validator(table_schema, registry=registry)
|
||||
|
||||
|
||||
def _validate_reward_table_catalogs(
|
||||
*,
|
||||
reward_table_files: list[Path],
|
||||
reward_table_validator: Draft202012Validator,
|
||||
known_item_ids: frozenset[str],
|
||||
) -> tuple[int, dict[str, str], dict[str, dict]]:
|
||||
"""Validate reward table JSON files. Returns (error_count, seen_ids, id_to_row)."""
|
||||
errors = 0
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_row: dict[str, dict] = {}
|
||||
|
||||
for path in reward_table_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
|
||||
|
||||
reward_tables = data.get("rewardTables")
|
||||
if not isinstance(reward_tables, list):
|
||||
print(f"error: {rel}: expected top-level 'rewardTables' array", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
for i, row in enumerate(reward_tables):
|
||||
if not isinstance(row, dict):
|
||||
print(f"error: {rel}: rewardTables[{i}] must be an object", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
row_schema_errors = 0
|
||||
for err in sorted(reward_table_validator.iter_errors(row), key=lambda e: e.path):
|
||||
loc = ".".join(str(p) for p in err.path) or "(root)"
|
||||
print(f"error: {rel} rewardTables[{i}] {loc}: {err.message}", file=sys.stderr)
|
||||
row_schema_errors += 1
|
||||
errors += 1
|
||||
rid = row.get("id")
|
||||
if isinstance(rid, str) and row_schema_errors == 0:
|
||||
prev = seen_ids.get(rid)
|
||||
if prev:
|
||||
print(f"error: duplicate reward table id {rid!r} in {prev} and {rel}", file=sys.stderr)
|
||||
errors += 1
|
||||
else:
|
||||
seen_ids[rid] = rel
|
||||
id_to_row[rid] = row
|
||||
fixed_grants = row.get("fixedGrants")
|
||||
if isinstance(fixed_grants, list) and row_schema_errors == 0:
|
||||
seen_grant_item_ids: set[str] = set()
|
||||
for j, grant in enumerate(fixed_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
item_id = grant.get("itemId")
|
||||
if isinstance(item_id, str):
|
||||
if item_id in seen_grant_item_ids:
|
||||
print(
|
||||
f"error: {rel} rewardTables[{i}].fixedGrants[{j}]: "
|
||||
f"duplicate itemId {item_id!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
errors += 1
|
||||
else:
|
||||
seen_grant_item_ids.add(item_id)
|
||||
if item_id not in known_item_ids:
|
||||
print(
|
||||
f"error: {rel} rewardTables[{i}].fixedGrants[{j}]: itemId {item_id!r} "
|
||||
"is not in item catalogs",
|
||||
file=sys.stderr,
|
||||
)
|
||||
errors += 1
|
||||
|
||||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _prototype_e5m3_reward_table_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E5M3 reward table contract fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
if ids != PROTOTYPE_E5M3_REWARD_TABLE_IDS:
|
||||
return (
|
||||
"error: prototype E5M3 expects exactly reward table ids "
|
||||
f"{sorted(PROTOTYPE_E5M3_REWARD_TABLE_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e5m3_reward_grant_content_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if frozen grant quantities fail, else None."""
|
||||
row = id_to_row.get("prototype_combat_pocket_clear")
|
||||
if row is None:
|
||||
return None
|
||||
fixed_grants = row.get("fixedGrants")
|
||||
if not isinstance(fixed_grants, list):
|
||||
return None
|
||||
grants: dict[str, int] = {}
|
||||
for grant in fixed_grants:
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
item_id = grant.get("itemId")
|
||||
quantity = grant.get("quantity")
|
||||
if isinstance(item_id, str) and isinstance(quantity, int):
|
||||
grants[item_id] = quantity
|
||||
if grants != PROTOTYPE_E5M3_REWARD_GRANTS:
|
||||
return (
|
||||
"error: prototype_combat_pocket_clear fixedGrants must match "
|
||||
f"{PROTOTYPE_E5M3_REWARD_GRANTS!r}, got {grants!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_encounter_catalogs(
|
||||
*,
|
||||
encounter_files: list[Path],
|
||||
encounter_validator: Draft202012Validator,
|
||||
known_reward_table_ids: frozenset[str],
|
||||
) -> tuple[int, dict[str, str], dict[str, dict]]:
|
||||
"""Validate encounter JSON files. Returns (error_count, seen_ids, id_to_row)."""
|
||||
errors = 0
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_row: dict[str, dict] = {}
|
||||
|
||||
for path in encounter_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
|
||||
|
||||
encounters = data.get("encounters")
|
||||
if not isinstance(encounters, list):
|
||||
print(f"error: {rel}: expected top-level 'encounters' array", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
for i, row in enumerate(encounters):
|
||||
if not isinstance(row, dict):
|
||||
print(f"error: {rel}: encounters[{i}] must be an object", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
row_schema_errors = 0
|
||||
for err in sorted(encounter_validator.iter_errors(row), key=lambda e: e.path):
|
||||
loc = ".".join(str(p) for p in err.path) or "(root)"
|
||||
print(f"error: {rel} encounters[{i}] {loc}: {err.message}", file=sys.stderr)
|
||||
row_schema_errors += 1
|
||||
errors += 1
|
||||
eid = row.get("id")
|
||||
if isinstance(eid, str) and row_schema_errors == 0:
|
||||
prev = seen_ids.get(eid)
|
||||
if prev:
|
||||
print(f"error: duplicate encounter id {eid!r} in {prev} and {rel}", file=sys.stderr)
|
||||
errors += 1
|
||||
else:
|
||||
seen_ids[eid] = rel
|
||||
id_to_row[eid] = row
|
||||
reward_table_id = row.get("rewardTableId")
|
||||
if isinstance(reward_table_id, str) and reward_table_id not in known_reward_table_ids:
|
||||
print(
|
||||
f"error: {rel} encounters[{i}]: rewardTableId {reward_table_id!r} "
|
||||
"is not in reward table catalogs",
|
||||
file=sys.stderr,
|
||||
)
|
||||
errors += 1
|
||||
|
||||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _prototype_e5m3_encounter_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E5M3 encounter contract fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
if ids != PROTOTYPE_E5M3_ENCOUNTER_IDS:
|
||||
return (
|
||||
"error: prototype E5M3 expects exactly encounter ids "
|
||||
f"{sorted(PROTOTYPE_E5M3_ENCOUNTER_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if NPC instance or reward table cross-refs fail, else None."""
|
||||
for eid, row in id_to_row.items():
|
||||
npc_ids = row.get("requiredNpcInstanceIds")
|
||||
if isinstance(npc_ids, list):
|
||||
npc_set = frozenset(str(x) for x in npc_ids if isinstance(x, str))
|
||||
if npc_set != PROTOTYPE_E5M2_NPC_INSTANCE_IDS:
|
||||
return (
|
||||
f"error: encounter {eid!r}: requiredNpcInstanceIds must be exactly "
|
||||
f"{sorted(PROTOTYPE_E5M2_NPC_INSTANCE_IDS)!r}, got {sorted(npc_set)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
|
|
@ -918,6 +1149,15 @@ def main() -> int:
|
|||
if not NPC_BEHAVIOR_SCHEMA.is_file():
|
||||
print(f"error: missing schema {NPC_BEHAVIOR_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not REWARD_GRANT_ROW_SCHEMA.is_file():
|
||||
print(f"error: missing schema {REWARD_GRANT_ROW_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not REWARD_TABLE_SCHEMA.is_file():
|
||||
print(f"error: missing schema {REWARD_TABLE_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not ENCOUNTER_DEF_SCHEMA.is_file():
|
||||
print(f"error: missing schema {ENCOUNTER_DEF_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_validator = Draft202012Validator(skill_schema)
|
||||
|
|
@ -936,6 +1176,9 @@ def main() -> int:
|
|||
ability_validator = Draft202012Validator(ability_schema)
|
||||
npc_behavior_schema = json.loads(NPC_BEHAVIOR_SCHEMA.read_text(encoding="utf-8"))
|
||||
npc_behavior_validator = Draft202012Validator(npc_behavior_schema)
|
||||
reward_table_validator = _reward_table_def_validator()
|
||||
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
encounter_validator = Draft202012Validator(encounter_schema)
|
||||
|
||||
if not SKILLS_DIR.is_dir():
|
||||
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
|
||||
|
|
@ -1010,6 +1253,24 @@ def main() -> int:
|
|||
print(f"error: no *_npc_behaviors.json files under {NPC_BEHAVIORS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not REWARD_TABLES_DIR.is_dir():
|
||||
print(f"error: missing directory {REWARD_TABLES_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
reward_table_files = sorted(REWARD_TABLES_DIR.glob("*_reward_tables.json"))
|
||||
if not reward_table_files:
|
||||
print(f"error: no *_reward_tables.json files under {REWARD_TABLES_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not ENCOUNTERS_DIR.is_dir():
|
||||
print(f"error: missing directory {ENCOUNTERS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
encounter_files = sorted(ENCOUNTERS_DIR.glob("*_encounters.json"))
|
||||
if not encounter_files:
|
||||
print(f"error: no *_encounters.json files under {ENCOUNTERS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_category: dict[str, str] = {}
|
||||
errors = 0
|
||||
|
|
@ -1237,6 +1498,44 @@ def main() -> int:
|
|||
print(e5m2_attack_ability_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
reward_table_errors, reward_table_seen_ids, reward_table_id_to_row = _validate_reward_table_catalogs(
|
||||
reward_table_files=reward_table_files,
|
||||
reward_table_validator=reward_table_validator,
|
||||
known_item_ids=frozenset(item_seen_ids.keys()),
|
||||
)
|
||||
if reward_table_errors:
|
||||
print(f"content validation failed with {reward_table_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e5m3_reward_table_err = _prototype_e5m3_reward_table_gate(reward_table_seen_ids)
|
||||
if e5m3_reward_table_err:
|
||||
print(e5m3_reward_table_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e5m3_reward_grant_err = _prototype_e5m3_reward_grant_content_gate(reward_table_id_to_row)
|
||||
if e5m3_reward_grant_err:
|
||||
print(e5m3_reward_grant_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
encounter_errors, encounter_seen_ids, encounter_id_to_row = _validate_encounter_catalogs(
|
||||
encounter_files=encounter_files,
|
||||
encounter_validator=encounter_validator,
|
||||
known_reward_table_ids=frozenset(reward_table_seen_ids.keys()),
|
||||
)
|
||||
if encounter_errors:
|
||||
print(f"content validation failed with {encounter_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e5m3_encounter_err = _prototype_e5m3_encounter_gate(encounter_seen_ids)
|
||||
if e5m3_encounter_err:
|
||||
print(e5m3_encounter_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e5m3_encounter_cross_ref_err = _prototype_e5m3_encounter_cross_ref_gate(encounter_id_to_row)
|
||||
if e5m3_encounter_cross_ref_err:
|
||||
print(e5m3_encounter_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"content OK: "
|
||||
f"{len(skill_files)} skill catalog file(s), "
|
||||
|
|
@ -1248,12 +1547,16 @@ def main() -> int:
|
|||
f"{len(recipe_files)} recipe catalog file(s), "
|
||||
f"{len(ability_files)} ability catalog file(s), "
|
||||
f"{len(npc_behavior_files)} npc behavior catalog file(s), "
|
||||
f"{len(reward_table_files)} reward table catalog file(s), "
|
||||
f"{len(encounter_files)} encounter catalog file(s), "
|
||||
f"{len(seen_ids)} unique skill id(s), "
|
||||
f"{len(item_seen_ids)} unique item id(s), "
|
||||
f"{len(node_seen_ids)} unique nodeDefId(s), "
|
||||
f"{len(recipe_seen_ids)} unique recipe id(s), "
|
||||
f"{len(ability_seen_ids)} unique ability id(s), "
|
||||
f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), "
|
||||
f"{len(reward_table_seen_ids)} unique reward table id(s), "
|
||||
f"{len(encounter_seen_ids)} unique encounter id(s), "
|
||||
f"{len(track_skill_ids)} mastery track(s)"
|
||||
)
|
||||
return 0
|
||||
|
|
|
|||
Loading…
Reference in New Issue