@alpaca-software/40kdc-data 0.5.5 → 0.5.12

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 (126) hide show
  1. package/README.md +10 -0
  2. package/dist/codegen-data.js +2 -0
  3. package/dist/codegen-data.js.map +1 -1
  4. package/dist/commands/import.d.ts.map +1 -1
  5. package/dist/commands/import.js +3 -1
  6. package/dist/commands/import.js.map +1 -1
  7. package/dist/compare.d.ts +167 -0
  8. package/dist/compare.d.ts.map +1 -0
  9. package/dist/compare.js +302 -0
  10. package/dist/compare.js.map +1 -0
  11. package/dist/convert-faction.d.ts.map +1 -1
  12. package/dist/convert-faction.js +22 -2
  13. package/dist/convert-faction.js.map +1 -1
  14. package/dist/cruncher/buffs.d.ts +13 -0
  15. package/dist/cruncher/buffs.d.ts.map +1 -1
  16. package/dist/cruncher/buffs.js +7 -0
  17. package/dist/cruncher/buffs.js.map +1 -1
  18. package/dist/cruncher/engine.d.ts.map +1 -1
  19. package/dist/cruncher/engine.js +14 -10
  20. package/dist/cruncher/engine.js.map +1 -1
  21. package/dist/data/bundle.generated.js +1 -1
  22. package/dist/data/bundle.generated.js.map +1 -1
  23. package/dist/data/dataset.d.ts +3 -1
  24. package/dist/data/dataset.d.ts.map +1 -1
  25. package/dist/data/dataset.js +4 -0
  26. package/dist/data/dataset.js.map +1 -1
  27. package/dist/data/entities.d.ts +22 -0
  28. package/dist/data/entities.d.ts.map +1 -1
  29. package/dist/data/entities.js +45 -1
  30. package/dist/data/entities.js.map +1 -1
  31. package/dist/data/index.d.ts +4 -0
  32. package/dist/data/index.d.ts.map +1 -1
  33. package/dist/data/index.js +4 -0
  34. package/dist/data/index.js.map +1 -1
  35. package/dist/data/types.d.ts +5 -1
  36. package/dist/data/types.d.ts.map +1 -1
  37. package/dist/data/types.js +2 -0
  38. package/dist/data/types.js.map +1 -1
  39. package/dist/export/newrecruit-json.d.ts.map +1 -1
  40. package/dist/export/newrecruit-json.js +4 -3
  41. package/dist/export/newrecruit-json.js.map +1 -1
  42. package/dist/export/newrecruit-simple.js +2 -2
  43. package/dist/export/newrecruit-simple.js.map +1 -1
  44. package/dist/export/newrecruit-wtc.d.ts.map +1 -1
  45. package/dist/export/newrecruit-wtc.js +3 -1
  46. package/dist/export/newrecruit-wtc.js.map +1 -1
  47. package/dist/export/rosterizer.d.ts.map +1 -1
  48. package/dist/export/rosterizer.js +6 -7
  49. package/dist/export/rosterizer.js.map +1 -1
  50. package/dist/extract-faction-pack.d.ts +56 -0
  51. package/dist/extract-faction-pack.d.ts.map +1 -0
  52. package/dist/extract-faction-pack.js +374 -0
  53. package/dist/extract-faction-pack.js.map +1 -0
  54. package/dist/gen-conformance.js +37 -4
  55. package/dist/gen-conformance.js.map +1 -1
  56. package/dist/generated.d.ts +66 -2
  57. package/dist/generated.d.ts.map +1 -1
  58. package/dist/generated.js.map +1 -1
  59. package/dist/import/gw.js +1 -1
  60. package/dist/import/gw.js.map +1 -1
  61. package/dist/import/import-roster.d.ts.map +1 -1
  62. package/dist/import/import-roster.js +5 -0
  63. package/dist/import/import-roster.js.map +1 -1
  64. package/dist/import/index.d.ts +1 -0
  65. package/dist/import/index.d.ts.map +1 -1
  66. package/dist/import/index.js +1 -0
  67. package/dist/import/index.js.map +1 -1
  68. package/dist/import/listforge-text.js +1 -1
  69. package/dist/import/listforge-text.js.map +1 -1
  70. package/dist/import/listforge.d.ts.map +1 -1
  71. package/dist/import/listforge.js +18 -3
  72. package/dist/import/listforge.js.map +1 -1
  73. package/dist/import/newrecruit-json.d.ts.map +1 -1
  74. package/dist/import/newrecruit-json.js +21 -3
  75. package/dist/import/newrecruit-json.js.map +1 -1
  76. package/dist/import/newrecruit-simple.js +1 -1
  77. package/dist/import/newrecruit-simple.js.map +1 -1
  78. package/dist/import/newrecruit-wtc.js +1 -1
  79. package/dist/import/newrecruit-wtc.js.map +1 -1
  80. package/dist/import/resolve.d.ts.map +1 -1
  81. package/dist/import/resolve.js +46 -18
  82. package/dist/import/resolve.js.map +1 -1
  83. package/dist/import/roster-json.d.ts +32 -0
  84. package/dist/import/roster-json.d.ts.map +1 -0
  85. package/dist/import/roster-json.js +92 -0
  86. package/dist/import/roster-json.js.map +1 -0
  87. package/dist/import/rosterizer.js +3 -3
  88. package/dist/import/rosterizer.js.map +1 -1
  89. package/dist/import/types.d.ts +16 -5
  90. package/dist/import/types.d.ts.map +1 -1
  91. package/dist/import/types.js.map +1 -1
  92. package/dist/index.d.ts +2 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +8 -0
  95. package/dist/index.js.map +1 -1
  96. package/dist/migrate-keystone-twins.d.ts +23 -0
  97. package/dist/migrate-keystone-twins.d.ts.map +1 -0
  98. package/dist/migrate-keystone-twins.js +184 -0
  99. package/dist/migrate-keystone-twins.js.map +1 -0
  100. package/dist/rube-goldberg.js +1 -1
  101. package/dist/rube-goldberg.js.map +1 -1
  102. package/dist/runner.d.ts.map +1 -1
  103. package/dist/runner.js +94 -1
  104. package/dist/runner.js.map +1 -1
  105. package/dist/scope.d.ts +31 -0
  106. package/dist/scope.d.ts.map +1 -0
  107. package/dist/scope.js +34 -0
  108. package/dist/scope.js.map +1 -0
  109. package/dist/translate/effect.d.ts +17 -3
  110. package/dist/translate/effect.d.ts.map +1 -1
  111. package/dist/translate/effect.js +23 -4
  112. package/dist/translate/effect.js.map +1 -1
  113. package/dist/translate/index.d.ts +1 -1
  114. package/dist/translate/index.d.ts.map +1 -1
  115. package/dist/translate/index.js +1 -1
  116. package/dist/translate/index.js.map +1 -1
  117. package/dist/validate.d.ts.map +1 -1
  118. package/dist/validate.js +2 -0
  119. package/dist/validate.js.map +1 -1
  120. package/package.json +2 -1
  121. package/schemas/core/hull-shape.schema.json +30 -0
  122. package/schemas/core/roster.schema.json +26 -9
  123. package/schemas/core/stratagem.schema.json +2 -2
  124. package/schemas/core/target-profile.schema.json +30 -0
  125. package/schemas/core/unit-composition.schema.json +7 -0
  126. package/schemas/enrichment/ability-dsl/ability.schema.json +14 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Standardized extractor for GW 11e Faction Pack PDFs → IP-safe staging JSON.
