@alpaca-software/40kdc-data 0.1.0

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 (155) hide show
  1. package/README.md +78 -0
  2. package/dist/bundle-schemas.d.ts +3 -0
  3. package/dist/bundle-schemas.js +137 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +31 -0
  6. package/dist/codegen-data.d.ts +1 -0
  7. package/dist/codegen-data.js +128 -0
  8. package/dist/commands/translate.d.ts +7 -0
  9. package/dist/commands/translate.js +238 -0
  10. package/dist/commands/validate-all.d.ts +3 -0
  11. package/dist/commands/validate-all.js +20 -0
  12. package/dist/commands/validate-core.d.ts +3 -0
  13. package/dist/commands/validate-core.js +12 -0
  14. package/dist/commands/validate-enrichment.d.ts +3 -0
  15. package/dist/commands/validate-enrichment.js +12 -0
  16. package/dist/convert-faction.d.ts +45 -0
  17. package/dist/convert-faction.js +479 -0
  18. package/dist/converters/configs/adepta-sororitas.d.ts +3 -0
  19. package/dist/converters/configs/adepta-sororitas.js +70 -0
  20. package/dist/converters/configs/adeptus-astartes.d.ts +3 -0
  21. package/dist/converters/configs/adeptus-astartes.js +74 -0
  22. package/dist/converters/configs/adeptus-custodes.d.ts +3 -0
  23. package/dist/converters/configs/adeptus-custodes.js +14 -0
  24. package/dist/converters/configs/adeptus-mechanicus.d.ts +3 -0
  25. package/dist/converters/configs/adeptus-mechanicus.js +51 -0
  26. package/dist/converters/configs/aeldari.d.ts +3 -0
  27. package/dist/converters/configs/aeldari.js +79 -0
  28. package/dist/converters/configs/agents-of-the-imperium.d.ts +3 -0
  29. package/dist/converters/configs/agents-of-the-imperium.js +57 -0
  30. package/dist/converters/configs/astra-militarum.d.ts +3 -0
  31. package/dist/converters/configs/astra-militarum.js +80 -0
  32. package/dist/converters/configs/black-templars.d.ts +3 -0
  33. package/dist/converters/configs/black-templars.js +16 -0
  34. package/dist/converters/configs/blood-angels.d.ts +3 -0
  35. package/dist/converters/configs/blood-angels.js +16 -0
  36. package/dist/converters/configs/chaos-daemons.d.ts +3 -0
  37. package/dist/converters/configs/chaos-daemons.js +40 -0
  38. package/dist/converters/configs/chaos-knights.d.ts +3 -0
  39. package/dist/converters/configs/chaos-knights.js +14 -0
  40. package/dist/converters/configs/chaos-space-marines.d.ts +3 -0
  41. package/dist/converters/configs/chaos-space-marines.js +95 -0
  42. package/dist/converters/configs/crimson-fists.d.ts +3 -0
  43. package/dist/converters/configs/crimson-fists.js +16 -0
  44. package/dist/converters/configs/dark-angels.d.ts +3 -0
  45. package/dist/converters/configs/dark-angels.js +16 -0
  46. package/dist/converters/configs/death-guard.d.ts +3 -0
  47. package/dist/converters/configs/death-guard.js +30 -0
  48. package/dist/converters/configs/deathwatch.d.ts +3 -0
  49. package/dist/converters/configs/deathwatch.js +16 -0
  50. package/dist/converters/configs/drukhari.d.ts +3 -0
  51. package/dist/converters/configs/drukhari.js +51 -0
  52. package/dist/converters/configs/emperors-children.d.ts +3 -0
  53. package/dist/converters/configs/emperors-children.js +38 -0
  54. package/dist/converters/configs/genestealer-cults.d.ts +3 -0
  55. package/dist/converters/configs/genestealer-cults.js +36 -0
  56. package/dist/converters/configs/grey-knights.d.ts +3 -0
  57. package/dist/converters/configs/grey-knights.js +39 -0
  58. package/dist/converters/configs/imperial-fists.d.ts +3 -0
  59. package/dist/converters/configs/imperial-fists.js +16 -0
  60. package/dist/converters/configs/imperial-knights.d.ts +3 -0
  61. package/dist/converters/configs/imperial-knights.js +14 -0
  62. package/dist/converters/configs/iron-hands.d.ts +3 -0
  63. package/dist/converters/configs/iron-hands.js +16 -0
  64. package/dist/converters/configs/leagues-of-votann.d.ts +3 -0
  65. package/dist/converters/configs/leagues-of-votann.js +32 -0
  66. package/dist/converters/configs/necrons.d.ts +3 -0
  67. package/dist/converters/configs/necrons.js +19 -0
  68. package/dist/converters/configs/orks.d.ts +3 -0
  69. package/dist/converters/configs/orks.js +71 -0
  70. package/dist/converters/configs/raven-guard.d.ts +3 -0
  71. package/dist/converters/configs/raven-guard.js +16 -0
  72. package/dist/converters/configs/salamanders.d.ts +3 -0
  73. package/dist/converters/configs/salamanders.js +16 -0
  74. package/dist/converters/configs/space-wolves.d.ts +3 -0
  75. package/dist/converters/configs/space-wolves.js +16 -0
  76. package/dist/converters/configs/tau-empire.d.ts +3 -0
  77. package/dist/converters/configs/tau-empire.js +44 -0
  78. package/dist/converters/configs/thousand-sons.d.ts +3 -0
  79. package/dist/converters/configs/thousand-sons.js +30 -0
  80. package/dist/converters/configs/tyranids.d.ts +3 -0
  81. package/dist/converters/configs/tyranids.js +27 -0
  82. package/dist/converters/configs/ultramarines.d.ts +3 -0
  83. package/dist/converters/configs/ultramarines.js +16 -0
  84. package/dist/converters/configs/white-scars.d.ts +3 -0
  85. package/dist/converters/configs/white-scars.js +16 -0
  86. package/dist/converters/configs/world-eaters.d.ts +3 -0
  87. package/dist/converters/configs/world-eaters.js +43 -0
  88. package/dist/converters/faction-config.d.ts +53 -0
  89. package/dist/converters/faction-config.js +22 -0
  90. package/dist/converters/id-generator.d.ts +14 -0
  91. package/dist/converters/id-generator.js +65 -0
  92. package/dist/converters/keyword-filter.d.ts +26 -0
  93. package/dist/converters/keyword-filter.js +78 -0
  94. package/dist/converters/stat-parser.d.ts +22 -0
  95. package/dist/converters/stat-parser.js +84 -0
  96. package/dist/converters/view-selector.d.ts +54 -0
  97. package/dist/converters/view-selector.js +96 -0
  98. package/dist/converters/weapon-dedup.d.ts +60 -0
  99. package/dist/converters/weapon-dedup.js +120 -0
  100. package/dist/data/bundle.generated.d.ts +3 -0
  101. package/dist/data/bundle.generated.js +3 -0
  102. package/dist/data/collection.d.ts +64 -0
  103. package/dist/data/collection.js +118 -0
  104. package/dist/data/dataset.d.ts +50 -0
  105. package/dist/data/dataset.js +134 -0
  106. package/dist/data/entities.d.ts +80 -0
  107. package/dist/data/entities.js +133 -0
  108. package/dist/data/index.d.ts +59 -0
  109. package/dist/data/index.js +57 -0
  110. package/dist/data/normalize.d.ts +29 -0
  111. package/dist/data/normalize.js +37 -0
  112. package/dist/data/types.d.ts +43 -0
  113. package/dist/data/types.js +25 -0
  114. package/dist/generated.d.ts +1084 -0
  115. package/dist/generated.js +2 -0
  116. package/dist/index.d.ts +3 -0
  117. package/dist/index.js +7 -0
  118. package/dist/known-support-10e.d.ts +31 -0
  119. package/dist/known-support-10e.js +113 -0
  120. package/dist/port-10e-faction.d.ts +52 -0
  121. package/dist/port-10e-faction.js +413 -0
  122. package/dist/report.d.ts +3 -0
  123. package/dist/report.js +31 -0
  124. package/dist/schema-loader.d.ts +15 -0
  125. package/dist/schema-loader.js +79 -0
  126. package/dist/validate.d.ts +21 -0
  127. package/dist/validate.js +124 -0
  128. package/package.json +77 -0
  129. package/schemas/$defs/common.schema.json +86 -0
  130. package/schemas/$defs/game-version-ref.schema.json +11 -0
  131. package/schemas/core/deployment-pattern.schema.json +102 -0
  132. package/schemas/core/detachment.schema.json +56 -0
  133. package/schemas/core/enhancement.schema.json +46 -0
  134. package/schemas/core/faction.schema.json +29 -0
  135. package/schemas/core/force-disposition.schema.json +22 -0
  136. package/schemas/core/game-version.schema.json +20 -0
  137. package/schemas/core/leader-attachment.schema.json +18 -0
  138. package/schemas/core/mission-matchup.schema.json +25 -0
  139. package/schemas/core/mission.schema.json +42 -0
  140. package/schemas/core/roster.schema.json +203 -0
  141. package/schemas/core/secondary-card.schema.json +195 -0
  142. package/schemas/core/stratagem.schema.json +58 -0
  143. package/schemas/core/terrain-layout.schema.json +135 -0
  144. package/schemas/core/unit-composition.schema.json +38 -0
  145. package/schemas/core/unit.schema.json +125 -0
  146. package/schemas/core/wargear-option.schema.json +47 -0
  147. package/schemas/core/weapon.schema.json +56 -0
  148. package/schemas/enrichment/ability-dsl/ability.schema.json +60 -0
  149. package/schemas/enrichment/ability-dsl/condition.schema.json +48 -0
  150. package/schemas/enrichment/ability-dsl/effect.schema.json +145 -0
  151. package/schemas/enrichment/ability-dsl/scope.schema.json +12 -0
  152. package/schemas/enrichment/interaction-flag.schema.json +17 -0
  153. package/schemas/enrichment/phase-mapping.schema.json +14 -0
  154. package/schemas/enrichment/resource-pool.schema.json +36 -0
  155. package/schemas/enrichment/timing-flag.schema.json +28 -0
