@alpaca-software/40kdc-data 0.3.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 (51) hide show
  1. package/dist/codegen-data.js +2 -0
  2. package/dist/codegen-data.js.map +1 -1
  3. package/dist/data/bundle.generated.js +1 -1
  4. package/dist/data/bundle.generated.js.map +1 -1
  5. package/dist/data/dataset.d.ts +16 -1
  6. package/dist/data/dataset.d.ts.map +1 -1
  7. package/dist/data/dataset.js +25 -0
  8. package/dist/data/dataset.js.map +1 -1
  9. package/dist/data/index.d.ts +4 -0
  10. package/dist/data/index.d.ts.map +1 -1
  11. package/dist/data/index.js +4 -0
  12. package/dist/data/index.js.map +1 -1
  13. package/dist/data/types.d.ts +5 -1
  14. package/dist/data/types.d.ts.map +1 -1
  15. package/dist/data/types.js +2 -0
  16. package/dist/data/types.js.map +1 -1
  17. package/dist/gen-conformance.js +158 -0
  18. package/dist/gen-conformance.js.map +1 -1
  19. package/dist/generated.d.ts +156 -36
  20. package/dist/generated.d.ts.map +1 -1
  21. package/dist/generated.js.map +1 -1
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/migrate-terrain.d.ts +2 -0
  27. package/dist/migrate-terrain.d.ts.map +1 -0
  28. package/dist/migrate-terrain.js +297 -0
  29. package/dist/migrate-terrain.js.map +1 -0
  30. package/dist/runner.d.ts.map +1 -1
  31. package/dist/runner.js +26 -0
  32. package/dist/runner.js.map +1 -1
  33. package/dist/terrain/index.d.ts +11 -0
  34. package/dist/terrain/index.d.ts.map +1 -0
  35. package/dist/terrain/index.js +9 -0
  36. package/dist/terrain/index.js.map +1 -0
  37. package/dist/terrain/resolve.d.ts +122 -0
  38. package/dist/terrain/resolve.d.ts.map +1 -0
  39. package/dist/terrain/resolve.js +221 -0
  40. package/dist/terrain/resolve.js.map +1 -0
  41. package/dist/terrain/solve.d.ts +56 -0
  42. package/dist/terrain/solve.d.ts.map +1 -0
  43. package/dist/terrain/solve.js +80 -0
  44. package/dist/terrain/solve.js.map +1 -0
  45. package/dist/validate.d.ts.map +1 -1
  46. package/dist/validate.js +1 -0
  47. package/dist/validate.js.map +1 -1
  48. package/package.json +2 -1
  49. package/schemas/$defs/common.schema.json +43 -0
  50. package/schemas/core/terrain-layout.schema.json +42 -56
  51. package/schemas/core/terrain-template.schema.json +105 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Terrain layout resolver — turns a {@link TerrainLayout} (template references +
3
+ * centroid-anchored placements + rotation/mirror) into absolute board-space
4
+ * polygon vertices. This is the shared geometry contract pinned by the
5
+ * `conformance/terrain-resolver` corpus; the Rust crate implements the same
6
+ * function and must reproduce these vertices byte-for-byte (4-dp rounded).
7
+ *
8
+ * ## Transform contract
9
+ *
10
+ * Frames are board inches, origin at a board corner, **y-down** (per
11
+ * `common.schema.json#/$defs/vec2`). A footprint is authored in natural local
12
+ * y-down coordinates; the resolver derives its **polygon area centroid** and
13
+ * treats local vertices as `(v - centroid)`, so `position` always denotes the
14
+ * centroid and is invariant under rotation and mirror.
15
+ *
16
+ * Local → board, for an unparented piece, is `mirror → rotate → translate`:
17
+ *
18
+ * board = position + R_cw(rotation) · M(mirror) · (v - centroid)
19
+ *
20
+ * with `M`: horizontal → (-x, y), vertical → (x, -y); and `R_cw(θ)` a clockwise
21
+ * rotation in the y-down frame, `[[cosθ, -sinθ], [sinθ, cosθ]]`.
22
+ *
23
+ * A feature with a `parent_area_id` (or a template's composed feature) is first
24
+ * placed in the parent area's **centroid-local frame** (origin at the area
25
+ * centroid), then carried through the area's own placement:
26
+ *
27
+ * board = T_area ∘ R_area ∘ M_area ( featurePos + R_feat · M_feat · (w - C_feat) )
28
+ *
29
+ * ## Emission order (a pinned invariant)
30
+ *
31
+ * Pieces are emitted in `layout.pieces` order. When a piece instances an area
32
+ * template that carries composed `features`, those features are emitted
33
+ * immediately after their area, in template-declaration order.
34
+ */
35
+ const DEG = Math.PI / 180;
36
+ /** A footprint's polygon vertices in natural local (y-down) coordinates. */
37
+ export function footprintVertices(fp) {
38
+ switch (fp.type) {
39
+ case "rectangle":
40
+ return [
41
+ { x: 0, y: 0 },
42
+ { x: fp.width, y: 0 },
43
+ { x: fp.width, y: fp.height },
44
+ { x: 0, y: fp.height },
45
+ ];
46
+ case "right-triangle":
47
+ // Right angle at the local origin, legs along +x and +y.
48
+ return [
49
+ { x: 0, y: 0 },
50
+ { x: fp.width, y: 0 },
51
+ { x: 0, y: fp.height },
52
+ ];
53
+ case "polygon":
54
+ return fp.points.map((p) => ({ x: p.x, y: p.y }));
55
+ default: {
56
+ const exhaustive = fp;
57
+ throw new Error(`unknown footprint type: ${JSON.stringify(exhaustive)}`);
58
+ }
59
+ }
60
+ }
61
+ /**
62
+ * Polygon area centroid (shoelace). Falls back to the vertex mean when the
63
+ * polygon is degenerate (zero signed area, e.g. collinear points) so the
64
+ * resolver never divides by zero.
65
+ */
66
+ export function polygonCentroid(verts) {
67
+ const n = verts.length;
68
+ if (n === 0)
69
+ return { x: 0, y: 0 };
70
+ let twiceArea = 0;
71
+ let cx = 0;
72
+ let cy = 0;
73
+ for (let i = 0; i < n; i++) {
74
+ const a = verts[i];
75
+ const b = verts[(i + 1) % n];
76
+ const cross = a.x * b.y - b.x * a.y;
77
+ twiceArea += cross;
78
+ cx += (a.x + b.x) * cross;
79
+ cy += (a.y + b.y) * cross;
80
+ }
81
+ if (twiceArea === 0) {
82
+ const mean = verts.reduce((acc, v) => ({ x: acc.x + v.x, y: acc.y + v.y }), { x: 0, y: 0 });
83
+ return { x: mean.x / n, y: mean.y / n };
84
+ }
85
+ return { x: cx / (3 * twiceArea), y: cy / (3 * twiceArea) };
86
+ }
87
+ function applyMirror(v, m) {
88
+ switch (m) {
89
+ case "horizontal":
90
+ return { x: -v.x, y: v.y };
91
+ case "vertical":
92
+ return { x: v.x, y: -v.y };
93
+ default:
94
+ return v;
95
+ }
96
+ }
97
+ /** Clockwise rotation by `deg` degrees in the y-down frame. */
98
+ function rotateCw(v, deg) {
99
+ if (deg === 0)
100
+ return { x: v.x, y: v.y };
101
+ const r = deg * DEG;
102
+ const c = Math.cos(r);
103
+ const s = Math.sin(r);
104
+ return { x: c * v.x - s * v.y, y: s * v.x + c * v.y };
105
+ }
106
+ /** mirror → rotate (no translation). The orientation-only part of a placement. */
107
+ function orient(v, rotation, mirror) {
108
+ return rotateCw(applyMirror(v, mirror), rotation);
109
+ }
110
+ /**
111
+ * The board-space offset of each footprint vertex from the piece centroid,
112
+ * after mirror + rotation but before translation. Adding `position` to each
113
+ * gives the resolved board vertices; this is the orientation-only part a
114
+ * card-measurement solver inverts to recover the centroid. Vertex order matches
115
+ * {@link footprintVertices}.
116
+ */
117
+ export function orientedOffsets(footprint, rotation, mirror) {
118
+ const verts = footprintVertices(footprint);
119
+ const c = polygonCentroid(verts);
120
+ return verts.map((v) => orient({ x: v.x - c.x, y: v.y - c.y }, rotation, mirror));
121
+ }
122
+ /**
123
+ * Place a footprint's local vertices into a target frame: recenter on the
124
+ * footprint centroid, mirror, rotate, then translate so the centroid lands on
125
+ * `position`. The target frame is board space for an area, or the parent area's
126
+ * centroid-local frame for a composed/parented feature.
127
+ */
128
+ function placeFootprint(fp, position, rotation, mirror) {
129
+ const verts = footprintVertices(fp);
130
+ const c = polygonCentroid(verts);
131
+ return verts.map((v) => {
132
+ const o = orient({ x: v.x - c.x, y: v.y - c.y }, rotation, mirror);
133
+ return { x: o.x + position.x, y: o.y + position.y };
134
+ });
135
+ }
136
+ const TWO_DP_ROUND = 1e4;
137
+ function round4(v) {
138
+ return { x: Math.round(v.x * TWO_DP_ROUND) / TWO_DP_ROUND, y: Math.round(v.y * TWO_DP_ROUND) / TWO_DP_ROUND };
139
+ }
140
+ function resolvedIdName(piece) {
141
+ return { id: piece.id ?? null, name: piece.name ?? null };
142
+ }
143
+ export class TerrainResolveError extends Error {
144
+ }
145
+ /**
146
+ * Resolve a layout to absolute board-space vertices per piece. `templates` is
147
+ * the catalog a piece's `template` references resolve against.
148
+ */
149
+ export function resolveLayout(layout, templates) {
150
+ const byId = new Map();
151
+ for (const t of templates)
152
+ byId.set(t.id, t);
153
+ const pieces = layout.pieces ?? [];
154
+ const areasById = new Map();
155
+ for (const p of pieces)
156
+ if (p.id)
157
+ areasById.set(p.id, p);
158
+ const footprintOf = (piece, where) => {
159
+ if (piece.footprint)
160
+ return piece.footprint;
161
+ if (piece.template) {
162
+ const t = byId.get(piece.template);
163
+ if (!t)
164
+ throw new TerrainResolveError(`${where}: unknown template "${piece.template}"`);
165
+ return t.footprint;
166
+ }
167
+ throw new TerrainResolveError(`${where}: piece has neither footprint nor template`);
168
+ };
169
+ const out = [];
170
+ for (const piece of pieces) {
171
+ const where = piece.id ?? piece.name ?? "<piece>";
172
+ const fp = footprintOf(piece, where);
173
+ const rotation = piece.rotation_degrees ?? 0;
174
+ const mirror = piece.mirror ?? "none";
175
+ const pieceType = piece.piece_type ?? (piece.parent_area_id ? "feature" : "area");
176
+ if (piece.parent_area_id) {
177
+ // Feature placed in its parent area's centroid-local frame.
178
+ const parent = areasById.get(piece.parent_area_id);
179
+ if (!parent) {
180
+ throw new TerrainResolveError(`${where}: unknown parent_area_id "${piece.parent_area_id}"`);
181
+ }
182
+ const areaLocal = placeFootprint(fp, piece.position, rotation, mirror);
183
+ const aRot = parent.rotation_degrees ?? 0;
184
+ const aMirror = parent.mirror ?? "none";
185
+ const vertices = areaLocal.map((p) => {
186
+ const o = orient(p, aRot, aMirror);
187
+ return round4({ x: o.x + parent.position.x, y: o.y + parent.position.y });
188
+ });
189
+ out.push({ ...resolvedIdName(piece), piece_type: pieceType, floor: piece.floor ?? 0, vertices });
190
+ continue;
191
+ }
192
+ // Unparented area or feature: place directly in board space.
193
+ const vertices = placeFootprint(fp, piece.position, rotation, mirror).map(round4);
194
+ out.push({ ...resolvedIdName(piece), piece_type: pieceType, floor: piece.floor ?? 0, vertices });
195
+ // Expand an area template's composed features, carried through this area's
196
+ // placement (same composition math as a parented feature).
197
+ if (piece.template) {
198
+ const t = byId.get(piece.template);
199
+ for (const feat of t?.features ?? []) {
200
+ const ft = byId.get(feat.template);
201
+ if (!ft) {
202
+ throw new TerrainResolveError(`${where}: composed feature references unknown template "${feat.template}"`);
203
+ }
204
+ const areaLocal = placeFootprint(ft.footprint, feat.position, feat.rotation_degrees ?? 0, feat.mirror ?? "none");
205
+ const featVerts = areaLocal.map((p) => {
206
+ const o = orient(p, rotation, mirror);
207
+ return round4({ x: o.x + piece.position.x, y: o.y + piece.position.y });
208
+ });
209
+ out.push({
210
+ id: feat.id ?? null,
211
+ name: ft.name ?? null,
212
+ piece_type: "feature",
213
+ floor: feat.floor ?? 0,
214
+ vertices: featVerts,
215
+ });
216
+ }
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+ //# sourceMappingURL=resolve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../src/terrain/resolve.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Terrain layout resolver — turns a {@link TerrainLayout} (template references +\n * centroid-anchored placements + rotation/mirror) into absolute board-space\n * polygon vertices. This is the shared geometry contract pinned by the\n * `conformance/terrain-resolver` corpus; the Rust crate implements the same\n * function and must reproduce these vertices byte-for-byte (4-dp rounded).\n *\n * ## Transform contract\n *\n * Frames are board inches, origin at a board corner, **y-down** (per\n * `common.schema.json#/$defs/vec2`). A footprint is authored in natural local\n * y-down coordinates; the resolver derives its **polygon area centroid** and\n * treats local vertices as `(v - centroid)`, so `position` always denotes the\n * centroid and is invariant under rotation and mirror.\n *\n * Local → board, for an unparented piece, is `mirror → rotate → translate`:\n *\n * board = position + R_cw(rotation) · M(mirror) · (v - centroid)\n *\n * with `M`: horizontal → (-x, y), vertical → (x, -y); and `R_cw(θ)` a clockwise\n * rotation in the y-down frame, `[[cosθ, -sinθ], [sinθ, cosθ]]`.\n *\n * A feature with a `parent_area_id` (or a template's composed feature) is first\n * placed in the parent area's **centroid-local frame** (origin at the area\n * centroid), then carried through the area's own placement:\n *\n * board = T_area ∘ R_area ∘ M_area ( featurePos + R_feat · M_feat · (w - C_feat) )\n *\n * ## Emission order (a pinned invariant)\n *\n * Pieces are emitted in `layout.pieces` order. When a piece instances an area\n * template that carries composed `features`, those features are emitted\n * immediately after their area, in template-declaration order.\n */\n\nexport interface Vec2 {\n x: number;\n y: number;\n}\n\nexport type Footprint =\n | { type: \"rectangle\"; width: number; height: number }\n | { type: \"right-triangle\"; width: number; height: number }\n | { type: \"polygon\"; points: Vec2[] };\n\nexport type Mirror = \"none\" | \"horizontal\" | \"vertical\";\n\nexport interface ComposedFeature {\n id?: string;\n template: string;\n position: Vec2;\n rotation_degrees?: number;\n mirror?: Mirror;\n floor?: number;\n}\n\nexport interface TerrainTemplate {\n id: string;\n name?: string;\n kind: \"area\" | \"feature\";\n footprint: Footprint;\n default_height_inches?: number;\n default_blocking?: boolean;\n default_terrain_area_keywords?: string[];\n features?: ComposedFeature[];\n}\n\nexport interface LayoutPiece {\n id?: string;\n name?: string;\n piece_type?: \"area\" | \"feature\";\n template?: string;\n footprint?: Footprint;\n position: Vec2;\n rotation_degrees?: number;\n mirror?: Mirror;\n parent_area_id?: string;\n floor?: number;\n height_inches?: number;\n terrain_area_keywords?: string[];\n link_group?: string;\n}\n\nexport interface TerrainLayout {\n id: string;\n name: string;\n pieces?: LayoutPiece[];\n}\n\nexport interface ResolvedPiece {\n /** Layout-local id when present, else the piece name, else null. */\n id: string | null;\n name: string | null;\n piece_type: \"area\" | \"feature\";\n floor: number;\n /** Absolute board-space polygon vertices, y-down. */\n vertices: Vec2[];\n}\n\nconst DEG = Math.PI / 180;\n\n/** A footprint's polygon vertices in natural local (y-down) coordinates. */\nexport function footprintVertices(fp: Footprint): Vec2[] {\n switch (fp.type) {\n case \"rectangle\":\n return [\n { x: 0, y: 0 },\n { x: fp.width, y: 0 },\n { x: fp.width, y: fp.height },\n { x: 0, y: fp.height },\n ];\n case \"right-triangle\":\n // Right angle at the local origin, legs along +x and +y.\n return [\n { x: 0, y: 0 },\n { x: fp.width, y: 0 },\n { x: 0, y: fp.height },\n ];\n case \"polygon\":\n return fp.points.map((p) => ({ x: p.x, y: p.y }));\n default: {\n const exhaustive: never = fp;\n throw new Error(`unknown footprint type: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n/**\n * Polygon area centroid (shoelace). Falls back to the vertex mean when the\n * polygon is degenerate (zero signed area, e.g. collinear points) so the\n * resolver never divides by zero.\n */\nexport function polygonCentroid(verts: Vec2[]): Vec2 {\n const n = verts.length;\n if (n === 0) return { x: 0, y: 0 };\n let twiceArea = 0;\n let cx = 0;\n let cy = 0;\n for (let i = 0; i < n; i++) {\n const a = verts[i];\n const b = verts[(i + 1) % n];\n const cross = a.x * b.y - b.x * a.y;\n twiceArea += cross;\n cx += (a.x + b.x) * cross;\n cy += (a.y + b.y) * cross;\n }\n if (twiceArea === 0) {\n const mean = verts.reduce((acc, v) => ({ x: acc.x + v.x, y: acc.y + v.y }), { x: 0, y: 0 });\n return { x: mean.x / n, y: mean.y / n };\n }\n return { x: cx / (3 * twiceArea), y: cy / (3 * twiceArea) };\n}\n\nfunction applyMirror(v: Vec2, m: Mirror): Vec2 {\n switch (m) {\n case \"horizontal\":\n return { x: -v.x, y: v.y };\n case \"vertical\":\n return { x: v.x, y: -v.y };\n default:\n return v;\n }\n}\n\n/** Clockwise rotation by `deg` degrees in the y-down frame. */\nfunction rotateCw(v: Vec2, deg: number): Vec2 {\n if (deg === 0) return { x: v.x, y: v.y };\n const r = deg * DEG;\n const c = Math.cos(r);\n const s = Math.sin(r);\n return { x: c * v.x - s * v.y, y: s * v.x + c * v.y };\n}\n\n/** mirror → rotate (no translation). The orientation-only part of a placement. */\nfunction orient(v: Vec2, rotation: number, mirror: Mirror): Vec2 {\n return rotateCw(applyMirror(v, mirror), rotation);\n}\n\n/**\n * The board-space offset of each footprint vertex from the piece centroid,\n * after mirror + rotation but before translation. Adding `position` to each\n * gives the resolved board vertices; this is the orientation-only part a\n * card-measurement solver inverts to recover the centroid. Vertex order matches\n * {@link footprintVertices}.\n */\nexport function orientedOffsets(footprint: Footprint, rotation: number, mirror: Mirror): Vec2[] {\n const verts = footprintVertices(footprint);\n const c = polygonCentroid(verts);\n return verts.map((v) => orient({ x: v.x - c.x, y: v.y - c.y }, rotation, mirror));\n}\n\n/**\n * Place a footprint's local vertices into a target frame: recenter on the\n * footprint centroid, mirror, rotate, then translate so the centroid lands on\n * `position`. The target frame is board space for an area, or the parent area's\n * centroid-local frame for a composed/parented feature.\n */\nfunction placeFootprint(\n fp: Footprint,\n position: Vec2,\n rotation: number,\n mirror: Mirror,\n): Vec2[] {\n const verts = footprintVertices(fp);\n const c = polygonCentroid(verts);\n return verts.map((v) => {\n const o = orient({ x: v.x - c.x, y: v.y - c.y }, rotation, mirror);\n return { x: o.x + position.x, y: o.y + position.y };\n });\n}\n\nconst TWO_DP_ROUND = 1e4;\nfunction round4(v: Vec2): Vec2 {\n return { x: Math.round(v.x * TWO_DP_ROUND) / TWO_DP_ROUND, y: Math.round(v.y * TWO_DP_ROUND) / TWO_DP_ROUND };\n}\n\nfunction resolvedIdName(piece: { id?: string; name?: string }): { id: string | null; name: string | null } {\n return { id: piece.id ?? null, name: piece.name ?? null };\n}\n\nexport class TerrainResolveError extends Error {}\n\n/**\n * Resolve a layout to absolute board-space vertices per piece. `templates` is\n * the catalog a piece's `template` references resolve against.\n */\nexport function resolveLayout(layout: TerrainLayout, templates: TerrainTemplate[]): ResolvedPiece[] {\n const byId = new Map<string, TerrainTemplate>();\n for (const t of templates) byId.set(t.id, t);\n\n const pieces = layout.pieces ?? [];\n const areasById = new Map<string, LayoutPiece>();\n for (const p of pieces) if (p.id) areasById.set(p.id, p);\n\n const footprintOf = (piece: { template?: string; footprint?: Footprint }, where: string): Footprint => {\n if (piece.footprint) return piece.footprint;\n if (piece.template) {\n const t = byId.get(piece.template);\n if (!t) throw new TerrainResolveError(`${where}: unknown template \"${piece.template}\"`);\n return t.footprint;\n }\n throw new TerrainResolveError(`${where}: piece has neither footprint nor template`);\n };\n\n const out: ResolvedPiece[] = [];\n\n for (const piece of pieces) {\n const where = piece.id ?? piece.name ?? \"<piece>\";\n const fp = footprintOf(piece, where);\n const rotation = piece.rotation_degrees ?? 0;\n const mirror = piece.mirror ?? \"none\";\n const pieceType = piece.piece_type ?? (piece.parent_area_id ? \"feature\" : \"area\");\n\n if (piece.parent_area_id) {\n // Feature placed in its parent area's centroid-local frame.\n const parent = areasById.get(piece.parent_area_id);\n if (!parent) {\n throw new TerrainResolveError(`${where}: unknown parent_area_id \"${piece.parent_area_id}\"`);\n }\n const areaLocal = placeFootprint(fp, piece.position, rotation, mirror);\n const aRot = parent.rotation_degrees ?? 0;\n const aMirror = parent.mirror ?? \"none\";\n const vertices = areaLocal.map((p) => {\n const o = orient(p, aRot, aMirror);\n return round4({ x: o.x + parent.position.x, y: o.y + parent.position.y });\n });\n out.push({ ...resolvedIdName(piece), piece_type: pieceType, floor: piece.floor ?? 0, vertices });\n continue;\n }\n\n // Unparented area or feature: place directly in board space.\n const vertices = placeFootprint(fp, piece.position, rotation, mirror).map(round4);\n out.push({ ...resolvedIdName(piece), piece_type: pieceType, floor: piece.floor ?? 0, vertices });\n\n // Expand an area template's composed features, carried through this area's\n // placement (same composition math as a parented feature).\n if (piece.template) {\n const t = byId.get(piece.template);\n for (const feat of t?.features ?? []) {\n const ft = byId.get(feat.template);\n if (!ft) {\n throw new TerrainResolveError(`${where}: composed feature references unknown template \"${feat.template}\"`);\n }\n const areaLocal = placeFootprint(\n ft.footprint,\n feat.position,\n feat.rotation_degrees ?? 0,\n feat.mirror ?? \"none\",\n );\n const featVerts = areaLocal.map((p) => {\n const o = orient(p, rotation, mirror);\n return round4({ x: o.x + piece.position.x, y: o.y + piece.position.y });\n });\n out.push({\n id: feat.id ?? null,\n name: ft.name ?? null,\n piece_type: \"feature\",\n floor: feat.floor ?? 0,\n vertices: featVerts,\n });\n }\n }\n }\n\n return out;\n}\n"]}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Card-measurement centroid solver — the inverse of the resolver's placement.
3
+ *
4
+ * Reference cards locate a terrain area by dimension lines: "this feature of the
5
+ * area is D inches from a board edge". The feature referenced varies per card
6
+ * and per piece, which is exactly why a single canonical anchor (the centroid)
7
+ * is hard to read off a card directly. This solver lets a user transcribe the
8
+ * card verbatim — pick the template, set the orientation shown, then enter one
9
+ * horizontal and one vertical dimension line against whatever feature the card
10
+ * happens to draw — and back-solves the centroid `position` the schema stores.
11
+ *
12
+ * Because the centroid is rotation- and mirror-invariant, orientation is fixed
13
+ * first; each dimension line then pins one axis of the centroid in closed form.
14
+ */
15
+ import { type Footprint, type Mirror, type Vec2 } from "./resolve.js";
16
+ /** A board edge a card dimension is measured from. left/right pin x; top/bottom pin y. */
17
+ export type BoardEdge = "left" | "right" | "top" | "bottom";
18
+ /**
19
+ * Which feature of the placed area a dimension line reaches: a specific
20
+ * footprint vertex (by index, in {@link footprintVertices} order), or one of
21
+ * the placed area's axis-aligned bounding faces ("the left face", etc.).
22
+ */
23
+ export type FeatureRef = {
24
+ kind: "vertex";
25
+ index: number;
26
+ } | {
27
+ kind: "face";
28
+ side: "min-x" | "max-x" | "min-y" | "max-y";
29
+ };
30
+ /** One card dimension line: `distance` inches from `edge` to `feature`. */
31
+ export interface DimensionLine {
32
+ edge: BoardEdge;
33
+ distance: number;
34
+ feature: FeatureRef;
35
+ }
36
+ export interface SolveInput {
37
+ footprint: Footprint;
38
+ rotation: number;
39
+ mirror: Mirror;
40
+ /** Board extents in inches (40kdc standard is 60 × 44). */
41
+ board: {
42
+ width: number;
43
+ height: number;
44
+ };
45
+ /** Two perpendicular dimension lines: exactly one must pin x, one must pin y. */
46
+ lines: [DimensionLine, DimensionLine];
47
+ }
48
+ export declare class TerrainSolveError extends Error {
49
+ }
50
+ /**
51
+ * Back-solve the centroid `position` from a template, its orientation, and two
52
+ * perpendicular card dimension lines. Closed form — one x-line and one y-line
53
+ * pin the two unknowns directly.
54
+ */
55
+ export declare function solveCentroid(input: SolveInput): Vec2;
56
+ //# sourceMappingURL=solve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"solve.d.ts","sourceRoot":"","sources":["../../src/terrain/solve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,KAAK,IAAI,EAAE,MAAM,cAAc,CAAC;AAEvF,0FAA0F;AAC1F,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,QAAQ,CAAC;AAE5D;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAA;CAAE,CAAC;AAElE,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,SAAS,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,iFAAiF;IACjF,KAAK,EAAE,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;CACvC;AAED,qBAAa,iBAAkB,SAAQ,KAAK;CAAG;AAkD/C;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAYrD"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Card-measurement centroid solver — the inverse of the resolver's placement.
3
+ *
4
+ * Reference cards locate a terrain area by dimension lines: "this feature of the
5
+ * area is D inches from a board edge". The feature referenced varies per card
6
+ * and per piece, which is exactly why a single canonical anchor (the centroid)
7
+ * is hard to read off a card directly. This solver lets a user transcribe the
8
+ * card verbatim — pick the template, set the orientation shown, then enter one
9
+ * horizontal and one vertical dimension line against whatever feature the card
10
+ * happens to draw — and back-solves the centroid `position` the schema stores.
11
+ *
12
+ * Because the centroid is rotation- and mirror-invariant, orientation is fixed
13
+ * first; each dimension line then pins one axis of the centroid in closed form.
14
+ */
15
+ import { orientedOffsets } from "./resolve.js";
16
+ export class TerrainSolveError extends Error {
17
+ }
18
+ /** The signed offset (from the centroid) the given feature resolves to, on its axis. */
19
+ function featureOffset(offsets, feature, axis) {
20
+ if (feature.kind === "vertex") {
21
+ const o = offsets[feature.index];
22
+ if (!o)
23
+ throw new TerrainSolveError(`vertex index ${feature.index} out of range`);
24
+ return axis === "x" ? o.x : o.y;
25
+ }
26
+ const xs = offsets.map((o) => o.x);
27
+ const ys = offsets.map((o) => o.y);
28
+ switch (feature.side) {
29
+ case "min-x":
30
+ return Math.min(...xs);
31
+ case "max-x":
32
+ return Math.max(...xs);
33
+ case "min-y":
34
+ return Math.min(...ys);
35
+ case "max-y":
36
+ return Math.max(...ys);
37
+ }
38
+ }
39
+ function axisOfEdge(edge) {
40
+ return edge === "left" || edge === "right" ? "x" : "y";
41
+ }
42
+ /** Solve one axis of the centroid from a single dimension line. */
43
+ function solveAxis(line, offsets, board) {
44
+ const axis = axisOfEdge(line.edge);
45
+ const o = featureOffset(offsets, line.feature, axis);
46
+ // edge → centroid: near-side edges measure from 0; far-side from the extent.
47
+ let value;
48
+ switch (line.edge) {
49
+ case "left":
50
+ value = line.distance - o;
51
+ break;
52
+ case "right":
53
+ value = board.width - line.distance - o;
54
+ break;
55
+ case "top":
56
+ value = line.distance - o;
57
+ break;
58
+ case "bottom":
59
+ value = board.height - line.distance - o;
60
+ break;
61
+ }
62
+ return { axis, value };
63
+ }
64
+ /**
65
+ * Back-solve the centroid `position` from a template, its orientation, and two
66
+ * perpendicular card dimension lines. Closed form — one x-line and one y-line
67
+ * pin the two unknowns directly.
68
+ */
69
+ export function solveCentroid(input) {
70
+ const offsets = orientedOffsets(input.footprint, input.rotation, input.mirror);
71
+ const a = solveAxis(input.lines[0], offsets, input.board);
72
+ const b = solveAxis(input.lines[1], offsets, input.board);
73
+ if (a.axis === b.axis) {
74
+ throw new TerrainSolveError("the two dimension lines must pin different axes (one of left/right, one of top/bottom)");
75
+ }
76
+ const x = a.axis === "x" ? a.value : b.value;
77
+ const y = a.axis === "y" ? a.value : b.value;
78
+ return { x, y };
79
+ }
80
+ //# sourceMappingURL=solve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"solve.js","sourceRoot":"","sources":["../../src/terrain/solve.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Card-measurement centroid solver — the inverse of the resolver's placement.\n *\n * Reference cards locate a terrain area by dimension lines: \"this feature of the\n * area is D inches from a board edge\". The feature referenced varies per card\n * and per piece, which is exactly why a single canonical anchor (the centroid)\n * is hard to read off a card directly. This solver lets a user transcribe the\n * card verbatim — pick the template, set the orientation shown, then enter one\n * horizontal and one vertical dimension line against whatever feature the card\n * happens to draw — and back-solves the centroid `position` the schema stores.\n *\n * Because the centroid is rotation- and mirror-invariant, orientation is fixed\n * first; each dimension line then pins one axis of the centroid in closed form.\n */\nimport { orientedOffsets, type Footprint, type Mirror, type Vec2 } from \"./resolve.js\";\n\n/** A board edge a card dimension is measured from. left/right pin x; top/bottom pin y. */\nexport type BoardEdge = \"left\" | \"right\" | \"top\" | \"bottom\";\n\n/**\n * Which feature of the placed area a dimension line reaches: a specific\n * footprint vertex (by index, in {@link footprintVertices} order), or one of\n * the placed area's axis-aligned bounding faces (\"the left face\", etc.).\n */\nexport type FeatureRef =\n | { kind: \"vertex\"; index: number }\n | { kind: \"face\"; side: \"min-x\" | \"max-x\" | \"min-y\" | \"max-y\" };\n\n/** One card dimension line: `distance` inches from `edge` to `feature`. */\nexport interface DimensionLine {\n edge: BoardEdge;\n distance: number;\n feature: FeatureRef;\n}\n\nexport interface SolveInput {\n footprint: Footprint;\n rotation: number;\n mirror: Mirror;\n /** Board extents in inches (40kdc standard is 60 × 44). */\n board: { width: number; height: number };\n /** Two perpendicular dimension lines: exactly one must pin x, one must pin y. */\n lines: [DimensionLine, DimensionLine];\n}\n\nexport class TerrainSolveError extends Error {}\n\n/** The signed offset (from the centroid) the given feature resolves to, on its axis. */\nfunction featureOffset(offsets: Vec2[], feature: FeatureRef, axis: \"x\" | \"y\"): number {\n if (feature.kind === \"vertex\") {\n const o = offsets[feature.index];\n if (!o) throw new TerrainSolveError(`vertex index ${feature.index} out of range`);\n return axis === \"x\" ? o.x : o.y;\n }\n const xs = offsets.map((o) => o.x);\n const ys = offsets.map((o) => o.y);\n switch (feature.side) {\n case \"min-x\":\n return Math.min(...xs);\n case \"max-x\":\n return Math.max(...xs);\n case \"min-y\":\n return Math.min(...ys);\n case \"max-y\":\n return Math.max(...ys);\n }\n}\n\nfunction axisOfEdge(edge: BoardEdge): \"x\" | \"y\" {\n return edge === \"left\" || edge === \"right\" ? \"x\" : \"y\";\n}\n\n/** Solve one axis of the centroid from a single dimension line. */\nfunction solveAxis(line: DimensionLine, offsets: Vec2[], board: { width: number; height: number }): { axis: \"x\" | \"y\"; value: number } {\n const axis = axisOfEdge(line.edge);\n const o = featureOffset(offsets, line.feature, axis);\n // edge → centroid: near-side edges measure from 0; far-side from the extent.\n let value: number;\n switch (line.edge) {\n case \"left\":\n value = line.distance - o;\n break;\n case \"right\":\n value = board.width - line.distance - o;\n break;\n case \"top\":\n value = line.distance - o;\n break;\n case \"bottom\":\n value = board.height - line.distance - o;\n break;\n }\n return { axis, value };\n}\n\n/**\n * Back-solve the centroid `position` from a template, its orientation, and two\n * perpendicular card dimension lines. Closed form — one x-line and one y-line\n * pin the two unknowns directly.\n */\nexport function solveCentroid(input: SolveInput): Vec2 {\n const offsets = orientedOffsets(input.footprint, input.rotation, input.mirror);\n const a = solveAxis(input.lines[0], offsets, input.board);\n const b = solveAxis(input.lines[1], offsets, input.board);\n if (a.axis === b.axis) {\n throw new TerrainSolveError(\n \"the two dimension lines must pin different axes (one of left/right, one of top/bottom)\",\n );\n }\n const x = a.axis === \"x\" ? a.value : b.value;\n const y = a.axis === \"y\" ? a.value : b.value;\n return { x, y };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AAS3B,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AA4CD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,MAAM,EACf,GAAG,CAAC,EAAE,MAAM,GACX,OAAO,CAAC,gBAAgB,CAAC,CA+E3B"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AAS3B,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AA6CD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,MAAM,EACf,GAAG,CAAC,EAAE,MAAM,GACX,OAAO,CAAC,gBAAgB,CAAC,CA+E3B"}
package/dist/validate.js CHANGED
@@ -24,6 +24,7 @@ const SCHEMA_MAP = {
24
24
  "mission-matchups": "https://40kdc.dev/schemas/core/mission-matchup.schema.json",
25
25
  missions: "https://40kdc.dev/schemas/core/mission.schema.json",
26
26
  "secondary-cards": "https://40kdc.dev/schemas/core/secondary-card.schema.json",
27
+ "terrain-templates": "https://40kdc.dev/schemas/core/terrain-template.schema.json",
27
28
  "terrain-layouts": "https://40kdc.dev/schemas/core/terrain-layout.schema.json",
28
29
  "phase-mappings": "https://40kdc.dev/schemas/enrichment/phase-mapping.schema.json",
29
30
  "timing-flags": "https://40kdc.dev/schemas/enrichment/timing-flag.schema.json",
@@ -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.3.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",
@@ -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
  }