@alpaca-software/40kdc-data 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/author-input.d.ts +20 -1
  2. package/dist/author-input.d.ts.map +1 -1
  3. package/dist/author-input.js +64 -8
  4. package/dist/author-input.js.map +1 -1
  5. package/dist/author-seed.d.ts +62 -0
  6. package/dist/author-seed.d.ts.map +1 -0
  7. package/dist/author-seed.js +194 -0
  8. package/dist/author-seed.js.map +1 -0
  9. package/dist/codegen-data.js +2 -0
  10. package/dist/codegen-data.js.map +1 -1
  11. package/dist/commands/translate.d.ts.map +1 -1
  12. package/dist/commands/translate.js +6 -68
  13. package/dist/commands/translate.js.map +1 -1
  14. package/dist/data/bundle.generated.js +1 -1
  15. package/dist/data/bundle.generated.js.map +1 -1
  16. package/dist/data/dataset.d.ts +16 -1
  17. package/dist/data/dataset.d.ts.map +1 -1
  18. package/dist/data/dataset.js +25 -0
  19. package/dist/data/dataset.js.map +1 -1
  20. package/dist/data/index.d.ts +4 -0
  21. package/dist/data/index.d.ts.map +1 -1
  22. package/dist/data/index.js +4 -0
  23. package/dist/data/index.js.map +1 -1
  24. package/dist/data/types.d.ts +5 -1
  25. package/dist/data/types.d.ts.map +1 -1
  26. package/dist/data/types.js +2 -0
  27. package/dist/data/types.js.map +1 -1
  28. package/dist/gen-conformance.js +180 -1
  29. package/dist/gen-conformance.js.map +1 -1
  30. package/dist/generated.d.ts +309 -154
  31. package/dist/generated.d.ts.map +1 -1
  32. package/dist/generated.js.map +1 -1
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +7 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/migrate-terrain.d.ts +2 -0
  38. package/dist/migrate-terrain.d.ts.map +1 -0
  39. package/dist/migrate-terrain.js +297 -0
  40. package/dist/migrate-terrain.js.map +1 -0
  41. package/dist/runner.d.ts.map +1 -1
  42. package/dist/runner.js +42 -0
  43. package/dist/runner.js.map +1 -1
  44. package/dist/terrain/index.d.ts +11 -0
  45. package/dist/terrain/index.d.ts.map +1 -0
  46. package/dist/terrain/index.js +9 -0
  47. package/dist/terrain/index.js.map +1 -0
  48. package/dist/terrain/resolve.d.ts +122 -0
  49. package/dist/terrain/resolve.d.ts.map +1 -0
  50. package/dist/terrain/resolve.js +221 -0
  51. package/dist/terrain/resolve.js.map +1 -0
  52. package/dist/terrain/solve.d.ts +56 -0
  53. package/dist/terrain/solve.d.ts.map +1 -0
  54. package/dist/terrain/solve.js +80 -0
  55. package/dist/terrain/solve.js.map +1 -0
  56. package/dist/translate/condition.d.ts +26 -0
  57. package/dist/translate/condition.d.ts.map +1 -0
  58. package/dist/translate/condition.js +171 -0
  59. package/dist/translate/condition.js.map +1 -0
  60. package/dist/translate/index.d.ts +9 -0
  61. package/dist/translate/index.d.ts.map +1 -0
  62. package/dist/translate/index.js +9 -0
  63. package/dist/translate/index.js.map +1 -0
  64. package/dist/translate/scoring.d.ts +38 -0
  65. package/dist/translate/scoring.d.ts.map +1 -0
  66. package/dist/translate/scoring.js +80 -0
  67. package/dist/translate/scoring.js.map +1 -0
  68. package/dist/validate.d.ts.map +1 -1
  69. package/dist/validate.js +1 -0
  70. package/dist/validate.js.map +1 -1
  71. package/package.json +3 -1
  72. package/schemas/$defs/common.schema.json +43 -0
  73. package/schemas/core/secondary-card.schema.json +50 -28
  74. package/schemas/core/terrain-layout.schema.json +42 -56
  75. package/schemas/core/terrain-template.schema.json +105 -0
  76. package/schemas/enrichment/ability-dsl/condition.schema.json +5 -2
  77. package/schemas/enrichment/ability-dsl/effect.schema.json +2 -1