3
+ *
4
+ * The faction packs are the structural source of truth for detachments,
5
+ * stratagems, and enhancements. This tool pulls the *facts* out of a pack —
6
+ * detachment names, stratagem names + CP cost + type + phase, enhancement names,
7
+ * detachment-rule names — and writes them to `data/_audit/faction-pack-input/
8
+ * <faction>.json` for human review before they are merged into `data/core/`.
9
+ *
10
+ * IP firewall: this tool MUST NOT emit GW rules/effect prose. It reads the
11
+ * `WHEN:` line of a stratagem card only to *derive* the structured phase /
12
+ * player-turn fields; the prose itself is never written to the staging file.
13
+ * Stored output is names + numeric/enum metadata only.
14
+ *
15
+ * The packs are *supplements*, not complete codexes: a pack contains only the
16
+ * extra/updated detachments. Absence from a pack does not imply removal. This
17
+ * tool reports only what a pack actually contains; completeness is reconciled
18
+ * against GW's full Detachment-Points list separately.
19
+ *
20
+ * Parsing uses `pdftotext -bbox-layout`, which emits per-word coordinates. GW
21
+ * lays each card out as discrete text blocks; we locate the "<DET> – <TYPE>
22
+ * STRATAGEM" label block, then read the name block directly above it, the CP
23
+ * block in the column gutter, and the `WHEN:` block below — all by coordinate.
24
+ * This is layout-independent (column positions differ across packs and pages).
25
+ * Anything the parser cannot determine is left null and recorded in a per-entry
26
+ * `flags` array for the reviewer.
27
+ *
28
+ * Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.
29
+ *
30
+ * Usage:
31
+ * npx tsx tools/src/extract-faction-pack.ts <pdf-path> --faction <faction-id>
32
+ * npx tsx tools/src/extract-faction-pack.ts --all --dir <pack-pdf-dir>
33
+ */
34
+ import { execFileSync } from "node:child_process";
35
+ import { readdirSync, writeFileSync, mkdirSync } from "node:fs";
36
+ import { resolve, basename } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
39
+ const DATA_ROOT = resolve(__dirname, "../../data");
40
+ const OUT_DIR = resolve(DATA_ROOT, "_audit", "faction-pack-input");
41
+ /** Pack-filename fragment → 40kdc faction id. `--all` uses this to route packs. */
42
+ const PACK_FACTION = [
43
+ ["space-marines", "adeptus-astartes"],
44
+ ["black_templars", "black-templars"],
45
+ ["blood_angels", "blood-angels"],
46
+ ["dark_angels", "dark-angels"],
47
+ ["space_wolves", "space-wolves"],
48
+ ["deathwatch", "deathwatch"],
49
+ ["grey_knights", "grey-knights"],
50
+ ];
51
+ const STRAT_TYPE = {
52
+ "BATTLE TACTIC": "battle-tactic",
53
+ "STRATEGIC PLOY": "strategic-ploy",
54
+ "EPIC DEED": "epic-deed",
55
+ WARGEAR: "wargear",
56
+ };
57
+ const TYPE_RE = /(BATTLE TACTIC|STRATEGIC PLOY|EPIC DEED|WARGEAR)\s+STRATAGEM$/;
58
+ const PHASES = ["command", "movement", "shooting", "charge", "fight"];
59
+ const SECTION_WORDS = /^(DETACHMENT RULES?|ENHANCEMENTS?|STRATAGEMS?|KEYWORDS?|RESTRICTIONS?)$/;
60
+ // Note: "Legends Datasheets" (not bare "Legends") — else it eats the detachment
61
+ // "Legends of Saga and Song".
62
+ const SECTION_TERMINATORS = /^(Datasheets|Rules Updates|Legends Datasheets|Imperial Armour|Index)\b/i;
63
+ /** Title/caps name → kebab id, matching existing entity-id conventions. */
64
+ export function slug(name) {
65
+ return name
66
+ .toLowerCase()
67
+ .replace(/[’']/g, "")
68
+ .replace(/&/g, " and ")
69
+ .replace(/[^a-z0-9]+/g, "-")
70
+ .replace(/^-+|-+$/g, "");
71
+ }
72
+ /** Title-case a pack ALL-CAPS header for storage as a display name. */
73
+ export function titleCase(raw) {
74
+ const small = new Set(["of", "the", "and", "to", "a", "in", "for"]);
75
+ return raw
76
+ .trim()
77
+ .toLowerCase()
78
+ .split(/\s+/)
79
+ .map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
80
+ .join(" ");
81
+ }
82
+ function runPdftotext(args) {
83
+ try {
84
+ return execFileSync("pdftotext", args, { encoding: "utf-8", maxBuffer: 128 * 1024 * 1024 });
85
+ }
86
+ catch (err) {
87
+ if (err.code === "ENOENT") {
88
+ throw new Error("pdftotext not found — install poppler (e.g. `brew install poppler`).");
89
+ }
90
+ throw err;
91
+ }
92
+ }
93
+ const decodeEntities = (s) => s
94
+ .replace(/&amp;/g, "&")
95
+ .replace(/&lt;/g, "<")
96
+ .replace(/&gt;/g, ">")
97
+ .replace(/&#39;/g, "'")
98
+ .replace(/&quot;/g, '"');
99
+ /**
100
+ * Parse the whole pack's bbox-layout HTML into positioned blocks. `gy` globalises
101
+ * y across pages (pageIndex × 100000 + y) so blocks sort in reading order without
102
+ * relying on printed page numbers (which drift from physical pages on later pages
103
+ * when full-bleed art is inserted).
104
+ */
105
+ function allBlocks(pdf) {
106
+ const html = runPdftotext(["-bbox-layout", pdf, "-"]);
107
+ const blocks = [];
108
+ html.split(/<page\b/).slice(1).forEach((pageHtml, pageIdx) => {
109
+ const offset = pageIdx * 100000;
110
+ for (const m of pageHtml.matchAll(/<block\b([^>]*)>([\s\S]*?)<\/block>/g)) {
111
+ const x = Number(/xMin="([\d.]+)"/.exec(m[1])?.[1] ?? "0");
112
+ const y = Number(/yMin="([\d.]+)"/.exec(m[1])?.[1] ?? "0");
113
+ const words = [...m[2].matchAll(/<word[^>]*>([^<]*)<\/word>/g)].map((w) => decodeEntities(w[1]));
114
+ const text = words.join(" ").replace(/\s+/g, " ").trim();
115
+ if (text)
116
+ blocks.push({ x, gy: offset + y, text });
117
+ }
118
+ });
119
+ return blocks;
120
+ }
121
+ const normCaps = (s) => s.toUpperCase().replace(/[’']/g, "'").replace(/\s+/g, " ").trim();
122
+ /** Datasheet/table headers that can masquerade as caps "enhancement" names. */
123
+ const DATASHEET_NOISE = new Set([
124
+ "RANGED WEAPONS", "MELEE WEAPONS", "RANGE", "WARGEAR OPTIONS", "UNIT COMPOSITION",
125
+ "KEYWORDS", "LEADER", "TRANSPORT", "DAMAGED", "SUPREME COMMANDER",
126
+ ]);
127
+ /** Non-detachment section headers that bound the last detachment's content. */
128
+ const SECTION_MARKER = /^(DATASHEETS|RULES UPDATES|LEGENDS DATASHEETS|IMPERIAL ARMOUR|INDEX)\b/;
129
+ const isCapsHeader = (t) => /^[A-Z0-9][A-Z0-9'’.,!&/()\- ]*$/.test(t) &&
130
+ /[A-Z]{3,}/.test(t) && // a real word, not a "1CP"/"D6" token
131
+ t.length >= 3 &&
132
+ t.length <= 46 &&
133
+ t.split(" ").length <= 7 &&
134
+ !SECTION_WORDS.test(t) &&
135
+ !/^\d+\s?CP$/.test(t) &&
136
+ !/\bSTRATAGEM$/.test(t);
137
+ const sameColumn = (a, b) => Math.abs(a - b) < 46;
138
+ const stripCp = (t) => {
139
+ const m = t.match(/^(.*?)[\s]+(\d+)\s?CP$/);
140
+ return m ? { name: m[1].trim(), cp: Number(m[2]) } : { name: t, cp: null };
141
+ };
142
+ /** Derive phases / player-turn from a `WHEN:` line (the prose itself is discarded). */
143
+ function parseWhen(when) {
144
+ const low = when.toLowerCase();
145
+ const phases = PHASES.filter((p) => low.includes(`${p} phase`));
146
+ let player_turn = null;
147
+ if (/opponent['’]?s?\b/.test(low))
148
+ player_turn = "opponent-turn";
149
+ else if (/\byour\b/.test(low) && phases.length)
150
+ player_turn = "your-turn";
151
+ else if (phases.length)
152
+ player_turn = "either";
153
+ const flag = phases.length ? undefined : "phase-review"; // e.g. "any phase"
154
+ return { phases, player_turn, flag };
155
+ }
156
+ /** Extract stratagems, enhancements, and rule name from a detachment's blocks. */
157
+ export function parseDetachmentBlocks(blocks, detachmentId) {
158
+ const flags = [];
159
+ const labels = blocks.filter((b) => /\bSTRATAGEM$/.test(b.text) && !/^STRATAGEMS?$/.test(b.text));
160
+ const cpBlocks = blocks.filter((b) => /^\d+\s?CP$/.test(b.text));
161
+ const whenBlocks = blocks.filter((b) => /^WHEN:/i.test(b.text));
162
+ // --- detachment rule name: first caps header under a DETACHMENT RULE(S) marker
163
+ let detachment_rule_name = null;
164
+ const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];
165
+ if (ruleHeader) {
166
+ const rn = blocks
167
+ .filter((b) => sameColumn(b.x, ruleHeader.x) && b.gy > ruleHeader.gy && isCapsHeader(b.text))
168
+ .sort((a, b) => a.gy - b.gy)[0];
169
+ if (rn)
170
+ detachment_rule_name = titleCase(rn.text);
171
+ }
172
+ // --- stratagems: keyed off each "<…> <TYPE> STRATAGEM" label block.
173
+ const stratagems = [];
174
+ const seenS = new Set();
175
+ for (const label of labels) {
176
+ const typeM = label.text.match(TYPE_RE);
177
+ const type = typeM ? STRAT_TYPE[typeM[1]] : null;
178
+ // name: nearest caps-header block directly above the label, same column
179
+ const nameBlock = blocks
180
+ .filter((b) => sameColumn(b.x, label.x) && b.gy < label.gy && isCapsHeader(stripCp(b.text).name))
181
+ .sort((a, b) => b.gy - a.gy)[0];
182
+ if (!nameBlock)
183
+ continue;
184
+ const { name, cp: inlineCp } = stripCp(nameBlock.text);
185
+ const id = slug(name);
186
+ if (!id || seenS.has(id))
187
+ continue;
188
+ seenS.add(id);
189
+ // cp: inline on the name block, else the CP block within the card window.
190
+ // Card layouts differ: new detachments put CP at the name's right edge on
191
+ // the name row; reprints put it in the left gutter below the label. So scan
192
+ // a window [name row → just below label] × [card width] and pick the CP
193
+ // nearest the name horizontally (avoids grabbing the adjacent column's CP).
194
+ let cp = inlineCp;
195
+ if (cp === null) {
196
+ const inCard = cpBlocks
197
+ .filter((b) => b.gy >= nameBlock.gy - 15 && b.gy <= label.gy + 70 && b.x >= nameBlock.x - 70 && b.x <= nameBlock.x + 260)
198
+ .sort((a, b) => Math.abs(a.x - nameBlock.x) - Math.abs(b.x - nameBlock.x))[0];
199
+ if (inCard)
200
+ cp = Number(inCard.text.match(/(\d+)/)[1]);
201
+ }
202
+ // phase: the first WHEN: block below the label in the same column
203
+ const when = whenBlocks
204
+ .filter((b) => sameColumn(b.x, label.x) && b.gy >= label.gy)
205
+ .sort((a, b) => a.gy - b.gy)[0];
206
+ const sFlags = [];
207
+ let phases = [];
208
+ let player_turn = null;
209
+ if (when) {
210
+ const w = parseWhen(when.text);
211
+ phases = w.phases;
212
+ player_turn = w.player_turn;
213
+ if (w.flag)
214
+ sFlags.push(w.flag);
215
+ }
216
+ else {
217
+ sFlags.push("phase-missing");
218
+ }
219
+ if (!type)
220
+ sFlags.push("type-missing");
221
+ if (cp === null)
222
+ sFlags.push("cp-missing");
223
+ sFlags.push("timing-missing"); // pack rarely states once-per-phase/turn/battle
224
+ stratagems.push({ id, name: titleCase(name), type, cp_cost: cp, phases, player_turn, timing: null, flags: sFlags });
225
+ }
226
+ if (!detachment_rule_name)
227
+ flags.push("rule-name-missing");
228
+ // --- enhancements (subtractive): a detachment page's caps headers are the
229
+ // title, tagline, rule name, section words, stratagem names, type labels,
230
+ // and enhancement names. Remove every known category; the rest are
231
+ // enhancements. Each must be a name+description pair, i.e. followed by a
232
+ // mixed-case prose block in its column. Cost is never printed in the pack.
233
+ const ruleSlug = detachment_rule_name ? slug(detachment_rule_name) : "";
234
+ const hasProseBelow = (b) => blocks
235
+ .filter((o) => sameColumn(o.x, b.x) && o.gy > b.gy)
236
+ .sort((p, q) => p.gy - q.gy)
237
+ .some((o, i) => i === 0 && /[a-z]/.test(o.text)); // nearest block below is prose
238
+ const enhancements = [];
239
+ const seenE = new Set();
240
+ for (const b of blocks) {
241
+ if (!isCapsHeader(b.text) || DATASHEET_NOISE.has(normCaps(b.text)))
242
+ continue;
243
+ const isUpgrade = / UPGRADE$/.test(b.text);
244
+ const name = b.text.replace(/ UPGRADE$/, "").trim();
245
+ const id = slug(name);
246
+ if (!id || seenE.has(id) || seenS.has(id))
247
+ continue; // skip stratagem names
248
+ if (id === detachmentId || id === ruleSlug)
249
+ continue; // skip title / rule name
250
+ if (!hasProseBelow(b))
251
+ continue; // enhancements always have a description
252
+ seenE.add(id);
253
+ enhancements.push({
254
+ id,
255
+ name: titleCase(name),
256
+ cost: null,
257
+ flags: ["cost-missing", "name-needs-review", ...(isUpgrade ? ["upgrade-tag?"] : [])],
258
+ });
259
+ }
260
+ if (!enhancements.length)
261
+ flags.push("no-enhancements-parsed");
262
+ return { detachment_rule_name, stratagems, enhancements, flags };
263
+ }
264
+ /**
265
+ * Parse the pack's table of contents (first two pages) into the ordered list of
266
+ * detachments with their start pages. Uses non-layout extraction so the two
267
+ * columns don't interleave; dot leaders are private-font glyphs (non-word
268
+ * chars), so a TOC row is `<Name><2+ non-word><page#>`. Entries are kept only
269
+ * between the "Detachments" header and the first non-detachment section.
270
+ */
271
+ export function parseToc(tocText) {
272
+ const rows = [];
273
+ let inDetachments = false;
274
+ let sawHeader = false;
275
+ for (const line of tocText.split("\n")) {
276
+ const m = line.match(/^\s*([A-Z][A-Za-z0-9'’ \-]+?)[^\w\s]{2,}\s*(\d+)\s*$/);
277
+ if (!m)
278
+ continue;
279
+ const name = m[1].trim();
280
+ const page = Number(m[2]);
281
+ if (/^Detachments$/i.test(name)) {
282
+ inDetachments = true;
283
+ sawHeader = true;
284
+ continue;
285
+ }
286
+ if (sawHeader && SECTION_TERMINATORS.test(name)) {
287
+ if (inDetachments)
288
+ rows.push({ name, page, section: true }); // bounds the last detachment
289
+ inDetachments = false;
290
+ continue;
291
+ }
292
+ if (inDetachments)
293
+ rows.push({ name, page });
294
+ }
295
+ return rows;
296
+ }
297
+ export function extractPack(pdf, faction) {
298
+ // detachment names (ordered) from the TOC; content boundaries from block positions
299
+ const names = parseToc(runPdftotext(["-f", "1", "-l", "2", pdf, "-"]))
300
+ .filter((t) => !t.section)
301
+ .map((t) => t.name);
302
+ const blocks = allBlocks(pdf);
303
+ // start of each detachment = its title block (exact uppercase name match, first
304
+ // occurrence). Stratagem labels like "NAME – TYPE STRATAGEM" are not exact, so
305
+ // they don't collide with the title.
306
+ const starts = names
307
+ .map((name) => {
308
+ const title = blocks.filter((b) => normCaps(b.text) === normCaps(name)).sort((a, b) => a.gy - b.gy)[0];
309
+ return title ? { name, gy: title.gy } : null;
310
+ })
311
+ .filter((s) => s !== null)
312
+ .sort((a, b) => a.gy - b.gy);
313
+ const sectionGys = blocks.filter((b) => SECTION_MARKER.test(normCaps(b.text))).map((b) => b.gy).sort((a, b) => a - b);
314
+ const detachments = [];
315
+ for (let i = 0; i < starts.length; i++) {
316
+ const start = starts[i].gy;
317
+ const nextDet = starts[i + 1]?.gy ?? Infinity;
318
+ const nextSection = sectionGys.find((gy) => gy > start) ?? Infinity;
319
+ // Backstop: a detachment is at most ~2 pages (gy page stride is 100000).
320
+ // Bounds the last detachment when no datasheets/section divider block is
321
+ // detected, so it can't bleed into the datasheet pages that follow.
322
+ const end = Math.min(nextDet, nextSection, start + 200000);
323
+ const id = slug(starts[i].name);
324
+ const body = parseDetachmentBlocks(blocks.filter((b) => b.gy >= start && b.gy < end), id);
325
+ detachments.push({ id, name: starts[i].name, ...body });
326
+ }
327
+ return { faction_id: faction, source_pack: basename(pdf), detachments };
328
+ }
329
+ function factionForPack(file) {
330
+ const lower = file.toLowerCase();
331
+ return PACK_FACTION.find(([frag]) => lower.includes(frag))?.[1];
332
+ }
333
+ function main() {
334
+ const argv = process.argv.slice(2);
335
+ mkdirSync(OUT_DIR, { recursive: true });
336
+ let jobs = [];
337
+ if (argv[0] === "--all") {
338
+ const dirIdx = argv.indexOf("--dir");
339
+ const dir = dirIdx === -1 ? undefined : argv[dirIdx + 1];
340
+ if (!dir) {
341
+ console.error("Usage: extract-faction-pack.ts --all --dir <pack-pdf-dir>");
342
+ process.exit(1);
343
+ }
344
+ for (const f of readdirSync(dir)) {
345
+ if (!/faction_pack.*\.pdf$/i.test(f))
346
+ continue;
347
+ const faction = factionForPack(f);
348
+ if (faction)
349
+ jobs.push({ pdf: resolve(dir, f), faction });
350
+ }
351
+ }
352
+ else {
353
+ const facIdx = argv.indexOf("--faction");
354
+ const pdf = argv[0];
355
+ if (!pdf || facIdx === -1) {
356
+ console.error("Usage: extract-faction-pack.ts <pdf-path> --faction <faction-id>");
357
+ process.exit(1);
358
+ }
359
+ jobs = [{ pdf: resolve(pdf), faction: argv[facIdx + 1] }];
360
+ }
361
+ for (const { pdf, faction } of jobs) {
362
+ const result = extractPack(pdf, faction);
363
+ const out = resolve(OUT_DIR, `${faction}.json`);
364
+ writeFileSync(out, JSON.stringify(result, null, 2) + "\n");
365
+ const nStrat = result.detachments.reduce((a, d) => a + d.stratagems.length, 0);
366
+ const nEnh = result.detachments.reduce((a, d) => a + d.enhancements.length, 0);
367
+ console.log(` ${faction}: ${result.detachments.length} detachments, ${nStrat} stratagems, ${nEnh} enhancements → ${out}`);
368
+ }
369
+ }
370
+ const isMain = process.argv[1] &&
371
+ resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
372
+ if (isMain)
373
+ main();
374
+ //# sourceMappingURL=extract-faction-pack.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extract-faction-pack.js","sourceRoot":"","sources":["../src/extract-faction-pack.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Standardized extractor for GW 11e Faction Pack PDFs → IP-safe staging JSON.\n *\n * The faction packs are the structural source of truth for detachments,\n * stratagems, and enhancements. This tool pulls the *facts* out of a pack —\n * detachment names, stratagem names + CP cost + type + phase, enhancement names,\n * detachment-rule names — and writes them to `data/_audit/faction-pack-input/\n * <faction>.json` for human review before they are merged into `data/core/`.\n *\n * IP firewall: this tool MUST NOT emit GW rules/effect prose. It reads the\n * `WHEN:` line of a stratagem card only to *derive* the structured phase /\n * player-turn fields; the prose itself is never written to the staging file.\n * Stored output is names + numeric/enum metadata only.\n *\n * The packs are *supplements*, not complete codexes: a pack contains only the\n * extra/updated detachments. Absence from a pack does not imply removal. This\n * tool reports only what a pack actually contains; completeness is reconciled\n * against GW's full Detachment-Points list separately.\n *\n * Parsing uses `pdftotext -bbox-layout`, which emits per-word coordinates. GW\n * lays each card out as discrete text blocks; we locate the \"<DET> – <TYPE>\n * STRATAGEM\" label block, then read the name block directly above it, the CP\n * block in the column gutter, and the `WHEN:` block below — all by coordinate.\n * This is layout-independent (column positions differ across packs and pages).\n * Anything the parser cannot determine is left null and recorded in a per-entry\n * `flags` array for the reviewer.\n *\n * Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.\n *\n * Usage:\n * npx tsx tools/src/extract-faction-pack.ts <pdf-path> --faction <faction-id>\n * npx tsx tools/src/extract-faction-pack.ts --all --dir <pack-pdf-dir>\n */\nimport { execFileSync } from \"node:child_process\";\nimport { readdirSync, writeFileSync, mkdirSync } from \"node:fs\";\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\");\nconst OUT_DIR = resolve(DATA_ROOT, \"_audit\", \"faction-pack-input\");\n\n/** Pack-filename fragment → 40kdc faction id. `--all` uses this to route packs. */\nconst PACK_FACTION: Array<[fragment: string, faction: string]> = [\n [\"space-marines\", \"adeptus-astartes\"],\n [\"black_templars\", \"black-templars\"],\n [\"blood_angels\", \"blood-angels\"],\n [\"dark_angels\", \"dark-angels\"],\n [\"space_wolves\", \"space-wolves\"],\n [\"deathwatch\", \"deathwatch\"],\n [\"grey_knights\", \"grey-knights\"],\n];\n\nconst STRAT_TYPE: Record<string, string> = {\n \"BATTLE TACTIC\": \"battle-tactic\",\n \"STRATEGIC PLOY\": \"strategic-ploy\",\n \"EPIC DEED\": \"epic-deed\",\n WARGEAR: \"wargear\",\n};\nconst TYPE_RE = /(BATTLE TACTIC|STRATEGIC PLOY|EPIC DEED|WARGEAR)\\s+STRATAGEM$/;\nconst PHASES = [\"command\", \"movement\", \"shooting\", \"charge\", \"fight\"] as const;\nconst SECTION_WORDS = /^(DETACHMENT RULES?|ENHANCEMENTS?|STRATAGEMS?|KEYWORDS?|RESTRICTIONS?)$/;\n// Note: \"Legends Datasheets\" (not bare \"Legends\") — else it eats the detachment\n// \"Legends of Saga and Song\".\nconst SECTION_TERMINATORS = /^(Datasheets|Rules Updates|Legends Datasheets|Imperial Armour|Index)\\b/i;\n\nexport interface ExtractedStratagem {\n id: string;\n name: string;\n type: string | null;\n cp_cost: number | null;\n phases: string[];\n player_turn: string | null;\n timing: string | null;\n flags: string[];\n}\nexport interface ExtractedEnhancement {\n id: string;\n name: string;\n cost: null; // never in the pack — comes from the Munitorum Field Manual\n flags: string[];\n}\nexport interface ExtractedDetachment {\n id: string;\n name: string;\n detachment_rule_name: string | null;\n stratagems: ExtractedStratagem[];\n enhancements: ExtractedEnhancement[];\n flags: string[];\n}\nexport interface PackExtract {\n faction_id: string;\n source_pack: string;\n detachments: ExtractedDetachment[];\n}\n\n/** A positioned text block from `pdftotext -bbox-layout`. `gy` is page-globalised y. */\ninterface Block {\n x: number;\n gy: number;\n text: string;\n}\n\n/** Title/caps name → kebab id, matching existing entity-id conventions. */\nexport function slug(name: string): string {\n return name\n .toLowerCase()\n .replace(/[’']/g, \"\")\n .replace(/&/g, \" and \")\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n}\n\n/** Title-case a pack ALL-CAPS header for storage as a display name. */\nexport function titleCase(raw: string): string {\n const small = new Set([\"of\", \"the\", \"and\", \"to\", \"a\", \"in\", \"for\"]);\n return raw\n .trim()\n .toLowerCase()\n .split(/\\s+/)\n .map((w, i) => (i > 0 && small.has(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))\n .join(\" \");\n}\n\nfunction runPdftotext(args: string[]): string {\n try {\n return execFileSync(\"pdftotext\", args, { encoding: \"utf-8\", maxBuffer: 128 * 1024 * 1024 });\n } catch (err: unknown) {\n if ((err as { code?: string }).code === \"ENOENT\") {\n throw new Error(\"pdftotext not found — install poppler (e.g. `brew install poppler`).\");\n }\n throw err;\n }\n}\n\nconst decodeEntities = (s: string): string =>\n s\n .replace(/&amp;/g, \"&\")\n .replace(/&lt;/g, \"<\")\n .replace(/&gt;/g, \">\")\n .replace(/&#39;/g, \"'\")\n .replace(/&quot;/g, '\"');\n\n/**\n * Parse the whole pack's bbox-layout HTML into positioned blocks. `gy` globalises\n * y across pages (pageIndex × 100000 + y) so blocks sort in reading order without\n * relying on printed page numbers (which drift from physical pages on later pages\n * when full-bleed art is inserted).\n */\nfunction allBlocks(pdf: string): Block[] {\n const html = runPdftotext([\"-bbox-layout\", pdf, \"-\"]);\n const blocks: Block[] = [];\n html.split(/<page\\b/).slice(1).forEach((pageHtml, pageIdx) => {\n const offset = pageIdx * 100000;\n for (const m of pageHtml.matchAll(/<block\\b([^>]*)>([\\s\\S]*?)<\\/block>/g)) {\n const x = Number(/xMin=\"([\\d.]+)\"/.exec(m[1])?.[1] ?? \"0\");\n const y = Number(/yMin=\"([\\d.]+)\"/.exec(m[1])?.[1] ?? \"0\");\n const words = [...m[2].matchAll(/<word[^>]*>([^<]*)<\\/word>/g)].map((w) => decodeEntities(w[1]));\n const text = words.join(\" \").replace(/\\s+/g, \" \").trim();\n if (text) blocks.push({ x, gy: offset + y, text });\n }\n });\n return blocks;\n}\n\nconst normCaps = (s: string): string => s.toUpperCase().replace(/[’']/g, \"'\").replace(/\\s+/g, \" \").trim();\n/** Datasheet/table headers that can masquerade as caps \"enhancement\" names. */\nconst DATASHEET_NOISE = new Set([\n \"RANGED WEAPONS\", \"MELEE WEAPONS\", \"RANGE\", \"WARGEAR OPTIONS\", \"UNIT COMPOSITION\",\n \"KEYWORDS\", \"LEADER\", \"TRANSPORT\", \"DAMAGED\", \"SUPREME COMMANDER\",\n]);\n/** Non-detachment section headers that bound the last detachment's content. */\nconst SECTION_MARKER = /^(DATASHEETS|RULES UPDATES|LEGENDS DATASHEETS|IMPERIAL ARMOUR|INDEX)\\b/;\n\nconst isCapsHeader = (t: string): boolean =>\n /^[A-Z0-9][A-Z0-9'’.,!&/()\\- ]*$/.test(t) &&\n /[A-Z]{3,}/.test(t) && // a real word, not a \"1CP\"/\"D6\" token\n t.length >= 3 &&\n t.length <= 46 &&\n t.split(\" \").length <= 7 &&\n !SECTION_WORDS.test(t) &&\n !/^\\d+\\s?CP$/.test(t) &&\n !/\\bSTRATAGEM$/.test(t);\n\nconst sameColumn = (a: number, b: number): boolean => Math.abs(a - b) < 46;\nconst stripCp = (t: string): { name: string; cp: number | null } => {\n const m = t.match(/^(.*?)[\\s]+(\\d+)\\s?CP$/);\n return m ? { name: m[1].trim(), cp: Number(m[2]) } : { name: t, cp: null };\n};\n\n/** Derive phases / player-turn from a `WHEN:` line (the prose itself is discarded). */\nfunction parseWhen(when: string): { phases: string[]; player_turn: string | null; flag?: string } {\n const low = when.toLowerCase();\n const phases = PHASES.filter((p) => low.includes(`${p} phase`));\n let player_turn: string | null = null;\n if (/opponent['’]?s?\\b/.test(low)) player_turn = \"opponent-turn\";\n else if (/\\byour\\b/.test(low) && phases.length) player_turn = \"your-turn\";\n else if (phases.length) player_turn = \"either\";\n const flag = phases.length ? undefined : \"phase-review\"; // e.g. \"any phase\"\n return { phases, player_turn, flag };\n}\n\n/** Extract stratagems, enhancements, and rule name from a detachment's blocks. */\nexport function parseDetachmentBlocks(blocks: Block[], detachmentId: string): Omit<ExtractedDetachment, \"id\" | \"name\"> {\n const flags: string[] = [];\n const labels = blocks.filter((b) => /\\bSTRATAGEM$/.test(b.text) && !/^STRATAGEMS?$/.test(b.text));\n const cpBlocks = blocks.filter((b) => /^\\d+\\s?CP$/.test(b.text));\n const whenBlocks = blocks.filter((b) => /^WHEN:/i.test(b.text));\n\n // --- detachment rule name: first caps header under a DETACHMENT RULE(S) marker\n let detachment_rule_name: string | null = null;\n const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];\n if (ruleHeader) {\n const rn = blocks\n .filter((b) => sameColumn(b.x, ruleHeader.x) && b.gy > ruleHeader.gy && isCapsHeader(b.text))\n .sort((a, b) => a.gy - b.gy)[0];\n if (rn) detachment_rule_name = titleCase(rn.text);\n }\n\n // --- stratagems: keyed off each \"<…> <TYPE> STRATAGEM\" label block.\n const stratagems: ExtractedStratagem[] = [];\n const seenS = new Set<string>();\n for (const label of labels) {\n const typeM = label.text.match(TYPE_RE);\n const type = typeM ? STRAT_TYPE[typeM[1]] : null;\n\n // name: nearest caps-header block directly above the label, same column\n const nameBlock = blocks\n .filter((b) => sameColumn(b.x, label.x) && b.gy < label.gy && isCapsHeader(stripCp(b.text).name))\n .sort((a, b) => b.gy - a.gy)[0];\n if (!nameBlock) continue;\n const { name, cp: inlineCp } = stripCp(nameBlock.text);\n const id = slug(name);\n if (!id || seenS.has(id)) continue;\n seenS.add(id);\n\n // cp: inline on the name block, else the CP block within the card window.\n // Card layouts differ: new detachments put CP at the name's right edge on\n // the name row; reprints put it in the left gutter below the label. So scan\n // a window [name row → just below label] × [card width] and pick the CP\n // nearest the name horizontally (avoids grabbing the adjacent column's CP).\n let cp = inlineCp;\n if (cp === null) {\n const inCard = cpBlocks\n .filter((b) => b.gy >= nameBlock.gy - 15 && b.gy <= label.gy + 70 && b.x >= nameBlock.x - 70 && b.x <= nameBlock.x + 260)\n .sort((a, b) => Math.abs(a.x - nameBlock.x) - Math.abs(b.x - nameBlock.x))[0];\n if (inCard) cp = Number(inCard.text.match(/(\\d+)/)![1]);\n }\n\n // phase: the first WHEN: block below the label in the same column\n const when = whenBlocks\n .filter((b) => sameColumn(b.x, label.x) && b.gy >= label.gy)\n .sort((a, b) => a.gy - b.gy)[0];\n const sFlags: string[] = [];\n let phases: string[] = [];\n let player_turn: string | null = null;\n if (when) {\n const w = parseWhen(when.text);\n phases = w.phases;\n player_turn = w.player_turn;\n if (w.flag) sFlags.push(w.flag);\n } else {\n sFlags.push(\"phase-missing\");\n }\n if (!type) sFlags.push(\"type-missing\");\n if (cp === null) sFlags.push(\"cp-missing\");\n sFlags.push(\"timing-missing\"); // pack rarely states once-per-phase/turn/battle\n\n stratagems.push({ id, name: titleCase(name), type, cp_cost: cp, phases, player_turn, timing: null, flags: sFlags });\n }\n\n if (!detachment_rule_name) flags.push(\"rule-name-missing\");\n\n // --- enhancements (subtractive): a detachment page's caps headers are the\n // title, tagline, rule name, section words, stratagem names, type labels,\n // and enhancement names. Remove every known category; the rest are\n // enhancements. Each must be a name+description pair, i.e. followed by a\n // mixed-case prose block in its column. Cost is never printed in the pack.\n const ruleSlug = detachment_rule_name ? slug(detachment_rule_name) : \"\";\n const hasProseBelow = (b: Block): boolean =>\n blocks\n .filter((o) => sameColumn(o.x, b.x) && o.gy > b.gy)\n .sort((p, q) => p.gy - q.gy)\n .some((o, i) => i === 0 && /[a-z]/.test(o.text)); // nearest block below is prose\n const enhancements: ExtractedEnhancement[] = [];\n const seenE = new Set<string>();\n for (const b of blocks) {\n if (!isCapsHeader(b.text) || DATASHEET_NOISE.has(normCaps(b.text))) continue;\n const isUpgrade = / UPGRADE$/.test(b.text);\n const name = b.text.replace(/ UPGRADE$/, \"\").trim();\n const id = slug(name);\n if (!id || seenE.has(id) || seenS.has(id)) continue; // skip stratagem names\n if (id === detachmentId || id === ruleSlug) continue; // skip title / rule name\n if (!hasProseBelow(b)) continue; // enhancements always have a description\n seenE.add(id);\n enhancements.push({\n id,\n name: titleCase(name),\n cost: null,\n flags: [\"cost-missing\", \"name-needs-review\", ...(isUpgrade ? [\"upgrade-tag?\"] : [])],\n });\n }\n if (!enhancements.length) flags.push(\"no-enhancements-parsed\");\n\n return { detachment_rule_name, stratagems, enhancements, flags };\n}\n\n/**\n * Parse the pack's table of contents (first two pages) into the ordered list of\n * detachments with their start pages. Uses non-layout extraction so the two\n * columns don't interleave; dot leaders are private-font glyphs (non-word\n * chars), so a TOC row is `<Name><2+ non-word><page#>`. Entries are kept only\n * between the \"Detachments\" header and the first non-detachment section.\n */\nexport function parseToc(tocText: string): Array<{ name: string; page: number; section?: boolean }> {\n const rows: Array<{ name: string; page: number; section?: boolean }> = [];\n let inDetachments = false;\n let sawHeader = false;\n for (const line of tocText.split(\"\\n\")) {\n const m = line.match(/^\\s*([A-Z][A-Za-z0-9'’ \\-]+?)[^\\w\\s]{2,}\\s*(\\d+)\\s*$/);\n if (!m) continue;\n const name = m[1].trim();\n const page = Number(m[2]);\n if (/^Detachments$/i.test(name)) {\n inDetachments = true;\n sawHeader = true;\n continue;\n }\n if (sawHeader && SECTION_TERMINATORS.test(name)) {\n if (inDetachments) rows.push({ name, page, section: true }); // bounds the last detachment\n inDetachments = false;\n continue;\n }\n if (inDetachments) rows.push({ name, page });\n }\n return rows;\n}\n\nexport function extractPack(pdf: string, faction: string): PackExtract {\n // detachment names (ordered) from the TOC; content boundaries from block positions\n const names = parseToc(runPdftotext([\"-f\", \"1\", \"-l\", \"2\", pdf, \"-\"]))\n .filter((t) => !t.section)\n .map((t) => t.name);\n const blocks = allBlocks(pdf);\n\n // start of each detachment = its title block (exact uppercase name match, first\n // occurrence). Stratagem labels like \"NAME – TYPE STRATAGEM\" are not exact, so\n // they don't collide with the title.\n const starts = names\n .map((name) => {\n const title = blocks.filter((b) => normCaps(b.text) === normCaps(name)).sort((a, b) => a.gy - b.gy)[0];\n return title ? { name, gy: title.gy } : null;\n })\n .filter((s): s is { name: string; gy: number } => s !== null)\n .sort((a, b) => a.gy - b.gy);\n const sectionGys = blocks.filter((b) => SECTION_MARKER.test(normCaps(b.text))).map((b) => b.gy).sort((a, b) => a - b);\n\n const detachments: ExtractedDetachment[] = [];\n for (let i = 0; i < starts.length; i++) {\n const start = starts[i].gy;\n const nextDet = starts[i + 1]?.gy ?? Infinity;\n const nextSection = sectionGys.find((gy) => gy > start) ?? Infinity;\n // Backstop: a detachment is at most ~2 pages (gy page stride is 100000).\n // Bounds the last detachment when no datasheets/section divider block is\n // detected, so it can't bleed into the datasheet pages that follow.\n const end = Math.min(nextDet, nextSection, start + 200000);\n const id = slug(starts[i].name);\n const body = parseDetachmentBlocks(\n blocks.filter((b) => b.gy >= start && b.gy < end),\n id,\n );\n detachments.push({ id, name: starts[i].name, ...body });\n }\n return { faction_id: faction, source_pack: basename(pdf), detachments };\n}\n\nfunction factionForPack(file: string): string | undefined {\n const lower = file.toLowerCase();\n return PACK_FACTION.find(([frag]) => lower.includes(frag))?.[1];\n}\n\nfunction main(): void {\n const argv = process.argv.slice(2);\n mkdirSync(OUT_DIR, { recursive: true });\n\n let jobs: Array<{ pdf: string; faction: string }> = [];\n if (argv[0] === \"--all\") {\n const dirIdx = argv.indexOf(\"--dir\");\n const dir = dirIdx === -1 ? undefined : argv[dirIdx + 1];\n if (!dir) {\n console.error(\"Usage: extract-faction-pack.ts --all --dir <pack-pdf-dir>\");\n process.exit(1);\n }\n for (const f of readdirSync(dir)) {\n if (!/faction_pack.*\\.pdf$/i.test(f)) continue;\n const faction = factionForPack(f);\n if (faction) jobs.push({ pdf: resolve(dir, f), faction });\n }\n } else {\n const facIdx = argv.indexOf(\"--faction\");\n const pdf = argv[0];\n if (!pdf || facIdx === -1) {\n console.error(\"Usage: extract-faction-pack.ts <pdf-path> --faction <faction-id>\");\n process.exit(1);\n }\n jobs = [{ pdf: resolve(pdf), faction: argv[facIdx + 1] }];\n }\n\n for (const { pdf, faction } of jobs) {\n const result = extractPack(pdf, faction);\n const out = resolve(OUT_DIR, `${faction}.json`);\n writeFileSync(out, JSON.stringify(result, null, 2) + \"\\n\");\n const nStrat = result.detachments.reduce((a, d) => a + d.stratagems.length, 0);\n const nEnh = result.detachments.reduce((a, d) => a + d.enhancements.length, 0);\n console.log(\n ` ${faction}: ${result.detachments.length} detachments, ${nStrat} stratagems, ${nEnh} enhancements → ${out}`,\n );\n }\n}\n\nconst isMain =\n process.argv[1] &&\n resolve(process.argv[1]).replace(/\\.\\w+$/, \"\") === fileURLToPath(import.meta.url).replace(/\\.\\w+$/, \"\");\nif (isMain) main();\n"]}
@@ -114,6 +114,15 @@ function decodeCanonicalSeed(caseDir) {
114
114
  if (existsSync(lfTextSeed)) {
115
115
  return readFileSync(lfTextSeed, "utf8");
116
116
  }
117
+ // A hand-authored canonical Roster (roster-json) — the lossless carrier used
118
+ // for cases that no upstream format can yet express, e.g. multi-detachment
119
+ // 11e lists. Like the legacy seeds it is import-only (no derived round-trip
120
+ // inputs are generated), since the text/header exporters print every
121
+ // detachment but their importers read only one.
122
+ const rosterJsonSeed = join(caseDir, "input.roster-json.json");
123
+ if (existsSync(rosterJsonSeed)) {
124
+ return JSON.parse(readFileSync(rosterJsonSeed, "utf8"));
125
+ }
117
126
  throw new Error(`no canonical input found in ${caseDir}`);
118
127
  }
119
128
  /** Run a decoded payload through the adapter pipeline up to (but not past)
@@ -169,10 +178,15 @@ function genRosters() {
169
178
  // exporters can't fully preserve, so the round-trip would fail
170
179
  // structurally rather than uncover a parser bug.
171
180
  const isNewRecruitSeed = existsSync(join(caseDir, "input.newrecruit-json.json"));
181
+ // The header text formats (wtc-compact/full, simple) print every detachment
182
+ // but their parsers read only one, so a multi-detachment roster can't
183
+ // round-trip through them — emit the export golden but not the derived
184
+ // round-trip input (the same reasoning as the legacy-ListForge skip).
185
+ const textRoundTrippable = isNewRecruitSeed && seed.detachments.length <= 1;
172
186
  for (const { format, inputName, goldenName } of TEXT_FORMATS) {
173
187
  const out = exportRoster(seed, format);
174
188
  writeText(join(caseDir, goldenName), out);
175
- if (isNewRecruitSeed) {
189
+ if (textRoundTrippable) {
176
190
  writeText(join(caseDir, inputName), out);
177
191
  }
178
192
  }
@@ -915,6 +929,11 @@ function genEffectTranslation() {
915
929
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
916
930
  const seen = new Map();
917
931
  const CAP = 5;
932
+ // Also cover the `applies_to` render path: force-include a few abilities that
933
+ // carry a curated filter even when their effect-node types are already
934
+ // exhausted, so the trailing `Applies to:` line stays pinned cross-impl.
935
+ const APPLIES_CAP = 3;
936
+ let appliesToSeen = 0;
918
937
  const cases = [];
919
938
  for (const a of abilities) {
920
939
  const types = new Set();
@@ -924,16 +943,30 @@ function genEffectTranslation() {
924
943
  if ((seen.get(t) ?? 0) < CAP)
925
944
  fresh = true;
926
945
  }
946
+ const coversAppliesTo = a.raw.applies_to != null && appliesToSeen < APPLIES_CAP;
947
+ if (coversAppliesTo)
948
+ fresh = true;
927
949
  if (!fresh)
928
950
  continue;
929
951
  for (const t of types)
930
952
  seen.set(t, (seen.get(t) ?? 0) + 1);
931
- cases.push({
953
+ if (a.raw.applies_to != null)
954
+ appliesToSeen += 1;
955
+ const entry = {
932
956
  caseId: `${a.id}#${cases.length}`,
933
957
  effect: a.raw.effect,
934
958
  scope: a.raw.scope ?? null,
935
- expected: { text: describeAbility({ effect: a.raw.effect, scope: a.raw.scope }) },
936
- });
959
+ };
960
+ if (a.raw.applies_to != null)
961
+ entry.applies_to = a.raw.applies_to;
962
+ entry.expected = {
963
+ text: describeAbility({
964
+ effect: a.raw.effect,
965
+ scope: a.raw.scope,
966
+ applies_to: a.raw.applies_to,
967
+ }),
968
+ };
969
+ cases.push(entry);
937
970
  }
938
971
  writeJson(join(CONFORMANCE, "effect-translation", "cases.json"), cases);
939
972
  console.log(`effect-translation/cases.json: ${cases.length} cases (${seen.size} node types)`);