@@ -0,0 +1,2 @@
1
+ /* Generated from crates/wh40kdc/schemas/bundled.schema.json by 'npm run codegen:types'. DO NOT EDIT BY HAND. */
2
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from "./data/index.js";
2
+ export * from "./generated.js";
3
+ export { createValidator, findSchemaFiles, listSchemaIds, SCHEMAS_ROOT, } from "./schema-loader.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // The linked, typed dataset — the primary entry point.
2
+ export * from "./data/index.js";
3
+ // Generated types for every entity in the dataset.
4
+ export * from "./generated.js";
5
+ // Schema access + AJV validation (secondary: this package also validates data
6
+ // against the canonical JSON Schemas).
7
+ export { createValidator, findSchemaFiles, listSchemaIds, SCHEMAS_ROOT, } from "./schema-loader.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Canonical registry of 10e units carrying the "additional leader" / "second
3
+ * leader" attachment rule — characters that can attach to a unit even when
4
+ * another Leader (e.g. Captain, Chapter Master, Lieutenant) is already
5
+ * attached. In 11e this is formalised as `attachment_role: "support"`.
6
+ *
7
+ * The registry has two layers:
8
+ *
9
+ * 1. **`FROM_UPSTREAM_SCRAPE`** — derived deterministically from army-assist
10
+ * `Datasheets.json` by scanning `leader_head` for the canonical phrasing
11
+ * (/already been attached|additional leader|attach this model.*even if/i)
12
+ * and resolving each datasheet name to our kebab-case unit id via the
13
+ * 10e-archive's `data/core/<faction>/units.json` name table.
14
+ * shadowboxing's `assets/Datasheets.json` yields the same 40 names.
15
+ *
16
+ * 2. **`MANUAL_OVERLAY`** — units whose 10e "additional leader" rule is
17
+ * *not* captured by either upstream scraper (data-gap entries) plus
18
+ * non-character special cases. Each entry needs a one-line comment
19
+ * naming the SME source so the gap is auditable. This layer is the
20
+ * reason 40kdc-data exists as a canonical upstream.
21
+ *
22
+ * The exported `KNOWN_SUPPORT_10E` is the merged view. To refresh layer 1,
23
+ * re-run the scan recipe documented above. To add a missing unit (layer 2),
24
+ * edit `MANUAL_OVERLAY` with a comment justifying the entry.
25
+ *
26
+ * Treat every entry as a **proposal** until human review confirms. The port
27
+ * emits a warning if a registry entry doesn't match an archive unit.
28
+ */
29
+ export declare const KNOWN_SUPPORT_10E: Record<string, readonly string[]>;
30
+ /** Flatten the registry to faction-prefixed ids for set membership tests. */
31
+ export declare function knownSupportSet(): Set<string>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Canonical registry of 10e units carrying the "additional leader" / "second
3
+ * leader" attachment rule — characters that can attach to a unit even when
4
+ * another Leader (e.g. Captain, Chapter Master, Lieutenant) is already
5
+ * attached. In 11e this is formalised as `attachment_role: "support"`.
6
+ *
7
+ * The registry has two layers:
8
+ *
9
+ * 1. **`FROM_UPSTREAM_SCRAPE`** — derived deterministically from army-assist
10
+ * `Datasheets.json` by scanning `leader_head` for the canonical phrasing
11
+ * (/already been attached|additional leader|attach this model.*even if/i)
12
+ * and resolving each datasheet name to our kebab-case unit id via the
13
+ * 10e-archive's `data/core/<faction>/units.json` name table.
14
+ * shadowboxing's `assets/Datasheets.json` yields the same 40 names.
15
+ *
16
+ * 2. **`MANUAL_OVERLAY`** — units whose 10e "additional leader" rule is
17
+ * *not* captured by either upstream scraper (data-gap entries) plus
18
+ * non-character special cases. Each entry needs a one-line comment
19
+ * naming the SME source so the gap is auditable. This layer is the
20
+ * reason 40kdc-data exists as a canonical upstream.
21
+ *
22
+ * The exported `KNOWN_SUPPORT_10E` is the merged view. To refresh layer 1,
23
+ * re-run the scan recipe documented above. To add a missing unit (layer 2),
24
+ * edit `MANUAL_OVERLAY` with a comment justifying the entry.
25
+ *
26
+ * Treat every entry as a **proposal** until human review confirms. The port
27
+ * emits a warning if a registry entry doesn't match an archive unit.
28
+ */
29
+ /** Layer 1 — derived from army-assist (and confirmed against shadowboxing). */
30
+ const FROM_UPSTREAM_SCRAPE = {
31
+ "adepta-sororitas": ["dialogus", "dogmata", "hospitaller", "imagifier", "ministorum-priest"],
32
+ // Successor chapters share these units via `parent_faction_id`, so
33
+ // chapter-specific variants like `crusade-ancient` (Black Templars) live
34
+ // under adeptus-astartes.
35
+ "adeptus-astartes": [
36
+ "ancient",
37
+ "ancient-in-terminator-armour",
38
+ "apothecary",
39
+ "apothecary-biologis",
40
+ "bladeguard-ancient",
41
+ "castellan",
42
+ "cato-sicarius",
43
+ "crusade-ancient",
44
+ "imperial-space-marine",
45
+ "lieutenant",
46
+ "lieutenant-in-phobos-armour",
47
+ "lieutenant-in-reiver-armour",
48
+ "sanguinary-priest",
49
+ ],
50
+ "adeptus-mechanicus": ["cybernetica-datasmith"],
51
+ "aeldari": ["eldrad-ulthran", "the-visarch", "warlock"],
52
+ "agents-of-the-imperium": ["ministorum-priest"],
53
+ "astra-militarum": ["death-rider-commissar", "ministorum-priest"],
54
+ "chaos-space-marines": ["master-of-executions"],
55
+ "death-guard": [
56
+ "biologus-putrifier",
57
+ "foul-blightspawn",
58
+ "icon-bearer",
59
+ "noxious-blightbringer",
60
+ "plague-surgeon",
61
+ "tallyman",
62
+ ],
63
+ "genestealer-cults": ["biophagus", "clamavus", "locus", "nexos"],
64
+ "necrons": [
65
+ "chronomancer",
66
+ "geomancer",
67
+ "orikan-the-diviner",
68
+ "plasmancer",
69
+ "psychomancer",
70
+ "technomancer",
71
+ ],
72
+ "world-eaters": ["master-of-executions"],
73
+ };
74
+ /** Layer 2 — units the upstream scrape misses, plus non-character special cases. */
75
+ const MANUAL_OVERLAY = {
76
+ // All three Kroot Shapers carry the additional-leader rule in the GW
77
+ // datasheet, but their `leader_head` in both army-assist and shadowboxing
78
+ // contains only the basic attachment list — the co-attach phrasing was
79
+ // dropped by both community scrapes. (Ethereal is *not* a co-attach Leader;
80
+ // confirmed not missing from the scrape.)
81
+ "tau-empire": ["kroot-flesh-shaper", "kroot-trail-shaper", "kroot-war-shaper"],
82
+ // Cryptothralls is a non-character bodyguard unit that joins a Cryptek-led
83
+ // unit. In 10e the co-attach Leader rule sits on the Cryptek datasheets
84
+ // (covered by layer 1); cryptothralls is included here for the 11e
85
+ // Support-pattern review since it's the "joiner" entity. May need a
86
+ // non-character Support encoding in 11e (`attachment_role` semantically
87
+ // expects a character).
88
+ "necrons": ["cryptothralls"],
89
+ };
90
+ /** Merge layers 1 and 2 into the public registry. */
91
+ function mergeLayers() {
92
+ const merged = {};
93
+ for (const [faction, ids] of Object.entries(FROM_UPSTREAM_SCRAPE)) {
94
+ merged[faction] = [...ids];
95
+ }
96
+ for (const [faction, ids] of Object.entries(MANUAL_OVERLAY)) {
97
+ merged[faction] = [...(merged[faction] ?? []), ...ids];
98
+ }
99
+ // Sort each list so output order is stable across re-runs.
100
+ for (const faction of Object.keys(merged))
101
+ merged[faction].sort();
102
+ return merged;
103
+ }
104
+ export const KNOWN_SUPPORT_10E = mergeLayers();
105
+ /** Flatten the registry to faction-prefixed ids for set membership tests. */
106
+ export function knownSupportSet() {
107
+ const set = new Set();
108
+ for (const [faction, ids] of Object.entries(KNOWN_SUPPORT_10E)) {
109
+ for (const id of ids)
110
+ set.add(`${faction}:${id}`);
111
+ }
112
+ return set;
113
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Port 10e archive faction data forward as an 11e provisional seed (Section 6).
3
+ *
4
+ * Usage:
5
+ * npx tsx tools/src/port-10e-faction.ts <faction-id>
6
+ * npx tsx tools/src/port-10e-faction.ts --all
7
+ *
8
+ * Reads data/{core,enrichment}/<faction>/*.json from the `10e-archive` ref,
9
+ * applies the per-entity 11e transforms, writes the result into the working
10
+ * tree, emits a per-faction audit under data/_port-audit/, and validates the
11
+ * output against the (bumped) schemas. Exits non-zero on validation failure.
12
+ *
13
+ * Distinct from convert-faction.ts (the army-assist bootstrap); this script is
14
+ * purpose-built for the archive→11e port and reads only from the git archive.
15
+ */
16
+ /** The provisional 11e dataslate every ported entity is stamped with. */
17
+ export declare const TARGET_GAME_VERSION: {
18
+ edition: string;
19
+ dataslate: string;
20
+ };
21
+ type Json = any;
22
+ export interface AuditItem {
23
+ category: string;
24
+ entity_type: string;
25
+ entity_id: string;
26
+ detail: string;
27
+ }
28
+ /** Faction directory names present under data/core/ on the archive ref. */
29
+ export declare function archiveFactions(): string[];
30
+ /** Ability ids granted via `ability-grant` whose id mentions cover. */
31
+ export declare function coverGrants(effect: Json): string[];
32
+ /** Charge/Fights-First effect types 11e may make redundant. */
33
+ export declare function chargeTimingTypes(effect: Json): string[];
34
+ /** True if any string value equals the 10e engagement-range constant (1"). */
35
+ export declare function referencesOneInch(entity: Json): boolean;
36
+ /** Stamp the provisional 11e game_version onto an entity. */
37
+ export declare function bump(entity: Json): Json;
38
+ /**
39
+ * units.json: bump, mark points provisional, set attachment_role.
40
+ *
41
+ * Precedence: a unit in `supportIds` (curated registry) becomes `"support"`,
42
+ * even if it isn't a leader_id (the cryptothralls case — non-character joiner).
43
+ * Otherwise, a `leader_id` becomes `"leader"`. Non-attaching units get no role.
44
+ */
45
+ export declare function portUnit(unit: Json, leaderIds: Set<string>, supportIds: Set<string>): Json;
46
+ /** enhancements.json: bump, mark provisional, default-fill upgrade_tag/max_targets. */
47
+ export declare function portEnhancement(enh: Json): Json;
48
+ /** detachments.json: bump, default-fill detachment_points/force_dispositions. */
49
+ export declare function portDetachment(det: Json): Json;
50
+ /** leader-attachments.json: drop the retired max_leaders_per_unit, then bump. */
51
+ export declare function portLeaderAttachment(la: Json): Json;
52
+ export {};
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Port 10e archive faction data forward as an 11e provisional seed (Section 6).
3
+ *
4
+ * Usage:
5
+ * npx tsx tools/src/port-10e-faction.ts <faction-id>
6
+ * npx tsx tools/src/port-10e-faction.ts --all
7
+ *
8
+ * Reads data/{core,enrichment}/<faction>/*.json from the `10e-archive` ref,
9
+ * applies the per-entity 11e transforms, writes the result into the working
10
+ * tree, emits a per-faction audit under data/_port-audit/, and validates the
11
+ * output against the (bumped) schemas. Exits non-zero on validation failure.
12
+ *
13
+ * Distinct from convert-faction.ts (the army-assist bootstrap); this script is
14
+ * purpose-built for the archive→11e port and reads only from the git archive.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+ import { writeFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
18
+ import { resolve, dirname, basename } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { createValidator } from "./schema-loader.js";
21
+ import { validateFiles } from "./validate.js";
22
+ import { KNOWN_SUPPORT_10E } from "./known-support-10e.js";
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const ROOT = resolve(__dirname, "../..");
25
+ const ARCHIVE_REF = "10e-archive";
26
+ const AUDIT_DIR = "data/_port-audit";
27
+ /** The provisional 11e dataslate every ported entity is stamped with. */
28
+ export const TARGET_GAME_VERSION = { edition: "11th", dataslate: "pre-launch-provisional" };
29
+ const ENTITY_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
30
+ // ─── git archive access ──────────────────────────────────────────────
31
+ function git(args) {
32
+ return execFileSync("git", args, {
33
+ cwd: ROOT,
34
+ encoding: "utf-8",
35
+ maxBuffer: 256 * 1024 * 1024,
36
+ });
37
+ }
38
+ /** Faction directory names present under data/core/ on the archive ref. */
39
+ export function archiveFactions() {
40
+ const out = git(["ls-tree", "-d", "--name-only", ARCHIVE_REF, "data/core/"]);
41
+ return out
42
+ .split("\n")
43
+ .map((l) => basename(l.trim()))
44
+ .filter((f) => f && f !== "_example")
45
+ .sort();
46
+ }
47
+ /** JSON files present for a faction in a data area on the archive ref. */
48
+ function archiveFiles(area, faction) {
49
+ let out;
50
+ try {
51
+ out = git(["ls-tree", "-r", "--name-only", ARCHIVE_REF, `data/${area}/${faction}`]);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ return out
57
+ .split("\n")
58
+ .map((l) => l.trim())
59
+ .filter((p) => p.endsWith(".json"));
60
+ }
61
+ function readArchiveJSON(path) {
62
+ return JSON.parse(git(["show", `${ARCHIVE_REF}:${path}`]));
63
+ }
64
+ function archiveSha() {
65
+ return git(["rev-parse", ARCHIVE_REF]).trim();
66
+ }
67
+ // ─── DSL inspection helpers ──────────────────────────────────────────
68
+ /** Collect every `type` string found anywhere in a DSL subtree. */
69
+ function collectTypes(node, acc) {
70
+ if (Array.isArray(node)) {
71
+ for (const v of node)
72
+ collectTypes(v, acc);
73
+ }
74
+ else if (node && typeof node === "object") {
75
+ if (typeof node.type === "string")
76
+ acc.push(node.type);
77
+ for (const v of Object.values(node))
78
+ collectTypes(v, acc);
79
+ }
80
+ }
81
+ /** Ability ids granted via `ability-grant` whose id mentions cover. */
82
+ export function coverGrants(effect) {
83
+ const hits = [];
84
+ const walk = (node) => {
85
+ if (Array.isArray(node)) {
86
+ for (const v of node)
87
+ walk(v);
88
+ }
89
+ else if (node && typeof node === "object") {
90
+ if (node.type === "ability-grant") {
91
+ const aid = String(node.modifier?.ability_id ?? "");
92
+ if (aid.toLowerCase().includes("cover"))
93
+ hits.push(aid);
94
+ }
95
+ for (const v of Object.values(node))
96
+ walk(v);
97
+ }
98
+ };
99
+ walk(effect);
100
+ return [...new Set(hits)];
101
+ }
102
+ /** Charge/Fights-First effect types 11e may make redundant. */
103
+ export function chargeTimingTypes(effect) {
104
+ const types = [];
105
+ collectTypes(effect, types);
106
+ return [...new Set(types.filter((t) => t === "fight-first" || t === "charged-this-turn"))];
107
+ }
108
+ /** True if any string value equals the 10e engagement-range constant (1"). */
109
+ export function referencesOneInch(entity) {
110
+ let found = false;
111
+ const walk = (node) => {
112
+ if (found)
113
+ return;
114
+ if (Array.isArray(node)) {
115
+ for (const v of node)
116
+ walk(v);
117
+ }
118
+ else if (node && typeof node === "object") {
119
+ for (const v of Object.values(node))
120
+ walk(v);
121
+ }
122
+ else if (typeof node === "string" && node.trim() === '1"') {
123
+ found = true;
124
+ }
125
+ };
126
+ walk(entity);
127
+ return found;
128
+ }
129
+ // ─── per-entity transforms (pure) ────────────────────────────────────
130
+ /** Stamp the provisional 11e game_version onto an entity. */
131
+ export function bump(entity) {
132
+ return { ...entity, game_version: { ...TARGET_GAME_VERSION } };
133
+ }
134
+ /**
135
+ * units.json: bump, mark points provisional, set attachment_role.
136
+ *
137
+ * Precedence: a unit in `supportIds` (curated registry) becomes `"support"`,
138
+ * even if it isn't a leader_id (the cryptothralls case — non-character joiner).
139
+ * Otherwise, a `leader_id` becomes `"leader"`. Non-attaching units get no role.
140
+ */
141
+ export function portUnit(unit, leaderIds, supportIds) {
142
+ const out = bump(unit);
143
+ out.points_provisional = true;
144
+ if (supportIds.has(unit.id)) {
145
+ out.attachment_role = "support";
146
+ }
147
+ else if (leaderIds.has(unit.id)) {
148
+ out.attachment_role = "leader";
149
+ }
150
+ return out;
151
+ }
152
+ /** enhancements.json: bump, mark provisional, default-fill upgrade_tag/max_targets. */
153
+ export function portEnhancement(enh) {
154
+ const out = bump(enh);
155
+ out.points_provisional = true;
156
+ if (out.upgrade_tag === undefined)
157
+ out.upgrade_tag = false;
158
+ if (out.max_targets === undefined)
159
+ out.max_targets = 1;
160
+ return out;
161
+ }
162
+ /** detachments.json: bump, default-fill detachment_points/force_dispositions. */
163
+ export function portDetachment(det) {
164
+ const out = bump(det);
165
+ if (out.detachment_points === undefined)
166
+ out.detachment_points = null;
167
+ if (out.force_dispositions === undefined)
168
+ out.force_dispositions = [];
169
+ return out;
170
+ }
171
+ /** leader-attachments.json: drop the retired max_leaders_per_unit, then bump. */
172
+ export function portLeaderAttachment(la) {
173
+ const { max_leaders_per_unit: _drop, ...rest } = la;
174
+ void _drop;
175
+ return bump(rest);
176
+ }
177
+ function transformCore(name, entities, leaderIds, supportIds) {
178
+ switch (name) {
179
+ case "units.json":
180
+ return entities.map((u) => portUnit(u, leaderIds, supportIds));
181
+ case "enhancements.json":
182
+ return entities.map(portEnhancement);
183
+ case "detachments.json":
184
+ return entities.map(portDetachment);
185
+ case "leader-attachments.json":
186
+ return entities.map(portLeaderAttachment);
187
+ default:
188
+ // factions, weapons, unit-compositions, stratagems — bump only
189
+ return entities.map(bump);
190
+ }
191
+ }
192
+ function writeJSON(rel, data) {
193
+ const out = resolve(ROOT, rel);
194
+ mkdirSync(dirname(out), { recursive: true });
195
+ writeFileSync(out, JSON.stringify(data, null, 2) + "\n");
196
+ }
197
+ function portFaction(faction) {
198
+ if (!ENTITY_ID_RE.test(faction)) {
199
+ throw new Error(`Invalid faction id: ${JSON.stringify(faction)}`);
200
+ }
201
+ const core = {};
202
+ for (const path of archiveFiles("core", faction))
203
+ core[basename(path)] = readArchiveJSON(path);
204
+ if (Object.keys(core).length === 0) {
205
+ throw new Error(`No core data for faction '${faction}' on ${ARCHIVE_REF}`);
206
+ }
207
+ const enrichment = {};
208
+ for (const path of archiveFiles("enrichment", faction)) {
209
+ enrichment[basename(path)] = readArchiveJSON(path);
210
+ }
211
+ const leaderIds = new Set((core["leader-attachments.json"] ?? []).map((e) => e.leader_id));
212
+ const supportIds = new Set(KNOWN_SUPPORT_10E[faction] ?? []);
213
+ const items = [];
214
+ const written = [];
215
+ // Warn before the port runs if a registry entry doesn't match an archive
216
+ // unit — silent typos would otherwise mean the role is never applied.
217
+ const unitIds = new Set((core["units.json"] ?? []).map((u) => u.id));
218
+ for (const candidateId of supportIds) {
219
+ if (!unitIds.has(candidateId)) {
220
+ console.warn(` ⚠ ${faction}: support registry lists '${candidateId}' but no such unit in archive`);
221
+ }
222
+ }
223
+ // Core files.
224
+ for (const [name, entities] of Object.entries(core)) {
225
+ const out = transformCore(name, entities, leaderIds, supportIds);
226
+ const rel = `data/core/${faction}/${name}`;
227
+ writeJSON(rel, out);
228
+ written.push(rel);
229
+ }
230
+ // Record an audit entry per unit the registry assigned `attachment_role:
231
+ // "support"` to — both as a visible roster of the call and so summary.md
232
+ // surfaces the list for review.
233
+ for (const candidateId of supportIds) {
234
+ if (!unitIds.has(candidateId))
235
+ continue;
236
+ items.push({
237
+ category: "support-assigned",
238
+ entity_type: "unit",
239
+ entity_id: candidateId,
240
+ detail: `Assigned attachment_role: "support" from the curated registry. To revert, remove from tools/src/known-support-10e.ts and re-run the port.`,
241
+ });
242
+ }
243
+ // Enrichment files + ability audits.
244
+ for (const [name, entities] of Object.entries(enrichment)) {
245
+ if (name === "abilities.json") {
246
+ for (const a of entities) {
247
+ const cover = coverGrants(a.effect ?? {});
248
+ if (cover.length) {
249
+ items.push({
250
+ category: "cover-ability",
251
+ entity_type: "ability",
252
+ entity_id: a.ability_id,
253
+ detail: `Grants ${cover.join(", ")}; 11e cover is −1 BS (not +1 Sv) — re-check the granted ability's definition.`,
254
+ });
255
+ }
256
+ const charge = chargeTimingTypes(a.effect ?? {});
257
+ if (charge.length) {
258
+ items.push({
259
+ category: "charge-timing",
260
+ entity_type: "ability",
261
+ entity_id: a.ability_id,
262
+ detail: `Uses ${charge.join(", ")}; 11e charging grants Fights First by default — check for redundancy.`,
263
+ });
264
+ }
265
+ if (referencesOneInch(a)) {
266
+ items.push({
267
+ category: "engagement-range",
268
+ entity_type: "ability",
269
+ entity_id: a.ability_id,
270
+ detail: `References 1"; 11e engagement range is 2" — confirm intent.`,
271
+ });
272
+ }
273
+ }
274
+ }
275
+ const rel = `data/enrichment/${faction}/${name}`;
276
+ writeJSON(rel, entities.map(bump));
277
+ written.push(rel);
278
+ }
279
+ // Bulk-review categories: every detachment / stratagem needs human work later.
280
+ const bulkReview = {};
281
+ if (core["detachments.json"]) {
282
+ bulkReview["detachment-disposition"] = core["detachments.json"].map((d) => d.id);
283
+ }
284
+ if (core["stratagems.json"]) {
285
+ bulkReview["stratagem-type"] = core["stratagems.json"].length;
286
+ }
287
+ const summary = {};
288
+ for (const it of items)
289
+ summary[it.category] = (summary[it.category] ?? 0) + 1;
290
+ writeJSON(`${AUDIT_DIR}/${faction}.json`, {
291
+ faction,
292
+ ported_from_ref: archiveSha(),
293
+ summary,
294
+ items,
295
+ bulk_review: bulkReview,
296
+ });
297
+ return { faction, written, summary };
298
+ }
299
+ /** Rebuild summary.md from every per-faction audit json currently on disk. */
300
+ function regenerateSummary() {
301
+ const dir = resolve(ROOT, AUDIT_DIR);
302
+ const audits = readdirSync(dir)
303
+ .filter((f) => f.endsWith(".json"))
304
+ .map((f) => JSON.parse(readFileSync(resolve(dir, f), "utf-8")))
305
+ .sort((a, b) => a.faction.localeCompare(b.faction));
306
+ const itemsOf = (a, category) => a.items.filter((i) => i.category === category);
307
+ const lines = [
308
+ "# 10e → 11e port — audit queue",
309
+ "",
310
+ "Generated by `tools/src/port-10e-faction.ts`. Each item below needs human",
311
+ "review before the provisional seed is treated as confirmed 11e data.",
312
+ "",
313
+ ];
314
+ // Flat roster of units the port assigned attachment_role: "support".
315
+ lines.push("## Support — assigned by the port");
316
+ lines.push("");
317
+ lines.push("The units below were written with `attachment_role: \"support\"` by the port,", "sourced from the curated 10e \"additional leader\" registry", "(`tools/src/known-support-10e.ts`).", "", "To **revert** any entry: remove the id from the registry and re-run", "`npx tsx tools/src/port-10e-faction.ts --all`. The port is the durable", "source of truth — don't hand-edit `attachment_role` in faction units.json.", "");
318
+ const assigned = audits.flatMap((a) => itemsOf(a, "support-assigned").map((i) => ({ faction: a.faction, id: i.entity_id })));
319
+ for (const c of assigned) {
320
+ lines.push(`- \`${c.faction}\` / **${c.id}**`);
321
+ }
322
+ lines.push("", `_${assigned.length} units assigned._`, "");
323
+ // Per-faction detail with names for the other discretionary flags.
324
+ lines.push("## Per-faction detail", "");
325
+ for (const a of audits) {
326
+ const cover = itemsOf(a, "cover-ability").map((i) => i.entity_id);
327
+ const charge = itemsOf(a, "charge-timing").map((i) => i.entity_id);
328
+ const engage = itemsOf(a, "engagement-range").map((i) => i.entity_id);
329
+ const support = itemsOf(a, "support-assigned").map((i) => i.entity_id);
330
+ const dets = a.bulk_review["detachment-disposition"] ?? [];
331
+ const strats = a.bulk_review["stratagem-type"] ?? 0;
332
+ if (!cover.length && !charge.length && !engage.length && !support.length && !dets.length && !strats) {
333
+ continue;
334
+ }
335
+ lines.push(`### ${a.faction}`, "");
336
+ if (support.length)
337
+ lines.push(`- Support candidates: ${fmtIds(support)}`);
338
+ if (cover.length)
339
+ lines.push(`- Cover abilities (rework \`benefit-of-cover\` → −1 BS): ${fmtIds(cover)}`);
340
+ if (charge.length)
341
+ lines.push(`- Charge-timing / Fights First (check redundancy): ${fmtIds(charge)}`);
342
+ if (engage.length)
343
+ lines.push(`- Engagement-range (1" → 2"): ${fmtIds(engage)}`);
344
+ if (dets.length)
345
+ lines.push(`- Detachments needing DP + Force Disposition assignment (${dets.length}): ${fmtIds(dets)}`);
346
+ if (strats)
347
+ lines.push(`- Stratagems pending 11e type-enum reconciliation: ${strats}`);
348
+ lines.push("");
349
+ }
350
+ const out = resolve(ROOT, `${AUDIT_DIR}/summary.md`);
351
+ mkdirSync(dirname(out), { recursive: true });
352
+ writeFileSync(out, lines.join("\n") + "\n");
353
+ }
354
+ /** Render a list of ids as inline-code, comma-separated. */
355
+ function fmtIds(ids) {
356
+ return ids.map((i) => `\`${i}\``).join(", ");
357
+ }
358
+ async function validateFaction(faction) {
359
+ const ajv = createValidator();
360
+ const core = await validateFiles(ajv, `core/${faction}/**/*.json`);
361
+ const enrich = await validateFiles(ajv, `enrichment/${faction}/**/*.json`);
362
+ const failed = core.failed + enrich.failed;
363
+ if (failed) {
364
+ for (const e of [...core.errors, ...enrich.errors]) {
365
+ const where = e.index >= 0 ? `[${e.index}]` : "";
366
+ console.error(` ✗ ${basename(e.file)}${where}: ${e.errors.map((x) => `${x.path} ${x.message}`).join("; ")}`);
367
+ }
368
+ }
369
+ return failed;
370
+ }
371
+ async function main() {
372
+ const args = process.argv.slice(2);
373
+ if (args.length === 0 || args[0] === "--help") {
374
+ console.log("Usage: npx tsx tools/src/port-10e-faction.ts <faction-id>");
375
+ console.log(" npx tsx tools/src/port-10e-faction.ts --all");
376
+ console.log(" npx tsx tools/src/port-10e-faction.ts --summary (rebuild audit summary only)");
377
+ process.exit(args[0] === "--help" ? 0 : 1);
378
+ }
379
+ if (args[0] === "--summary") {
380
+ regenerateSummary();
381
+ console.log(`Rebuilt ${AUDIT_DIR}/summary.md`);
382
+ return;
383
+ }
384
+ const factions = args[0] === "--all" ? archiveFactions() : [args[0]];
385
+ let totalFailed = 0;
386
+ for (const faction of factions) {
387
+ console.log(`\n▶ porting ${faction}`);
388
+ const res = portFaction(faction);
389
+ for (const w of res.written)
390
+ console.log(` ✓ ${w}`);
391
+ const failed = await validateFaction(faction);
392
+ if (failed) {
393
+ console.error(` ✗ ${faction}: ${failed} validation failure(s)`);
394
+ totalFailed += failed;
395
+ }
396
+ else {
397
+ const cats = Object.entries(res.summary).map(([k, v]) => `${k}=${v}`).join(", ");
398
+ console.log(` ✓ ${faction} validates (audit: ${cats || "none"})`);
399
+ }
400
+ }
401
+ regenerateSummary();
402
+ console.log(`\n${factions.length} faction(s) ported. Audit queue: ${AUDIT_DIR}/summary.md`);
403
+ if (totalFailed)
404
+ process.exit(1);
405
+ }
406
+ const isMain = process.argv[1] &&
407
+ resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
408
+ if (isMain) {
409
+ main().catch((err) => {
410
+ console.error(err);
411
+ process.exit(1);
412
+ });
413
+ }
@@ -0,0 +1,3 @@
1
+ import type { ValidationResult } from "./validate.js";
2
+ export type ReporterMode = "pretty" | "json";
3
+ export declare function formatReport(result: ValidationResult, mode: ReporterMode): string;
package/dist/report.js ADDED
@@ -0,0 +1,31 @@
1
+ import chalk from "chalk";
2
+ export function formatReport(result, mode) {
3
+ if (mode === "json") {
4
+ return JSON.stringify(result, null, 2);
5
+ }
6
+ const lines = [];
7
+ lines.push("");
8
+ lines.push(chalk.bold("40kdc Data Validation Report"));
9
+ lines.push(chalk.gray("─".repeat(40)));
10
+ lines.push(`Files scanned: ${result.totalFiles}`);
11
+ lines.push(`Items validated: ${result.totalItems}`);
12
+ lines.push(chalk.green(`Passed: ${result.passed}`));
13
+ if (result.failed > 0) {
14
+ lines.push(chalk.red(`Failed: ${result.failed}`));
15
+ lines.push("");
16
+ for (const err of result.errors) {
17
+ const loc = err.index >= 0 ? `[${err.index}]` : "";
18
+ lines.push(chalk.red(` ✗ ${err.file}${loc}`));
19
+ for (const e of err.errors) {
20
+ lines.push(chalk.yellow(` ${e.path}: ${e.message}`));
21
+ }
22
+ }
23
+ }
24
+ else {
25
+ lines.push(chalk.green(`Failed: 0`));
26
+ lines.push("");
27
+ lines.push(chalk.green("All validations passed."));
28
+ }
29
+ lines.push("");
30
+ return lines.join("\n");
31
+ }