@@ -1 +1 @@
1
- {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"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","sourcesContent":["import type Ajv from \"ajv\";\nimport { readFileSync } from \"node:fs\";\nimport { glob } from \"glob\";\nimport { resolve, basename } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\n\nexport interface ValidationError {\n file: string;\n index: number;\n errors: Array<{ path: string; message: string }>;\n}\n\nexport interface ValidationResult {\n totalFiles: number;\n totalItems: number;\n passed: number;\n failed: number;\n errors: ValidationError[];\n}\n\n/**\n * Map from data file base-name prefix to schema $id.\n */\nconst SCHEMA_MAP: Record<string, string> = {\n factions: \"https://40kdc.dev/schemas/core/faction.schema.json\",\n units: \"https://40kdc.dev/schemas/core/unit.schema.json\",\n weapons: \"https://40kdc.dev/schemas/core/weapon.schema.json\",\n \"weapon-keywords\": \"https://40kdc.dev/schemas/core/weapon-keyword.schema.json\",\n \"game-versions\": \"https://40kdc.dev/schemas/core/game-version.schema.json\",\n detachments: \"https://40kdc.dev/schemas/core/detachment.schema.json\",\n enhancements: \"https://40kdc.dev/schemas/core/enhancement.schema.json\",\n stratagems: \"https://40kdc.dev/schemas/core/stratagem.schema.json\",\n \"wargear-options\": \"https://40kdc.dev/schemas/core/wargear-option.schema.json\",\n \"leader-attachments\": \"https://40kdc.dev/schemas/core/leader-attachment.schema.json\",\n \"unit-compositions\": \"https://40kdc.dev/schemas/core/unit-composition.schema.json\",\n \"force-dispositions\": \"https://40kdc.dev/schemas/core/force-disposition.schema.json\",\n \"deployment-patterns\": \"https://40kdc.dev/schemas/core/deployment-pattern.schema.json\",\n \"mission-matchups\": \"https://40kdc.dev/schemas/core/mission-matchup.schema.json\",\n missions: \"https://40kdc.dev/schemas/core/mission.schema.json\",\n \"secondary-cards\": \"https://40kdc.dev/schemas/core/secondary-card.schema.json\",\n \"terrain-layouts\": \"https://40kdc.dev/schemas/core/terrain-layout.schema.json\",\n \"phase-mappings\": \"https://40kdc.dev/schemas/enrichment/phase-mapping.schema.json\",\n \"timing-flags\": \"https://40kdc.dev/schemas/enrichment/timing-flag.schema.json\",\n \"interaction-flags\": \"https://40kdc.dev/schemas/enrichment/interaction-flag.schema.json\",\n abilities: \"https://40kdc.dev/schemas/enrichment/ability-dsl/ability.schema.json\",\n \"resource-pools\": \"https://40kdc.dev/schemas/enrichment/resource-pool.schema.json\",\n};\n\n/**\n * Determine which schema $id to use for a given data file path.\n * Convention: the file's base name prefix (before the first dot) maps to a schema.\n */\nfunction resolveSchemaId(filePath: string): string | null {\n const base = basename(filePath);\n for (const [prefix, schemaId] of Object.entries(SCHEMA_MAP)) {\n if (base.startsWith(prefix)) {\n return schemaId;\n }\n }\n return null;\n}\n\n/**\n * Validate all data files matching the given glob pattern.\n * Each data file is expected to be a JSON array; each element is validated individually.\n */\nexport async function validateFiles(\n ajv: Ajv,\n pattern: string,\n cwd?: string,\n): Promise<ValidationResult> {\n const root = cwd ?? DATA_ROOT;\n const files = await glob(pattern, { cwd: root, absolute: true });\n\n const result: ValidationResult = {\n totalFiles: files.length,\n totalItems: 0,\n passed: 0,\n failed: 0,\n errors: [],\n };\n\n for (const file of files) {\n const schemaId = resolveSchemaId(file);\n if (!schemaId) {\n result.errors.push({\n file,\n index: -1,\n errors: [{ path: \"\", message: `No schema mapping found for file: ${basename(file)}` }],\n });\n result.failed++;\n continue;\n }\n\n const validate = ajv.getSchema(schemaId);\n if (!validate) {\n result.errors.push({\n file,\n index: -1,\n errors: [{ path: \"\", message: `Schema not found: ${schemaId}` }],\n });\n result.failed++;\n continue;\n }\n\n let data: unknown;\n try {\n const raw = readFileSync(file, \"utf-8\");\n data = JSON.parse(raw);\n } catch (err) {\n result.errors.push({\n file,\n index: -1,\n errors: [{ path: \"\", message: `Failed to parse JSON: ${(err as Error).message}` }],\n });\n result.failed++;\n continue;\n }\n\n if (!Array.isArray(data)) {\n result.errors.push({\n file,\n index: -1,\n errors: [{ path: \"\", message: \"Data file must be a JSON array\" }],\n });\n result.failed++;\n continue;\n }\n\n for (let i = 0; i < data.length; i++) {\n result.totalItems++;\n const valid = validate(data[i]);\n if (valid) {\n result.passed++;\n } else {\n result.failed++;\n result.errors.push({\n file,\n index: i,\n errors: (validate.errors ?? []).map((e) => ({\n path: e.instancePath || \"/\",\n message: e.message ?? \"Unknown validation error\",\n })),\n });\n }\n }\n }\n\n return result;\n}\n"]}
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"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","sourcesContent":["import type Ajv from \"ajv\";\nimport { readFileSync } from \"node:fs\";\nimport { glob } from \"glob\";\nimport { resolve, basename } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\n\nexport interface ValidationError {\n file: string;\n index: number;\n errors: Array<{ path: string; message: string }>;\n}\n\nexport interface ValidationResult {\n totalFiles: number;\n totalItems: number;\n passed: number;\n failed: number;\n errors: ValidationError[];\n}\n\n/**\n * Map from data file base-name prefix to schema $id.\n */\nconst SCHEMA_MAP: Record<string, string> = {\n factions: \"https://40kdc.dev/schemas/core/faction.schema.json\",\n units: \"https://40kdc.dev/schemas/core/unit.schema.json\",\n weapons: \"https://40kdc.dev/schemas/core/weapon.schema.json\",\n \"weapon-keywords\": \"https://40kdc.dev/schemas/core/weapon-keyword.schema.json\",\n \"game-versions\": \"https://40kdc.dev/schemas/core/game-version.schema.json\",\n detachments: \"https://40kdc.dev/schemas/core/detachment.schema.json\",\n enhancements: \"https://40kdc.dev/schemas/core/enhancement.schema.json\",\n stratagems: \"https://40kdc.dev/schemas/core/stratagem.schema.json\",\n \"wargear-options\": \"https://40kdc.dev/schemas/core/wargear-option.schema.json\",\n \"leader-attachments\": \"https://40kdc.dev/schemas/core/leader-attachment.schema.json\",\n \"unit-compositions\": \"https://40kdc.dev/schemas/core/unit-composition.schema.json\",\n \"force-dispositions\": \"https://40kdc.dev/schemas/core/force-disposition.schema.json\",\n \"deployment-patterns\": \"https://40kdc.dev/schemas/core/deployment-pattern.schema.json\",\n \"mission-matchups\": \"https://40kdc.dev/schemas/core/mission-matchup.schema.json\",\n missions: \"https://40kdc.dev/schemas/core/mission.schema.json\",\n \"secondary-cards\": \"https://40kdc.dev/schemas/core/secondary-card.schema.json\",\n \"terrain-templates\": \"https://40kdc.dev/schemas/core/terrain-template.schema.json\",\n \"terrain-layouts\": \"https://40kdc.dev/schemas/core/terrain-layout.schema.json\",\n \"phase-mappings\": \"https://40kdc.dev/schemas/enrichment/phase-mapping.schema.json\",\n \"timing-flags\": \"https://40kdc.dev/schemas/enrichment/timing-flag.schema.json\",\n \"interaction-flags\": \"https://40kdc.dev/schemas/enrichment/interaction-flag.schema.json\",\n abilities: \"https://40kdc.dev/schemas/enrichment/ability-dsl/ability.schema.json\",\n \"resource-pools\": \"https://40kdc.dev/schemas/enrichment/resource-pool.schema.json\",\n};\n\n/**\n * Determine which schema $id to use for a given data file path.\n * Convention: the file's base name prefix (before the first dot) maps to a schema.\n */\nfunction resolveSchemaId(filePath: string): string | null {\n const base = basename(filePath);\n for (const [prefix, schemaId] of Object.entries(SCHEMA_MAP)) {\n if (base.startsWith(prefix)) {\n return schemaId;\n }\n }\n return null;\n}\n\n/**\n * Validate all data files matching the given glob pattern.\n * Each data file is expected to be a JSON array; each element is validated individually.\n */\nexport async function validateFiles(\n ajv: Ajv,\n pattern: string,\n cwd?: string,\n): Promise<ValidationResult> {\n const root = cwd ?? DATA_ROOT;\n const files = await glob(pattern, { cwd: root, absolute: true });\n\n const result: ValidationResult = {\n totalFiles: files.length,\n totalItems: 0,\n passed: 0,\n failed: 0,\n errors: [],\n };\n\n for (const file of files) {\n const schemaId = resolveSchemaId(file);\n if (!schemaId) {\n result.errors.push({\n file,\n index: -1,\n errors: [{ path: \"\", message: `No schema mapping found for file: ${basename(file)}` }],\n });\n result.failed++;\n continue;\n }\n\n const validate = ajv.getSchema(schemaId);\n if (!validate) {\n result.errors.push({\n file,\n index: -1,\n errors: [{ path: \"\", message: `Schema not found: ${schemaId}` }],\n });\n result.failed++;\n continue;\n }\n\n let data: unknown;\n try {\n const raw = readFileSync(file, \"utf-8\");\n data = JSON.parse(raw);\n } catch (err) {\n result.errors.push({\n file,\n index: -1,\n errors: [{ path: \"\", message: `Failed to parse JSON: ${(err as Error).message}` }],\n });\n result.failed++;\n continue;\n }\n\n if (!Array.isArray(data)) {\n result.errors.push({\n file,\n index: -1,\n errors: [{ path: \"\", message: \"Data file must be a JSON array\" }],\n });\n result.failed++;\n continue;\n }\n\n for (let i = 0; i < data.length; i++) {\n result.totalItems++;\n const valid = validate(data[i]);\n if (valid) {\n result.passed++;\n } else {\n result.failed++;\n result.errors.push({\n file,\n index: i,\n errors: (validate.errors ?? []).map((e) => ({\n path: e.instancePath || \"/\",\n message: e.message ?? \"Unknown validation error\",\n })),\n });\n }\n }\n }\n\n return result;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alpaca-software/40kdc-data",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "The 40kdc Warhammer 40K dataset behind a linked, typed API — find units, follow them to their weapons, abilities, phases, and factions. Also validates data against the canonical JSON Schemas.",
6
6
  "keywords": [
@@ -52,6 +52,7 @@
52
52
  "codegen:data": "tsx src/codegen-data.ts",
53
53
  "gen:conformance": "tsx src/gen-conformance.ts",
54
54
  "docs:api": "typedoc",
55
+ "docs:api:html": "typedoc --options typedoc.html.json",
55
56
  "link:abilities": "tsx src/link-abilities.ts",
56
57
  "validate": "tsx src/cli.ts validate-all",
57
58
  "validate:core": "tsx src/cli.ts validate-core",
@@ -60,6 +61,7 @@
60
61
  "audit:coverage": "tsx src/cli.ts audit-coverage --write",
61
62
  "scrub:defensive-flag": "tsx src/scrub-defensive-flag.ts",
62
63
  "author:input": "tsx src/author-input.ts",
64
+ "author:seed": "tsx src/author-seed.ts",
63
65
  "author:propose": "tsx src/author-batch.ts propose",
64
66
  "author:repair": "tsx src/author-batch.ts repair",
65
67
  "author:apply": "tsx src/author-batch.ts apply",
@@ -81,6 +81,49 @@
81
81
  },
82
82
  "required": ["x", "y"],
83
83
  "additionalProperties": false
84
+ },
85
+ "footprint": {
86
+ "description": "A terrain piece's 2D footprint in local inches (y-down): an axis-aligned rectangle with its min corner at the local origin, a right triangle with the right angle at the local origin and legs along +x/+y, or an explicit polygon (>= 3 points). The placement resolver re-centers the footprint on its polygon area centroid, so the local-origin convention does not affect where the piece lands — only its shape matters.",
87
+ "oneOf": [
88
+ {
89
+ "type": "object",
90
+ "properties": {
91
+ "type": { "const": "rectangle" },
92
+ "width": { "type": "number", "exclusiveMinimum": 0 },
93
+ "height": { "type": "number", "exclusiveMinimum": 0 }
94
+ },
95
+ "required": ["type", "width", "height"],
96
+ "additionalProperties": false
97
+ },
98
+ {
99
+ "type": "object",
100
+ "properties": {
101
+ "type": { "const": "right-triangle" },
102
+ "width": { "type": "number", "exclusiveMinimum": 0 },
103
+ "height": { "type": "number", "exclusiveMinimum": 0 }
104
+ },
105
+ "required": ["type", "width", "height"],
106
+ "additionalProperties": false
107
+ },
108
+ {
109
+ "type": "object",
110
+ "properties": {
111
+ "type": { "const": "polygon" },
112
+ "points": {
113
+ "type": "array",
114
+ "items": { "$ref": "#/$defs/vec2" },
115
+ "minItems": 3
116
+ }
117
+ },
118
+ "required": ["type", "points"],
119
+ "additionalProperties": false
120
+ }
121
+ ]
122
+ },
123
+ "terrain-area-keyword": {
124
+ "type": "string",
125
+ "enum": ["obscuring", "hidden", "plunging-fire"],
126
+ "description": "An 11e terrain-area keyword. Confirmed launch set; extend as further keywords publish on dataslate."
84
127
  }
