@alpaca-software/40kdc-data 0.5.12 → 0.5.14
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.
- package/dist/author-input-pack.d.ts +27 -0
- package/dist/author-input-pack.d.ts.map +1 -0
- package/dist/author-input-pack.js +198 -0
- package/dist/author-input-pack.js.map +1 -0
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/index.d.ts +1 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +1 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/roster-resolve.d.ts +16 -1
- package/dist/data/roster-resolve.d.ts.map +1 -1
- package/dist/data/roster-resolve.js +19 -0
- package/dist/data/roster-resolve.js.map +1 -1
- package/dist/extract-faction-pack.d.ts +16 -11
- package/dist/extract-faction-pack.d.ts.map +1 -1
- package/dist/extract-faction-pack.js +24 -80
- package/dist/extract-faction-pack.js.map +1 -1
- package/dist/generated.d.ts +4 -0
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/import/gw-headerless.d.ts +70 -0
- package/dist/import/gw-headerless.d.ts.map +1 -0
- package/dist/import/gw-headerless.js +343 -0
- package/dist/import/gw-headerless.js.map +1 -0
- package/dist/import/import-roster.d.ts.map +1 -1
- package/dist/import/import-roster.js +8 -0
- package/dist/import/import-roster.js.map +1 -1
- package/dist/merge-faction-pack.d.ts +2 -0
- package/dist/merge-faction-pack.d.ts.map +1 -0
- package/dist/merge-faction-pack.js +157 -0
- package/dist/merge-faction-pack.js.map +1 -0
- package/dist/pack-blocks.d.ts +32 -0
- package/dist/pack-blocks.d.ts.map +1 -0
- package/dist/pack-blocks.js +99 -0
- package/dist/pack-blocks.js.map +1 -0
- package/dist/seed-detachment-abilities.d.ts +2 -0
- package/dist/seed-detachment-abilities.d.ts.map +1 -0
- package/dist/seed-detachment-abilities.js +137 -0
- package/dist/seed-detachment-abilities.js.map +1 -0
- package/package.json +2 -1
- package/schemas/core/detachment.schema.json +6 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Block } from "./pack-blocks.js";
|
|
2
|
+
import type { AuthorInputEntry } from "./author-input.js";
|
|
3
|
+
/** The start of one card within a detachment: its id, name, column, and top y. */
|
|
4
|
+
export interface CardAnchor {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
kind: "detachment" | "stratagem" | "enhancement";
|
|
8
|
+
x: number;
|
|
9
|
+
topGy: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Locate every card-start anchor in a detachment's blocks: the detachment rule
|
|
13
|
+
* (under a DETACHMENT RULE(S) header), each stratagem (its name block above a
|
|
14
|
+
* "<NAME> – <TYPE> STRATAGEM" label), and each enhancement (a caps header with a
|
|
15
|
+
* prose body below). Anchors bound each other's bodies by same-column y order.
|
|
16
|
+
*/
|
|
17
|
+
export declare function findAnchors(blocks: Block[]): CardAnchor[];
|
|
18
|
+
/**
|
|
19
|
+
* Capture a card's rule body: every same-column block strictly below the anchor's
|
|
20
|
+
* name/header, up to the next same-column anchor (the next card's start). Joined
|
|
21
|
+
* in reading order. Column-scoping is what keeps the adjacent column's text out —
|
|
22
|
+
* the whole reason bbox beats `-layout` on these 2-column pages.
|
|
23
|
+
*/
|
|
24
|
+
export declare function captureBody(blocks: Block[], anchor: CardAnchor, anchors: CardAnchor[]): string;
|
|
25
|
+
/** Build author-input entries for one faction's new-detachment stubs from its pack. */
|
|
26
|
+
export declare function buildFactionFromPack(faction: string, pdf: string): AuthorInputEntry[];
|
|
27
|
+
//# sourceMappingURL=author-input-pack.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"author-input-pack.d.ts","sourceRoot":"","sources":["../src/author-input-pack.ts"],"names":[],"mappings":"AA8BA,OAAO,EACL,KAAK,KAAK,EAOX,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAW1D,kFAAkF;AAClF,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,GAAG,WAAW,GAAG,aAAa,CAAC;IACjD,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACf;AAkBD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,UAAU,EAAE,CAgDzD;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,CAa9F;AAID,uFAAuF;AACvF,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAmDrF"}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the DSL-authoring input for *new 11e detachments* from a Faction Pack PDF.
|
|
3
|
+
*
|
|
4
|
+
* The stock `author-input.ts` resolves each empty-modifier ability stub to its
|
|
5
|
+
* source rule in the **10e army-assist archive** (unit→datasheet→ability chain).
|
|
6
|
+
* That archive predates the 11e Faction Packs, so the new detachments' rules /
|
|
7
|
+
* stratagems / enhancements aren't in it — the chain returns nothing and the
|
|
8
|
+
* authoring pipeline has no text to classify.
|
|
9
|
+
*
|
|
10
|
+
* This sibling tool fills the gap from the *primary* 11e source: the pack PDF.
|
|
11
|
+
* It captures each card's rule body by bbox coordinate (column-scoped, so the
|
|
12
|
+
* packs' 2-column stratagem layout never interleaves — the failure mode that
|
|
13
|
+
* killed the `-layout` approach) and writes the identical `AuthorInputEntry`
|
|
14
|
+
* shape that `author-batch.ts propose` consumes. Nothing downstream changes.
|
|
15
|
+
*
|
|
16
|
+
* IP firewall: rule prose is captured ONLY into the gitignored authoring worklist
|
|
17
|
+
* `data/_audit/author-input/<faction>.json` (.gitignore). It is never written to
|
|
18
|
+
* the committed `faction-pack-input/` staging (that stays names + metadata only).
|
|
19
|
+
*
|
|
20
|
+
* Scope: only empty-modifier stubs that belong to a detachment (`detachment_id`
|
|
21
|
+
* set) — i.e. the new-detachment rules/stratagems/enhancements the pack covers.
|
|
22
|
+
* Unit-scoped stubs (`detachment_id` null) remain the 10e-archive path's job.
|
|
23
|
+
*
|
|
24
|
+
* Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.
|
|
25
|
+
*
|
|
26
|
+
* Usage: npx tsx tools/src/author-input-pack.ts --pdf <pack.pdf> --faction <id>
|
|
27
|
+
*/
|
|
28
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
29
|
+
import { resolve } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { isCapsHeader, sameColumn, stripCp, slug, titleCase, normCaps, } from "./pack-blocks.js";
|
|
32
|
+
import { detachmentSegments } from "./extract-faction-pack.js";
|
|
33
|
+
import { hasEmptyModifier } from "./audit-coverage.js";
|
|
34
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
35
|
+
const DATA_ROOT = resolve(__dirname, "../../data");
|
|
36
|
+
const ENRICHMENT_ROOT = resolve(DATA_ROOT, "enrichment");
|
|
37
|
+
const OUT_DIR = resolve(DATA_ROOT, "_audit", "author-input");
|
|
38
|
+
const readJSON = (p) => JSON.parse(readFileSync(p, "utf-8"));
|
|
39
|
+
const STRAT_LABEL = /\bSTRATAGEM$/;
|
|
40
|
+
const STRAT_SECTION = /^STRATAGEMS?$/;
|
|
41
|
+
/** Table headers that masquerade as caps "enhancement" names (mirror of extractor). */
|
|
42
|
+
const NOISE = new Set([
|
|
43
|
+
"RANGED WEAPONS", "MELEE WEAPONS", "RANGE", "WARGEAR OPTIONS", "UNIT COMPOSITION",
|
|
44
|
+
"KEYWORDS", "LEADER", "TRANSPORT", "DAMAGED", "SUPREME COMMANDER",
|
|
45
|
+
]);
|
|
46
|
+
/** True when the nearest same-column block below `b` is mixed-case prose. */
|
|
47
|
+
function hasProseBelow(blocks, b) {
|
|
48
|
+
return blocks
|
|
49
|
+
.filter((o) => sameColumn(o.x, b.x) && o.gy > b.gy)
|
|
50
|
+
.sort((p, q) => p.gy - q.gy)
|
|
51
|
+
.some((o, i) => i === 0 && /[a-z]/.test(o.text));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Locate every card-start anchor in a detachment's blocks: the detachment rule
|
|
55
|
+
* (under a DETACHMENT RULE(S) header), each stratagem (its name block above a
|
|
56
|
+
* "<NAME> – <TYPE> STRATAGEM" label), and each enhancement (a caps header with a
|
|
57
|
+
* prose body below). Anchors bound each other's bodies by same-column y order.
|
|
58
|
+
*/
|
|
59
|
+
export function findAnchors(blocks) {
|
|
60
|
+
const anchors = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const push = (a) => {
|
|
63
|
+
if (a.id && !seen.has(a.id)) {
|
|
64
|
+
seen.add(a.id);
|
|
65
|
+
anchors.push(a);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
// detachment rule: first caps header beneath the DETACHMENT RULE(S) marker.
|
|
69
|
+
const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];
|
|
70
|
+
let ruleSlug = "";
|
|
71
|
+
if (ruleHeader) {
|
|
72
|
+
const rn = blocks
|
|
73
|
+
.filter((b) => sameColumn(b.x, ruleHeader.x) && b.gy > ruleHeader.gy && isCapsHeader(b.text))
|
|
74
|
+
.sort((a, b) => a.gy - b.gy)[0];
|
|
75
|
+
if (rn) {
|
|
76
|
+
ruleSlug = slug(rn.text);
|
|
77
|
+
push({ id: ruleSlug, name: titleCase(rn.text), kind: "detachment", x: rn.x, topGy: rn.gy });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// stratagems: name block directly above each "… STRATAGEM" label, same column.
|
|
81
|
+
const stratIds = new Set();
|
|
82
|
+
for (const label of blocks.filter((b) => STRAT_LABEL.test(b.text) && !STRAT_SECTION.test(b.text))) {
|
|
83
|
+
const nameBlock = blocks
|
|
84
|
+
.filter((b) => sameColumn(b.x, label.x) && b.gy < label.gy && isCapsHeader(stripCp(b.text).name))
|
|
85
|
+
.sort((a, b) => b.gy - a.gy)[0];
|
|
86
|
+
if (!nameBlock)
|
|
87
|
+
continue;
|
|
88
|
+
const { name } = stripCp(nameBlock.text);
|
|
89
|
+
const id = slug(name);
|
|
90
|
+
stratIds.add(id);
|
|
91
|
+
push({ id, name: titleCase(name), kind: "stratagem", x: nameBlock.x, topGy: nameBlock.gy });
|
|
92
|
+
}
|
|
93
|
+
// enhancements: remaining caps headers with a prose body below (subtractive,
|
|
94
|
+
// mirroring the extractor) — not stratagem names, the rule, or table noise.
|
|
95
|
+
for (const b of blocks) {
|
|
96
|
+
if (!isCapsHeader(b.text) || NOISE.has(normCaps(b.text)))
|
|
97
|
+
continue;
|
|
98
|
+
const id = slug(b.text.replace(/ UPGRADE$/, "").trim());
|
|
99
|
+
if (!id || id === ruleSlug || stratIds.has(id) || seen.has(id))
|
|
100
|
+
continue;
|
|
101
|
+
if (!hasProseBelow(blocks, b))
|
|
102
|
+
continue;
|
|
103
|
+
const name = titleCase(b.text.replace(/ UPGRADE$/, "").trim());
|
|
104
|
+
push({ id, name, kind: "enhancement", x: b.x, topGy: b.gy });
|
|
105
|
+
}
|
|
106
|
+
return anchors;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Capture a card's rule body: every same-column block strictly below the anchor's
|
|
110
|
+
* name/header, up to the next same-column anchor (the next card's start). Joined
|
|
111
|
+
* in reading order. Column-scoping is what keeps the adjacent column's text out —
|
|
112
|
+
* the whole reason bbox beats `-layout` on these 2-column pages.
|
|
113
|
+
*/
|
|
114
|
+
export function captureBody(blocks, anchor, anchors) {
|
|
115
|
+
const nextTop = Math.min(...anchors
|
|
116
|
+
.filter((a) => a !== anchor && sameColumn(a.x, anchor.x) && a.topGy > anchor.topGy)
|
|
117
|
+
.map((a) => a.topGy), Infinity);
|
|
118
|
+
return blocks
|
|
119
|
+
.filter((b) => sameColumn(b.x, anchor.x) && b.gy > anchor.topGy && b.gy < nextTop)
|
|
120
|
+
.sort((a, b) => a.gy - b.gy)
|
|
121
|
+
.map((b) => b.text)
|
|
122
|
+
.join("\n")
|
|
123
|
+
.trim();
|
|
124
|
+
}
|
|
125
|
+
const normName = (s) => normCaps(s);
|
|
126
|
+
/** Build author-input entries for one faction's new-detachment stubs from its pack. */
|
|
127
|
+
export function buildFactionFromPack(faction, pdf) {
|
|
128
|
+
const abilitiesPath = resolve(ENRICHMENT_ROOT, faction, "abilities.json");
|
|
129
|
+
if (!existsSync(abilitiesPath))
|
|
130
|
+
return [];
|
|
131
|
+
const abilities = readJSON(abilitiesPath);
|
|
132
|
+
// In scope: empty-modifier stubs that belong to a detachment (the new-detachment
|
|
133
|
+
// rules/stratagems/enhancements). Unit-scoped stubs stay the 10e-archive path's.
|
|
134
|
+
const stubs = abilities.filter((a) => a.detachment_id && hasEmptyModifier(a.effect));
|
|
135
|
+
const segments = detachmentSegments(pdf);
|
|
136
|
+
const segById = new Map(segments.map((s) => [s.id, s]));
|
|
137
|
+
const anchorsBySeg = new Map();
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const a of stubs) {
|
|
140
|
+
const base = {
|
|
141
|
+
faction,
|
|
142
|
+
ability_id: a.ability_id,
|
|
143
|
+
name: a.name,
|
|
144
|
+
unit_ids: (a.unit_ids ?? []),
|
|
145
|
+
target: (a.effect && typeof a.effect === "object" && a.effect.target) || null,
|
|
146
|
+
scope: a.scope ?? null,
|
|
147
|
+
faction_id: a.faction_id ?? null,
|
|
148
|
+
ability_type: a.ability_type ?? null,
|
|
149
|
+
};
|
|
150
|
+
const seg = segById.get(a.detachment_id);
|
|
151
|
+
if (!seg) {
|
|
152
|
+
out.push({ ...base, resolved: false, reason: `detachment "${a.detachment_id}" not found in pack` });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
let anchors = anchorsBySeg.get(seg.id);
|
|
156
|
+
if (!anchors)
|
|
157
|
+
anchorsBySeg.set(seg.id, (anchors = findAnchors(seg.blocks)));
|
|
158
|
+
const anchor = anchors.find((c) => c.id === a.ability_id) ??
|
|
159
|
+
anchors.find((c) => normName(c.name) === normName(a.name));
|
|
160
|
+
if (!anchor) {
|
|
161
|
+
out.push({ ...base, resolved: false, reason: `no card matched "${a.name}" in "${a.detachment_id}"` });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const description = captureBody(seg.blocks, anchor, anchors);
|
|
165
|
+
if (!description) {
|
|
166
|
+
out.push({ ...base, resolved: false, reason: `empty body for "${a.name}"` });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
out.push({
|
|
170
|
+
...base,
|
|
171
|
+
resolved: true,
|
|
172
|
+
src: { datasheet_id: "", src_type: null, parameter: null, phases: null, description },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function main() {
|
|
178
|
+
const argv = process.argv.slice(2);
|
|
179
|
+
const pdf = argv[argv.indexOf("--pdf") + 1];
|
|
180
|
+
const faction = argv[argv.indexOf("--faction") + 1];
|
|
181
|
+
if (!argv.includes("--pdf") || !argv.includes("--faction") || !pdf || !faction) {
|
|
182
|
+
console.error("Usage: npx tsx tools/src/author-input-pack.ts --pdf <pack.pdf> --faction <id>");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
const entries = buildFactionFromPack(faction, resolve(pdf));
|
|
186
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
187
|
+
const outPath = resolve(OUT_DIR, `${faction}.json`);
|
|
188
|
+
writeFileSync(outPath, JSON.stringify(entries, null, 2) + "\n");
|
|
189
|
+
const resolved = entries.filter((e) => e.resolved).length;
|
|
190
|
+
console.log(` ${faction}: ${entries.length} stubs, ${resolved} source-resolved → ${outPath}`);
|
|
191
|
+
for (const e of entries.filter((e) => !e.resolved))
|
|
192
|
+
console.log(` unresolved ${e.ability_id}: ${e.reason}`);
|
|
193
|
+
}
|
|
194
|
+
const isMain = process.argv[1] &&
|
|
195
|
+
resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
|
|
196
|
+
if (isMain)
|
|
197
|
+
main();
|
|
198
|
+
//# sourceMappingURL=author-input-pack.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"author-input-pack.js","sourceRoot":"","sources":["../src/author-input-pack.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Build the DSL-authoring input for *new 11e detachments* from a Faction Pack PDF.\n *\n * The stock `author-input.ts` resolves each empty-modifier ability stub to its\n * source rule in the **10e army-assist archive** (unit→datasheet→ability chain).\n * That archive predates the 11e Faction Packs, so the new detachments' rules /\n * stratagems / enhancements aren't in it — the chain returns nothing and the\n * authoring pipeline has no text to classify.\n *\n * This sibling tool fills the gap from the *primary* 11e source: the pack PDF.\n * It captures each card's rule body by bbox coordinate (column-scoped, so the\n * packs' 2-column stratagem layout never interleaves — the failure mode that\n * killed the `-layout` approach) and writes the identical `AuthorInputEntry`\n * shape that `author-batch.ts propose` consumes. Nothing downstream changes.\n *\n * IP firewall: rule prose is captured ONLY into the gitignored authoring worklist\n * `data/_audit/author-input/<faction>.json` (.gitignore). It is never written to\n * the committed `faction-pack-input/` staging (that stays names + metadata only).\n *\n * Scope: only empty-modifier stubs that belong to a detachment (`detachment_id`\n * set) — i.e. the new-detachment rules/stratagems/enhancements the pack covers.\n * Unit-scoped stubs (`detachment_id` null) remain the 10e-archive path's job.\n *\n * Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.\n *\n * Usage: npx tsx tools/src/author-input-pack.ts --pdf <pack.pdf> --faction <id>\n */\nimport { readFileSync, writeFileSync, mkdirSync, existsSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport {\n type Block,\n isCapsHeader,\n sameColumn,\n stripCp,\n slug,\n titleCase,\n normCaps,\n} from \"./pack-blocks.js\";\nimport { detachmentSegments } from \"./extract-faction-pack.js\";\nimport { hasEmptyModifier } from \"./audit-coverage.js\";\nimport type { AuthorInputEntry } from \"./author-input.js\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\nconst ENRICHMENT_ROOT = resolve(DATA_ROOT, \"enrichment\");\nconst OUT_DIR = resolve(DATA_ROOT, \"_audit\", \"author-input\");\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype Json = any;\nconst readJSON = (p: string): Json => JSON.parse(readFileSync(p, \"utf-8\"));\n\n/** The start of one card within a detachment: its id, name, column, and top y. */\nexport interface CardAnchor {\n id: string;\n name: string;\n kind: \"detachment\" | \"stratagem\" | \"enhancement\";\n x: number;\n topGy: number;\n}\n\nconst STRAT_LABEL = /\\bSTRATAGEM$/;\nconst STRAT_SECTION = /^STRATAGEMS?$/;\n/** Table headers that masquerade as caps \"enhancement\" names (mirror of extractor). */\nconst NOISE = new Set([\n \"RANGED WEAPONS\", \"MELEE WEAPONS\", \"RANGE\", \"WARGEAR OPTIONS\", \"UNIT COMPOSITION\",\n \"KEYWORDS\", \"LEADER\", \"TRANSPORT\", \"DAMAGED\", \"SUPREME COMMANDER\",\n]);\n\n/** True when the nearest same-column block below `b` is mixed-case prose. */\nfunction hasProseBelow(blocks: Block[], b: Block): boolean {\n return 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));\n}\n\n/**\n * Locate every card-start anchor in a detachment's blocks: the detachment rule\n * (under a DETACHMENT RULE(S) header), each stratagem (its name block above a\n * \"<NAME> – <TYPE> STRATAGEM\" label), and each enhancement (a caps header with a\n * prose body below). Anchors bound each other's bodies by same-column y order.\n */\nexport function findAnchors(blocks: Block[]): CardAnchor[] {\n const anchors: CardAnchor[] = [];\n const seen = new Set<string>();\n const push = (a: CardAnchor): void => {\n if (a.id && !seen.has(a.id)) {\n seen.add(a.id);\n anchors.push(a);\n }\n };\n\n // detachment rule: first caps header beneath the DETACHMENT RULE(S) marker.\n const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];\n let ruleSlug = \"\";\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) {\n ruleSlug = slug(rn.text);\n push({ id: ruleSlug, name: titleCase(rn.text), kind: \"detachment\", x: rn.x, topGy: rn.gy });\n }\n }\n\n // stratagems: name block directly above each \"… STRATAGEM\" label, same column.\n const stratIds = new Set<string>();\n for (const label of blocks.filter((b) => STRAT_LABEL.test(b.text) && !STRAT_SECTION.test(b.text))) {\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 } = stripCp(nameBlock.text);\n const id = slug(name);\n stratIds.add(id);\n push({ id, name: titleCase(name), kind: \"stratagem\", x: nameBlock.x, topGy: nameBlock.gy });\n }\n\n // enhancements: remaining caps headers with a prose body below (subtractive,\n // mirroring the extractor) — not stratagem names, the rule, or table noise.\n for (const b of blocks) {\n if (!isCapsHeader(b.text) || NOISE.has(normCaps(b.text))) continue;\n const id = slug(b.text.replace(/ UPGRADE$/, \"\").trim());\n if (!id || id === ruleSlug || stratIds.has(id) || seen.has(id)) continue;\n if (!hasProseBelow(blocks, b)) continue;\n const name = titleCase(b.text.replace(/ UPGRADE$/, \"\").trim());\n push({ id, name, kind: \"enhancement\", x: b.x, topGy: b.gy });\n }\n\n return anchors;\n}\n\n/**\n * Capture a card's rule body: every same-column block strictly below the anchor's\n * name/header, up to the next same-column anchor (the next card's start). Joined\n * in reading order. Column-scoping is what keeps the adjacent column's text out —\n * the whole reason bbox beats `-layout` on these 2-column pages.\n */\nexport function captureBody(blocks: Block[], anchor: CardAnchor, anchors: CardAnchor[]): string {\n const nextTop = Math.min(\n ...anchors\n .filter((a) => a !== anchor && sameColumn(a.x, anchor.x) && a.topGy > anchor.topGy)\n .map((a) => a.topGy),\n Infinity,\n );\n return blocks\n .filter((b) => sameColumn(b.x, anchor.x) && b.gy > anchor.topGy && b.gy < nextTop)\n .sort((a, b) => a.gy - b.gy)\n .map((b) => b.text)\n .join(\"\\n\")\n .trim();\n}\n\nconst normName = (s: string): string => normCaps(s);\n\n/** Build author-input entries for one faction's new-detachment stubs from its pack. */\nexport function buildFactionFromPack(faction: string, pdf: string): AuthorInputEntry[] {\n const abilitiesPath = resolve(ENRICHMENT_ROOT, faction, \"abilities.json\");\n if (!existsSync(abilitiesPath)) return [];\n const abilities: Json[] = readJSON(abilitiesPath);\n\n // In scope: empty-modifier stubs that belong to a detachment (the new-detachment\n // rules/stratagems/enhancements). Unit-scoped stubs stay the 10e-archive path's.\n const stubs = abilities.filter((a) => a.detachment_id && hasEmptyModifier(a.effect));\n\n const segments = detachmentSegments(pdf);\n const segById = new Map(segments.map((s) => [s.id, s]));\n const anchorsBySeg = new Map<string, CardAnchor[]>();\n\n const out: AuthorInputEntry[] = [];\n for (const a of stubs) {\n const base = {\n faction,\n ability_id: a.ability_id as string,\n name: a.name as string,\n unit_ids: (a.unit_ids ?? []) as string[],\n target: (a.effect && typeof a.effect === \"object\" && a.effect.target) || null,\n scope: a.scope ?? null,\n faction_id: a.faction_id ?? null,\n ability_type: a.ability_type ?? null,\n };\n const seg = segById.get(a.detachment_id);\n if (!seg) {\n out.push({ ...base, resolved: false, reason: `detachment \"${a.detachment_id}\" not found in pack` });\n continue;\n }\n let anchors = anchorsBySeg.get(seg.id);\n if (!anchors) anchorsBySeg.set(seg.id, (anchors = findAnchors(seg.blocks)));\n const anchor =\n anchors.find((c) => c.id === a.ability_id) ??\n anchors.find((c) => normName(c.name) === normName(a.name));\n if (!anchor) {\n out.push({ ...base, resolved: false, reason: `no card matched \"${a.name}\" in \"${a.detachment_id}\"` });\n continue;\n }\n const description = captureBody(seg.blocks, anchor, anchors);\n if (!description) {\n out.push({ ...base, resolved: false, reason: `empty body for \"${a.name}\"` });\n continue;\n }\n out.push({\n ...base,\n resolved: true,\n src: { datasheet_id: \"\", src_type: null, parameter: null, phases: null, description },\n });\n }\n return out;\n}\n\nfunction main(): void {\n const argv = process.argv.slice(2);\n const pdf = argv[argv.indexOf(\"--pdf\") + 1];\n const faction = argv[argv.indexOf(\"--faction\") + 1];\n if (!argv.includes(\"--pdf\") || !argv.includes(\"--faction\") || !pdf || !faction) {\n console.error(\"Usage: npx tsx tools/src/author-input-pack.ts --pdf <pack.pdf> --faction <id>\");\n process.exit(1);\n }\n\n const entries = buildFactionFromPack(faction, resolve(pdf));\n mkdirSync(OUT_DIR, { recursive: true });\n const outPath = resolve(OUT_DIR, `${faction}.json`);\n writeFileSync(outPath, JSON.stringify(entries, null, 2) + \"\\n\");\n const resolved = entries.filter((e) => e.resolved).length;\n console.log(` ${faction}: ${entries.length} stubs, ${resolved} source-resolved → ${outPath}`);\n for (const e of entries.filter((e) => !e.resolved)) console.log(` unresolved ${e.ability_id}: ${e.reason}`);\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"]}
|