85
128
  }
86
129
  }
@@ -111,39 +111,55 @@
111
111
  "required": ["operation"],
112
112
  "additionalProperties": false
113
113
  },
114
- "action": {
115
- "type": "object",
116
- "description": "Optional player action the card enables.",
117
- "properties": {
118
- "starts": {
119
- "$ref": "../defs/common.schema.json#/$defs/phase",
120
- "description": "Phase in which the action can be started."
121
- },
122
- "player_turn": { "$ref": "../defs/common.schema.json#/$defs/player-turn" },
123
- "units": {
124
- "$ref": "../enrichment/ability-dsl/condition.schema.json",
125
- "description": "Eligibility predicate for which units may perform the action."
126
- },
127
- "use_limit": {
128
- "type": "integer",
129
- "minimum": 1,
130
- "description": "Maximum number of times the action may be performed."
131
- },
132
- "completes": {
133
- "$ref": "../enrichment/ability-dsl/condition.schema.json",
134
- "description": "Predicate for when the action is considered complete."
114
+ "actions": {
115
+ "type": "array",
116
+ "minItems": 1,
117
+ "description": "Optional player actions the card enables. Most cards have a single action; a few (e.g. Observe Enemy, with separate Baited-removal and Spotted actions) have two distinct actions on the same card.",
118
+ "items": {
119
+ "type": "object",
120
+ "properties": {
121
+ "action_id": {
122
+ "type": "string",
123
+ "minLength": 1,
124
+ "maxLength": 64,
125
+ "description": "Optional kebab-case identifier used to reference this action from `action-completed` conditions in `awards[].when`."
126
+ },
127
+ "starts": {
128
+ "$ref": "../defs/common.schema.json#/$defs/phase",
129
+ "description": "Phase in which the action can be started."
130
+ },
131
+ "player_turn": { "$ref": "../defs/common.schema.json#/$defs/player-turn" },
132
+ "units": {
133
+ "$ref": "../enrichment/ability-dsl/condition.schema.json",
134
+ "description": "Eligibility predicate for which units may perform the action."
135
+ },
136
+ "use_limit": {
137
+ "type": "integer",
138
+ "minimum": 1,
139
+ "description": "Maximum number of times the action may be performed (per turn unless `use_limit_scope` says otherwise)."
140
+ },
141
+ "use_limit_scope": {
142
+ "type": "string",
143
+ "enum": ["per-turn", "per-game"],
144
+ "default": "per-turn",
145
+ "description": "Whether `use_limit` is enforced per turn or once per game (e.g. Recover the Relics / Find and Deny 'Overwhelming Force' is once per game)."
146
+ },
147
+ "completes": {
148
+ "$ref": "../enrichment/ability-dsl/condition.schema.json",
149
+ "description": "Predicate for when the action is considered complete."
150
+ },
151
+ "effect": {
152
+ "$ref": "../enrichment/ability-dsl/effect.schema.json",
153
+ "description": "Effect applied when the action completes (e.g. terrain-area-tag, objective-tag, or unit-tag to mark transient state)."
154
+ }
135
155
  },
136
- "effect": {
137
- "$ref": "../enrichment/ability-dsl/effect.schema.json",
138
- "description": "Effect applied when the action completes (e.g. terrain-area-tag to mark transient state on a terrain piece)."
139
- }
140
- },
141
- "additionalProperties": false
156
+ "additionalProperties": false
157
+ }
142
158
  },
143
159
  "awards": {
144
160
  "type": "array",
145
161
  "minItems": 1,
146
- "description": "VP-award blocks: each scores when `trigger` fires and the optional `when` condition holds. An award scores either a flat `vp` or a count-scaled `vp_per` (VP per instance of the thing named by `per`). Awards accrue independently and sum; a card's '+ ... CUMULATIVE' rows are modelled as separate awards flagged `cumulative` for faithful round-trip.",
162
+ "description": "VP-award blocks: each scores when `trigger` fires and the optional `when` condition holds. An award scores either a flat `vp` or a count-scaled `vp_per` (VP per instance of the thing named by `per`). Awards accrue independently and sum; a card's '+ ... CUMULATIVE' rows are modelled as separate awards flagged `cumulative` for faithful round-trip. Awards sharing the same `exclusive_group` value within a card resolve as the highest-scoring single award fires (the card's literal 'OR' rows between tier breakpoints, e.g. Record-Breaking Mission's 3-Fronts vs 4-Fronts).",
147
163
  "items": {
148
164
  "type": "object",
149
165
  "properties": {
@@ -174,6 +190,12 @@
174
190
  "type": "boolean",
175
191
  "default": false,
176
192
  "description": "Marks an award the card shows as an additive '+' bonus to the preceding award in the same trigger block (the card's CUMULATIVE rows). Purely descriptive — all awards accrue independently and are summed."
193
+ },
194
+ "exclusive_group": {
195
+ "type": "string",
196
+ "minLength": 1,
197
+ "maxLength": 64,
198
+ "description": "Awards sharing this kebab-case group key resolve as 'score only the highest, not the sum' (the card's literal OR between tier rows). Awards with different `exclusive_group` values, or no value, accrue independently."
177
199
  }
178
200
  },
179
201
  "oneOf": [
@@ -2,84 +2,68 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://40kdc.dev/schemas/core/terrain-layout.schema.json",
4
4
  "title": "Terrain Layout",
5
- "description": "A recommended arrangement of terrain pieces on the board, independent of the deployment map (a deployment-pattern references the layouts it recommends via recommended_terrain_layout_ids). Geometry is the source of truth; the GW standard piece templates are expressed as explicit footprints, with an optional descriptive `template` label. Footprints are deliberately open (not enum-locked) the launch catalog and its size are unconfirmed, so this models any shape rather than a fixed set. No layout data is authored yet.",
5
+ "description": "A recommended arrangement of terrain pieces on the board, independent of the deployment map (a deployment-pattern references the layouts it recommends via recommended_terrain_layout_ids). Each piece draws its geometry from a catalog `template` (a terrain-template entity) or an inline `footprint`; geometry is the source of truth. Placement is template-centroid-anchored: `position` is the piece's centroid, which is invariant under rotation and mirror, so orientation and location are decoupled. Resolved board-space vertices are derived by the shared terrain resolver (pinned by the conformance corpus), never stored here. No layout data is authored yet beyond migrated examples.",
6
6
  "type": "object",
7
7
  "$defs": {
8
- "footprint": {
9
- "description": "A terrain piece's 2D footprint, relative to the piece's `position`. Axis-aligned rectangle, right triangle (right angle at the local origin, legs along +x/+y), or an explicit polygon. GW's standard templates (e.g. 7\"×11.5\" rectangles, 8\"×11.5\" right triangles, 6\"×4\" rectangles, 10\"×2.5\" and 6\"×2\" lines) are all expressible here; lines are thin rectangles.",
10
- "oneOf": [
11
- {
12
- "type": "object",
13
- "properties": {
14
- "type": { "const": "rectangle" },
15
- "width": { "type": "number", "exclusiveMinimum": 0 },
16
- "height": { "type": "number", "exclusiveMinimum": 0 }
17
- },
18
- "required": ["type", "width", "height"],
19
- "additionalProperties": false
20
- },
21
- {
22
- "type": "object",
23
- "properties": {
24
- "type": { "const": "right-triangle" },
25
- "width": { "type": "number", "exclusiveMinimum": 0 },
26
- "height": { "type": "number", "exclusiveMinimum": 0 }
27
- },
28
- "required": ["type", "width", "height"],
29
- "additionalProperties": false
30
- },
31
- {
32
- "type": "object",
33
- "properties": {
34
- "type": { "const": "polygon" },
35
- "points": {
36
- "type": "array",
37
- "items": { "$ref": "../defs/common.schema.json#/$defs/vec2" },
38
- "minItems": 3
39
- }
40
- },
41
- "required": ["type", "points"],
42
- "additionalProperties": false
43
- }
44
- ]
45
- },
46
- "terrain-area-keyword": {
47
- "type": "string",
48
- "enum": ["obscuring", "hidden", "plunging-fire"],
49
- "description": "An 11e terrain-area keyword. Confirmed launch set; extend as further keywords publish on dataslate."
50
- },
51
8
  "piece": {
52
9
  "type": "object",
53
- "description": "One terrain feature placed on the board.",
10
+ "description": "One terrain piece placed on the board. Geometry comes from a catalog `template` or an inline `footprint` (if both are present, `footprint` is authoritative and `template` is provenance).",
54
11
  "properties": {
12
+ "id": {
13
+ "$ref": "../defs/common.schema.json#/$defs/entity-id",
14
+ "description": "Layout-local id. Required for any piece referenced by another piece's `parent_area_id`."
15
+ },
55
16
  "name": { "type": "string", "minLength": 1, "maxLength": 128 },
56
- "footprint": { "$ref": "#/$defs/footprint" },
17
+ "piece_type": {
18
+ "type": "string",
19
+ "enum": ["area", "feature"],
20
+ "default": "area",
21
+ "description": "An `area` is a gameplay terrain zone (the 11e 'terrain area'); a `feature` is physical scenery (walls, containers, pipes) placed on an area."
22
+ },
23
+ "template": {
24
+ "$ref": "../defs/common.schema.json#/$defs/entity-id",
25
+ "description": "Id of the terrain-template this piece instances. Footprint and defaults (height, blocking, keywords) are taken from that template unless overridden here."
26
+ },
27
+ "footprint": {
28
+ "$ref": "../defs/common.schema.json#/$defs/footprint",
29
+ "description": "Inline geometry, standing in for or overriding a template footprint. Authoritative when present."
30
+ },
57
31
  "position": {
58
32
  "$ref": "../defs/common.schema.json#/$defs/vec2",
59
- "description": "Board-inch placement of the footprint's local origin."
33
+ "description": "Placement of the piece's CENTROID (the polygon area centroid of its footprint). Rotation- and mirror-invariant: changing `rotation_degrees` or `mirror` never moves this point. In board inches, unless the piece is a feature with `parent_area_id`, in which case it is in the parent area's centroid-local frame."
60
34
  },
61
35
  "rotation_degrees": {
62
36
  "type": "number",
63
37
  "minimum": 0,
64
38
  "exclusiveMaximum": 360,
65
- "description": "Clockwise rotation of the footprint about `position`. Absent or 0 means axis-aligned."
39
+ "description": "Clockwise rotation about the centroid in the y-down board frame. Absent or 0 means the template's natural orientation."
66
40
  },
67
- "template": {
41
+ "mirror": {
68
42
  "type": "string",
69
- "minLength": 1,
70
- "maxLength": 64,
71
- "description": "Optional descriptive label for the GW standard template this piece uses (e.g. 'large-ruin', 'long-wall'). Free-form, not enum-locked — the geometry in `footprint` is authoritative."
43
+ "enum": ["none", "horizontal", "vertical"],
44
+ "default": "none",
45
+ "description": "Reflection applied in the centroid-local frame before rotation: `horizontal` negates local x (left-right flip), `vertical` negates local y."
46
+ },
47
+ "parent_area_id": {
48
+ "$ref": "../defs/common.schema.json#/$defs/entity-id",
49
+ "description": "For a feature: the layout-local id of the area it sits on. The feature's `position`/`rotation_degrees`/`mirror` are composed with the parent area's placement, so moving, rotating, or mirroring the area carries the feature with it."
50
+ },
51
+ "floor": {
52
+ "type": "integer",
53
+ "minimum": 0,
54
+ "default": 0,
55
+ "description": "Ruin floor this piece occupies (0 = ground level)."
72
56
  },
73
57
  "height_inches": {
74
58
  "type": "number",
75
59
  "minimum": 0,
76
- "description": "Height of the piece in inches. Gates Plunging Fire (a piece 3\" or taller confers +1 BS on ground-level targets)."
60
+ "description": "Height of the piece in inches; overrides the template default. Gates Plunging Fire (a piece 3\" or taller confers +1 BS on ground-level targets)."
77
61
  },
78
62
  "terrain_area_keywords": {
79
63
  "type": "array",
80
64
  "uniqueItems": true,
81
- "description": "Terrain-area keywords this piece's area carries.",
82
- "items": { "$ref": "#/$defs/terrain-area-keyword" }
65
+ "description": "Terrain-area keywords this piece's area carries; overrides the template default.",
66
+ "items": { "$ref": "../defs/common.schema.json#/$defs/terrain-area-keyword" }
83
67
  },
84
68
  "link_group": {
85
69
  "type": "string",
@@ -109,7 +93,9 @@
109
93
  "additionalProperties": false
110
94
  }
111
95
  },
112
- "required": ["footprint", "position"],
96
+ "required": ["position"],
97
+ "if": { "not": { "required": ["template"] } },
98
+ "then": { "required": ["footprint"] },
113
99
  "additionalProperties": false
114
100
  }
115
101
  },
@@ -0,0 +1,105 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://40kdc.dev/schemas/core/terrain-template.schema.json",
4
+ "title": "Terrain Template",
5
+ "description": "A reusable terrain piece in the standard catalog: a gameplay area (the 11e terrain-area templates) or a scenery feature (walls, containers, pipes, floor segments). Footprints are authored in natural local inches; the terrain resolver derives each footprint's polygon area centroid and re-centers on it, so a layout piece that instances a template places its centroid via the layout's `position`. An `area` template may carry an embedded `features` list — scenery placed in the area's centroid-local frame — making the template a reusable composition (e.g. a ruin with its walls). Placing such a template places all of its features, transformed by the area's own placement.",
6
+ "type": "object",
7
+ "$defs": {
8
+ "composed-feature": {
9
+ "type": "object",
10
+ "description": "A feature placed on an area template, positioned in the area's centroid-local frame (y-down inches). When the area is placed, rotated, or mirrored, its composed features are carried along.",
11
+ "properties": {
12
+ "id": {
13
+ "$ref": "../defs/common.schema.json#/$defs/entity-id",
14
+ "description": "Composition-local id for this feature instance."
15
+ },
16
+ "template": {
17
+ "$ref": "../defs/common.schema.json#/$defs/entity-id",
18
+ "description": "Id of the feature-kind terrain-template to place."
19
+ },
20
+ "position": {
21
+ "$ref": "../defs/common.schema.json#/$defs/vec2",
22
+ "description": "The feature's centroid in the area's centroid-local frame (origin at the area centroid, y-down inches)."
23
+ },
24
+ "rotation_degrees": {
25
+ "type": "number",
26
+ "minimum": 0,
27
+ "exclusiveMaximum": 360,
28
+ "description": "Clockwise rotation of the feature about its own centroid, within the area-local frame."
29
+ },
30
+ "mirror": {
31
+ "type": "string",
32
+ "enum": ["none", "horizontal", "vertical"],
33
+ "default": "none"
34
+ },
35
+ "floor": {
36
+ "type": "integer",
37
+ "minimum": 0,
38
+ "default": 0,
39
+ "description": "Ruin floor this feature occupies (0 = ground level)."
40
+ }
41
+ },
42
+ "required": ["template", "position"],
43
+ "additionalProperties": false
44
+ }
45
+ },
46
+ "properties": {
47
+ "id": { "$ref": "../defs/common.schema.json#/$defs/entity-id" },
48
+ "name": { "type": "string", "minLength": 1, "maxLength": 128 },
49
+ "kind": {
50
+ "type": "string",
51
+ "enum": ["area", "feature"],
52
+ "description": "`area` = a gameplay terrain zone; `feature` = physical scenery placed on an area."
53
+ },
54
+ "source": {
55
+ "type": "string",
56
+ "minLength": 1,
57
+ "maxLength": 64,
58
+ "description": "Catalog or mission pack the template originates from."
59
+ },
60
+ "footprint": { "$ref": "../defs/common.schema.json#/$defs/footprint" },
61
+ "default_height_inches": {
62
+ "type": "number",
63
+ "minimum": 0,
64
+ "description": "Default height in inches for pieces instancing this template. Gates Plunging Fire (>= 3\")."
65
+ },
66
+ "default_blocking": {
67
+ "type": "boolean",
68
+ "description": "Whether the template blocks line of sight / movement by default."
69
+ },
70
+ "ground_accessible": {
71
+ "type": "boolean",
72
+ "default": true,
73
+ "description": "Whether models may be placed on the ground footprint. `false` marks an elevated-only piece (a platform reachable only on its `upper_floor`, e.g. a gantry/catwalk) or a solid obstacle with no valid placement (e.g. a generator). Meaningful for `kind: \"feature\"`."
74
+ },
75
+ "upper_floor": {
76
+ "type": "object",
77
+ "description": "An elevated platform carried by this feature (e.g. a ruin's second storey). Its footprint is authored in the SAME local frame as `footprint` and re-centered on the GROUND footprint's polygon area centroid, so the two floors stay registered when the piece is placed, rotated, or mirrored. Non-resolved metadata: the terrain resolver does not emit it; authoring/visualization tools render it as an overlay. Meaningful for `kind: \"feature\"`.",
78
+ "properties": {
79
+ "footprint": { "$ref": "../defs/common.schema.json#/$defs/footprint" },
80
+ "floor": {
81
+ "type": "integer",
82
+ "minimum": 1,
83
+ "default": 1,
84
+ "description": "Ruin floor this platform occupies (1 = first floor above ground)."
85
+ }
86
+ },
87
+ "required": ["footprint"],
88
+ "additionalProperties": false
89
+ },
90
+ "default_terrain_area_keywords": {
91
+ "type": "array",
92
+ "uniqueItems": true,
93
+ "description": "Terrain-area keywords areas of this template carry by default. Meaningful for `kind: \"area\"`.",
94
+ "items": { "$ref": "../defs/common.schema.json#/$defs/terrain-area-keyword" }
95
+ },
96
+ "features": {
97
+ "type": "array",
98
+ "description": "Composed scenery features, in the area's centroid-local frame. Only meaningful for `kind: \"area\"`.",
99
+ "items": { "$ref": "#/$defs/composed-feature" }
100
+ },
101
+ "game_version": { "$ref": "../defs/game-version-ref.schema.json" }
102
+ },
103
+ "required": ["id", "name", "kind", "footprint", "game_version"],
104
+ "additionalProperties": false
105
+ }
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "simple-condition": {
13
13
  "type": "object",
14
- "$comment": "Board/meta-state and scoring predicates. `parameters` is intentionally open (additionalProperties: true); each type documents its own param convention. Scoring predicates added for mission cards: `units-destroyed` { side: 'enemy'|'friendly', window: 'this-turn'|'previous-turn', count_min: int } — at least count_min units of `side` were destroyed in `window`. `units-destroyed-comparison` { subject: {side, window}, comparator: 'greater-than'|'greater-or-equal', reference: {side, window} } — compares two destruction tallies (e.g. more enemy units destroyed this turn than friendly last turn). `objective-majority` { relative_to: 'opponent' } — you control more objectives than the named party. `controls-objective` params: { count_min: int, objective_role?: 'central', exclude?: 'home', objective?: 'opponent-home' }.",
14
+ "$comment": "Board/meta-state and scoring predicates. `parameters` is intentionally open (additionalProperties: true); each type documents its own param convention. Scoring predicates added for mission cards: `units-destroyed` { side: 'enemy'|'friendly', window: 'this-turn'|'previous-turn', count_min: int } — at least count_min units of `side` were destroyed in `window`. `units-destroyed-comparison` { subject: {side, window}, comparator: 'greater-than'|'greater-or-equal', reference: {side, window} } — compares two destruction tallies (e.g. more enemy units destroyed this turn than friendly last turn). `objective-majority` { relative_to: 'opponent' } — you control more objectives than the named party. `controls-objective` params: { count_min: int, objective_role?: 'central'|'expansion'|'non-home'|'home', exclude?: 'home', objective?: 'opponent-home'|'your-home', scope?: 'enemy-territory'|'your-territory' }. Mission-card extensions (11e primary deck): `action-completed` { action_id?: string, target_kind?: 'objective'|'terrain'|'enemy-unit'|'self', target_filter?: { in_enemy_territory?: bool, objective_role?: 'central'|'non-home', exclude?: 'home' }, count_min: int, window?: 'this-turn'|'previous-turn'|'cumulative' } — at least count_min instances of a named action were completed in the window. `objective-has-tag` { tag: 'baited'|'triangulated'|'consecrated'|'sabotaged'|'marked'|'vanguard'|'spotted', count_min: int, count_max?: int, objective?: 'opponent-home'|'your-home', scope?: 'enemy-territory'|'your-territory' } — at least count_min objectives carry the named transient tag. `unit-has-tag` { tag: 'doomed'|'spotted', side: 'enemy'|'friendly', count_min: int, window?: 'destroyed-this-turn'|'still-on-board' } — at least count_min units of `side` carry the tag (optionally with a destruction filter — Punishment scores when a Doomed unit was destroyed or left the battlefield). `terrain-has-tag` { tag: 'mined'|'marked'|'vanguard', friendly_units_min?: int, enemy_units_max?: int, last_marked?: bool, in_enemy_dz?: bool } — terrain piece state predicate; `last_marked` selects the most-recently-marked piece (Find and Deny / Recover the Relics' Overwhelming Force trigger). `new-objective-controlled` { count_min: int } — at least count_min objectives are controlled this turn that were not controlled in the previous command phase. `engagement-fronts` { count_min: int } — friendly units engage enemies in at least count_min distinct fronts; a 'front' is one of the territory zones from the deployment-pattern's `territories[]`, so this composes with the existing `territory-control` predicate. `destroyed-while-on-objective` { destroyer_on_objective?: bool, victim_on_objective?: bool, count_min: int } — count_min enemy units were destroyed this turn under the named spatial condition (the destroying friendly unit, the destroyed enemy unit, or both were standing on an objective at the moment of the kill).",
15
15
  "properties": {
16
16
  "type": {
17
17
  "type": "string",
@@ -27,7 +27,10 @@
27
27
  "destroyed-by-attack-type", "controls-objective", "is-attached",
28
28
  "terrain-area-control", "engagement-state", "territory-control",
29
29
  "fights-first", "disposition-matches",
30
- "units-destroyed", "units-destroyed-comparison", "objective-majority"
30
+ "units-destroyed", "units-destroyed-comparison", "objective-majority",
31
+ "action-completed", "objective-has-tag", "unit-has-tag",
32
+ "terrain-has-tag", "new-objective-controlled",
33
+ "engagement-fronts", "destroyed-while-on-objective"
31
34
  ]
32
35
  },
33
36
  "parameters": { "type": "object", "additionalProperties": true },
@@ -30,6 +30,7 @@
30
30
  "model-destruction", "resurrection",
31
31
  "resource-gain", "resource-spend",
32
32
  "charge-roll-modifier", "terrain-area-tag",
33
+ "objective-tag", "unit-tag",
33
34
  "bs-modifier", "engagement-passthrough"
34
35
  ]
35
36
  },
@@ -45,7 +46,7 @@
45
46
  "modifier": { "type": "object", "additionalProperties": true }
46
47
  },
47
48
  "required": ["type", "target"],
48
- "$comment": "When `type` is `re-roll`, `modifier` must carry `roll` (string) and `subset` (`ones` | `all-failures`). Rerolls always target failures; the subset decides whether only 1s are rerolled or every failed die. The constraint is enforced by AJV at validation time and stripped from the codegen bundle (typify can't model if/then/else) — the generated TS/Rust types therefore see `modifier` as an open object, matching its other-`type` callers. When `type` is `feel-no-pain`, `modifier` carries `threshold` (the FNP save target) and optionally `scope` ∈ {`all`, `mortal`}; an absent scope defaults to `all` (fires on every unsaved wound). The two scopes compose independently against the mortal-wound stream.",
49
+ "$comment": "When `type` is `re-roll`, `modifier` must carry `roll` (string) and `subset` (`ones` | `all-failures`). Rerolls always target failures; the subset decides whether only 1s are rerolled or every failed die. The constraint is enforced by AJV at validation time and stripped from the codegen bundle (typify can't model if/then/else) — the generated TS/Rust types therefore see `modifier` as an open object, matching its other-`type` callers. When `type` is `feel-no-pain`, `modifier` carries `threshold` (the FNP save target) and optionally `scope` ∈ {`all`, `mortal`}; an absent scope defaults to `all` (fires on every unsaved wound). The two scopes compose independently against the mortal-wound stream. Tag effects (`terrain-area-tag`, `objective-tag`, `unit-tag`) set a transient marker on the named subject; `modifier` carries `tag` (string) and optionally `source` ('this-action'|'destroying-unit') and `clears_on` ('turn-rollover'|'never'). `target` for tag effects names the kind of entity the tag is applied to ('unit', 'self') — a placeholder, since the marker target is the objective/terrain/unit specified by the action context, not a combat target.",
49
50
  "allOf": [
50
51
  {
51
52
  "if": {