@alpaca-software/40kdc-data 0.5.11 → 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/README.md +10 -0
- 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/codegen-data.js +1 -0
- package/dist/codegen-data.js.map +1 -1
- package/dist/commands/import.d.ts.map +1 -1
- package/dist/commands/import.js +3 -1
- package/dist/commands/import.js.map +1 -1
- package/dist/convert-faction.d.ts.map +1 -1
- package/dist/convert-faction.js +22 -2
- package/dist/convert-faction.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/dataset.d.ts +2 -1
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +2 -0
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/entities.d.ts +22 -0
- package/dist/data/entities.d.ts.map +1 -1
- package/dist/data/entities.js +28 -0
- package/dist/data/entities.js.map +1 -1
- package/dist/data/index.d.ts +3 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +3 -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/data/types.d.ts +3 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/types.js +1 -0
- package/dist/data/types.js.map +1 -1
- package/dist/export/newrecruit-json.d.ts.map +1 -1
- package/dist/export/newrecruit-json.js +4 -3
- package/dist/export/newrecruit-json.js.map +1 -1
- package/dist/export/newrecruit-simple.js +2 -2
- package/dist/export/newrecruit-simple.js.map +1 -1
- package/dist/export/newrecruit-wtc.d.ts.map +1 -1
- package/dist/export/newrecruit-wtc.js +3 -1
- package/dist/export/newrecruit-wtc.js.map +1 -1
- package/dist/export/rosterizer.d.ts.map +1 -1
- package/dist/export/rosterizer.js +6 -7
- package/dist/export/rosterizer.js.map +1 -1
- package/dist/extract-faction-pack.d.ts +61 -0
- package/dist/extract-faction-pack.d.ts.map +1 -0
- package/dist/extract-faction-pack.js +318 -0
- package/dist/extract-faction-pack.js.map +1 -0
- package/dist/gen-conformance.js +37 -4
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +46 -2
- 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/gw.js +1 -1
- package/dist/import/gw.js.map +1 -1
- 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/import/listforge-text.js +1 -1
- package/dist/import/listforge-text.js.map +1 -1
- package/dist/import/listforge.d.ts.map +1 -1
- package/dist/import/listforge.js +18 -3
- package/dist/import/listforge.js.map +1 -1
- package/dist/import/newrecruit-json.d.ts.map +1 -1
- package/dist/import/newrecruit-json.js +21 -3
- package/dist/import/newrecruit-json.js.map +1 -1
- package/dist/import/newrecruit-simple.js +1 -1
- package/dist/import/newrecruit-simple.js.map +1 -1
- package/dist/import/newrecruit-wtc.js +1 -1
- package/dist/import/newrecruit-wtc.js.map +1 -1
- package/dist/import/resolve.d.ts.map +1 -1
- package/dist/import/resolve.js +46 -18
- package/dist/import/resolve.js.map +1 -1
- package/dist/import/roster-json.d.ts +5 -3
- package/dist/import/roster-json.d.ts.map +1 -1
- package/dist/import/roster-json.js +8 -4
- package/dist/import/roster-json.js.map +1 -1
- package/dist/import/rosterizer.js +3 -3
- package/dist/import/rosterizer.js.map +1 -1
- package/dist/import/types.d.ts +15 -4
- package/dist/import/types.d.ts.map +1 -1
- package/dist/import/types.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.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/rube-goldberg.js +1 -1
- package/dist/rube-goldberg.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +33 -1
- package/dist/runner.js.map +1 -1
- package/dist/scope.d.ts +31 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +34 -0
- package/dist/scope.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/dist/translate/effect.d.ts +17 -3
- package/dist/translate/effect.d.ts.map +1 -1
- package/dist/translate/effect.js +23 -4
- package/dist/translate/effect.js.map +1 -1
- package/dist/translate/index.d.ts +1 -1
- package/dist/translate/index.d.ts.map +1 -1
- package/dist/translate/index.js +1 -1
- package/dist/translate/index.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +1 -0
- package/dist/validate.js.map +1 -1
- package/package.json +3 -1
- package/schemas/core/detachment.schema.json +6 -0
- package/schemas/core/hull-shape.schema.json +30 -0
- package/schemas/core/roster.schema.json +24 -8
- package/schemas/core/stratagem.schema.json +2 -2
- package/schemas/core/unit-composition.schema.json +7 -0
- package/schemas/enrichment/ability-dsl/ability.schema.json +14 -0
|
@@ -0,0 +1,318 @@
|
|
|
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 { readdirSync, writeFileSync, mkdirSync } from "node:fs";
|
|
35
|
+
import { resolve, basename } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { runPdftotext, allBlocks, slug, titleCase, normCaps, isCapsHeader, sameColumn, stripCp, } from "./pack-blocks.js";
|
|
38
|
+
export { slug, titleCase } from "./pack-blocks.js";
|
|
39
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
40
|
+
const DATA_ROOT = resolve(__dirname, "../../data");
|
|
41
|
+
const OUT_DIR = resolve(DATA_ROOT, "_audit", "faction-pack-input");
|
|
42
|
+
/** Pack-filename fragment → 40kdc faction id. `--all` uses this to route packs. */
|
|
43
|
+
const PACK_FACTION = [
|
|
44
|
+
["space-marines", "adeptus-astartes"],
|
|
45
|
+
["black_templars", "black-templars"],
|
|
46
|
+
["blood_angels", "blood-angels"],
|
|
47
|
+
["dark_angels", "dark-angels"],
|
|
48
|
+
["space_wolves", "space-wolves"],
|
|
49
|
+
["deathwatch", "deathwatch"],
|
|
50
|
+
["grey_knights", "grey-knights"],
|
|
51
|
+
];
|
|
52
|
+
const STRAT_TYPE = {
|
|
53
|
+
"BATTLE TACTIC": "battle-tactic",
|
|
54
|
+
"STRATEGIC PLOY": "strategic-ploy",
|
|
55
|
+
"EPIC DEED": "epic-deed",
|
|
56
|
+
WARGEAR: "wargear",
|
|
57
|
+
};
|
|
58
|
+
const TYPE_RE = /(BATTLE TACTIC|STRATEGIC PLOY|EPIC DEED|WARGEAR)\s+STRATAGEM$/;
|
|
59
|
+
const PHASES = ["command", "movement", "shooting", "charge", "fight"];
|
|
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
|
+
/** Datasheet/table headers that can masquerade as caps "enhancement" names. */
|
|
64
|
+
const DATASHEET_NOISE = new Set([
|
|
65
|
+
"RANGED WEAPONS", "MELEE WEAPONS", "RANGE", "WARGEAR OPTIONS", "UNIT COMPOSITION",
|
|
66
|
+
"KEYWORDS", "LEADER", "TRANSPORT", "DAMAGED", "SUPREME COMMANDER",
|
|
67
|
+
]);
|
|
68
|
+
/** Non-detachment section headers that bound the last detachment's content. */
|
|
69
|
+
const SECTION_MARKER = /^(DATASHEETS|RULES UPDATES|LEGENDS DATASHEETS|IMPERIAL ARMOUR|INDEX)\b/;
|
|
70
|
+
/** Derive phases / player-turn from a `WHEN:` line (the prose itself is discarded). */
|
|
71
|
+
function parseWhen(when) {
|
|
72
|
+
const low = when.toLowerCase();
|
|
73
|
+
const phases = PHASES.filter((p) => low.includes(`${p} phase`));
|
|
74
|
+
let player_turn = null;
|
|
75
|
+
if (/opponent['’]?s?\b/.test(low))
|
|
76
|
+
player_turn = "opponent-turn";
|
|
77
|
+
else if (/\byour\b/.test(low) && phases.length)
|
|
78
|
+
player_turn = "your-turn";
|
|
79
|
+
else if (phases.length)
|
|
80
|
+
player_turn = "either";
|
|
81
|
+
const flag = phases.length ? undefined : "phase-review"; // e.g. "any phase"
|
|
82
|
+
return { phases, player_turn, flag };
|
|
83
|
+
}
|
|
84
|
+
/** Extract stratagems, enhancements, and rule name from a detachment's blocks. */
|
|
85
|
+
export function parseDetachmentBlocks(blocks, detachmentId) {
|
|
86
|
+
const flags = [];
|
|
87
|
+
const labels = blocks.filter((b) => /\bSTRATAGEM$/.test(b.text) && !/^STRATAGEMS?$/.test(b.text));
|
|
88
|
+
const cpBlocks = blocks.filter((b) => /^\d+\s?CP$/.test(b.text));
|
|
89
|
+
const whenBlocks = blocks.filter((b) => /^WHEN:/i.test(b.text));
|
|
90
|
+
// --- detachment rule name: first caps header under a DETACHMENT RULE(S) marker
|
|
91
|
+
let detachment_rule_name = null;
|
|
92
|
+
const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];
|
|
93
|
+
if (ruleHeader) {
|
|
94
|
+
const rn = blocks
|
|
95
|
+
.filter((b) => sameColumn(b.x, ruleHeader.x) && b.gy > ruleHeader.gy && isCapsHeader(b.text))
|
|
96
|
+
.sort((a, b) => a.gy - b.gy)[0];
|
|
97
|
+
if (rn)
|
|
98
|
+
detachment_rule_name = titleCase(rn.text);
|
|
99
|
+
}
|
|
100
|
+
// --- stratagems: keyed off each "<…> <TYPE> STRATAGEM" label block.
|
|
101
|
+
const stratagems = [];
|
|
102
|
+
const seenS = new Set();
|
|
103
|
+
for (const label of labels) {
|
|
104
|
+
const typeM = label.text.match(TYPE_RE);
|
|
105
|
+
const type = typeM ? STRAT_TYPE[typeM[1]] : null;
|
|
106
|
+
// name: nearest caps-header block directly above the label, same column
|
|
107
|
+
const nameBlock = blocks
|
|
108
|
+
.filter((b) => sameColumn(b.x, label.x) && b.gy < label.gy && isCapsHeader(stripCp(b.text).name))
|
|
109
|
+
.sort((a, b) => b.gy - a.gy)[0];
|
|
110
|
+
if (!nameBlock)
|
|
111
|
+
continue;
|
|
112
|
+
const { name, cp: inlineCp } = stripCp(nameBlock.text);
|
|
113
|
+
const id = slug(name);
|
|
114
|
+
if (!id || seenS.has(id))
|
|
115
|
+
continue;
|
|
116
|
+
seenS.add(id);
|
|
117
|
+
// cp: inline on the name block, else the CP block within the card window.
|
|
118
|
+
// Card layouts differ: new detachments put CP at the name's right edge on
|
|
119
|
+
// the name row; reprints put it in the left gutter below the label. So scan
|
|
120
|
+
// a window [name row → just below label] × [card width] and pick the CP
|
|
121
|
+
// nearest the name horizontally (avoids grabbing the adjacent column's CP).
|
|
122
|
+
let cp = inlineCp;
|
|
123
|
+
if (cp === null) {
|
|
124
|
+
const inCard = cpBlocks
|
|
125
|
+
.filter((b) => b.gy >= nameBlock.gy - 15 && b.gy <= label.gy + 70 && b.x >= nameBlock.x - 70 && b.x <= nameBlock.x + 260)
|
|
126
|
+
.sort((a, b) => Math.abs(a.x - nameBlock.x) - Math.abs(b.x - nameBlock.x))[0];
|
|
127
|
+
if (inCard)
|
|
128
|
+
cp = Number(inCard.text.match(/(\d+)/)[1]);
|
|
129
|
+
}
|
|
130
|
+
// phase: the first WHEN: block below the label in the same column
|
|
131
|
+
const when = whenBlocks
|
|
132
|
+
.filter((b) => sameColumn(b.x, label.x) && b.gy >= label.gy)
|
|
133
|
+
.sort((a, b) => a.gy - b.gy)[0];
|
|
134
|
+
const sFlags = [];
|
|
135
|
+
let phases = [];
|
|
136
|
+
let player_turn = null;
|
|
137
|
+
if (when) {
|
|
138
|
+
const w = parseWhen(when.text);
|
|
139
|
+
phases = w.phases;
|
|
140
|
+
player_turn = w.player_turn;
|
|
141
|
+
if (w.flag)
|
|
142
|
+
sFlags.push(w.flag);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
sFlags.push("phase-missing");
|
|
146
|
+
}
|
|
147
|
+
if (!type)
|
|
148
|
+
sFlags.push("type-missing");
|
|
149
|
+
if (cp === null)
|
|
150
|
+
sFlags.push("cp-missing");
|
|
151
|
+
sFlags.push("timing-missing"); // pack rarely states once-per-phase/turn/battle
|
|
152
|
+
stratagems.push({ id, name: titleCase(name), type, cp_cost: cp, phases, player_turn, timing: null, flags: sFlags });
|
|
153
|
+
}
|
|
154
|
+
if (!detachment_rule_name)
|
|
155
|
+
flags.push("rule-name-missing");
|
|
156
|
+
// --- enhancements (subtractive): a detachment page's caps headers are the
|
|
157
|
+
// title, tagline, rule name, section words, stratagem names, type labels,
|
|
158
|
+
// and enhancement names. Remove every known category; the rest are
|
|
159
|
+
// enhancements. Each must be a name+description pair, i.e. followed by a
|
|
160
|
+
// mixed-case prose block in its column. Cost is never printed in the pack.
|
|
161
|
+
const ruleSlug = detachment_rule_name ? slug(detachment_rule_name) : "";
|
|
162
|
+
const hasProseBelow = (b) => blocks
|
|
163
|
+
.filter((o) => sameColumn(o.x, b.x) && o.gy > b.gy)
|
|
164
|
+
.sort((p, q) => p.gy - q.gy)
|
|
165
|
+
.some((o, i) => i === 0 && /[a-z]/.test(o.text)); // nearest block below is prose
|
|
166
|
+
const enhancements = [];
|
|
167
|
+
const seenE = new Set();
|
|
168
|
+
for (const b of blocks) {
|
|
169
|
+
if (!isCapsHeader(b.text) || DATASHEET_NOISE.has(normCaps(b.text)))
|
|
170
|
+
continue;
|
|
171
|
+
const isUpgrade = / UPGRADE$/.test(b.text);
|
|
172
|
+
const name = b.text.replace(/ UPGRADE$/, "").trim();
|
|
173
|
+
const id = slug(name);
|
|
174
|
+
if (!id || seenE.has(id) || seenS.has(id))
|
|
175
|
+
continue; // skip stratagem names
|
|
176
|
+
if (id === detachmentId || id === ruleSlug)
|
|
177
|
+
continue; // skip title / rule name
|
|
178
|
+
if (!hasProseBelow(b))
|
|
179
|
+
continue; // enhancements always have a description
|
|
180
|
+
seenE.add(id);
|
|
181
|
+
enhancements.push({
|
|
182
|
+
id,
|
|
183
|
+
name: titleCase(name),
|
|
184
|
+
cost: null,
|
|
185
|
+
flags: ["cost-missing", "name-needs-review", ...(isUpgrade ? ["upgrade-tag?"] : [])],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (!enhancements.length)
|
|
189
|
+
flags.push("no-enhancements-parsed");
|
|
190
|
+
return { detachment_rule_name, stratagems, enhancements, flags };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Parse the pack's table of contents (first two pages) into the ordered list of
|
|
194
|
+
* detachments with their start pages. Uses non-layout extraction so the two
|
|
195
|
+
* columns don't interleave; dot leaders are private-font glyphs (non-word
|
|
196
|
+
* chars), so a TOC row is `<Name><2+ non-word><page#>`. Entries are kept only
|
|
197
|
+
* between the "Detachments" header and the first non-detachment section.
|
|
198
|
+
*/
|
|
199
|
+
export function parseToc(tocText) {
|
|
200
|
+
const rows = [];
|
|
201
|
+
let inDetachments = false;
|
|
202
|
+
let sawHeader = false;
|
|
203
|
+
for (const line of tocText.split("\n")) {
|
|
204
|
+
const m = line.match(/^\s*([A-Z][A-Za-z0-9'’ \-]+?)[^\w\s]{2,}\s*(\d+)\s*$/);
|
|
205
|
+
if (!m)
|
|
206
|
+
continue;
|
|
207
|
+
const name = m[1].trim();
|
|
208
|
+
const page = Number(m[2]);
|
|
209
|
+
if (/^Detachments$/i.test(name)) {
|
|
210
|
+
inDetachments = true;
|
|
211
|
+
sawHeader = true;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (sawHeader && SECTION_TERMINATORS.test(name)) {
|
|
215
|
+
if (inDetachments)
|
|
216
|
+
rows.push({ name, page, section: true }); // bounds the last detachment
|
|
217
|
+
inDetachments = false;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (inDetachments)
|
|
221
|
+
rows.push({ name, page });
|
|
222
|
+
}
|
|
223
|
+
return rows;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Segment a pack into per-detachment block slices. Detachment names come (ordered)
|
|
227
|
+
* from the TOC; content boundaries come from block positions: each detachment runs
|
|
228
|
+
* from its title block to the next detachment, the next non-detachment section
|
|
229
|
+
* marker, or a ~2-page backstop — whichever comes first. Shared by `extractPack`
|
|
230
|
+
* (names/metadata) and `author-input-pack` (rule bodies) so both segment identically.
|
|
231
|
+
*/
|
|
232
|
+
export function detachmentSegments(pdf) {
|
|
233
|
+
const names = parseToc(runPdftotext(["-f", "1", "-l", "2", pdf, "-"]))
|
|
234
|
+
.filter((t) => !t.section)
|
|
235
|
+
.map((t) => t.name);
|
|
236
|
+
const blocks = allBlocks(pdf);
|
|
237
|
+
// start of each detachment = its title block (exact uppercase name match, first
|
|
238
|
+
// occurrence). Stratagem labels like "NAME – TYPE STRATAGEM" are not exact, so
|
|
239
|
+
// they don't collide with the title.
|
|
240
|
+
const starts = names
|
|
241
|
+
.map((name) => {
|
|
242
|
+
const title = blocks.filter((b) => normCaps(b.text) === normCaps(name)).sort((a, b) => a.gy - b.gy)[0];
|
|
243
|
+
return title ? { name, gy: title.gy } : null;
|
|
244
|
+
})
|
|
245
|
+
.filter((s) => s !== null)
|
|
246
|
+
.sort((a, b) => a.gy - b.gy);
|
|
247
|
+
const sectionGys = blocks.filter((b) => SECTION_MARKER.test(normCaps(b.text))).map((b) => b.gy).sort((a, b) => a - b);
|
|
248
|
+
const segments = [];
|
|
249
|
+
for (let i = 0; i < starts.length; i++) {
|
|
250
|
+
const start = starts[i].gy;
|
|
251
|
+
const nextDet = starts[i + 1]?.gy ?? Infinity;
|
|
252
|
+
const nextSection = sectionGys.find((gy) => gy > start) ?? Infinity;
|
|
253
|
+
// Backstop: a detachment is at most ~2 pages (gy page stride is 100000).
|
|
254
|
+
// Bounds the last detachment when no datasheets/section divider block is
|
|
255
|
+
// detected, so it can't bleed into the datasheet pages that follow.
|
|
256
|
+
const end = Math.min(nextDet, nextSection, start + 200000);
|
|
257
|
+
segments.push({
|
|
258
|
+
id: slug(starts[i].name),
|
|
259
|
+
name: starts[i].name,
|
|
260
|
+
blocks: blocks.filter((b) => b.gy >= start && b.gy < end),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return segments;
|
|
264
|
+
}
|
|
265
|
+
export function extractPack(pdf, faction) {
|
|
266
|
+
const detachments = detachmentSegments(pdf).map((seg) => ({
|
|
267
|
+
id: seg.id,
|
|
268
|
+
name: seg.name,
|
|
269
|
+
...parseDetachmentBlocks(seg.blocks, seg.id),
|
|
270
|
+
}));
|
|
271
|
+
return { faction_id: faction, source_pack: basename(pdf), detachments };
|
|
272
|
+
}
|
|
273
|
+
function factionForPack(file) {
|
|
274
|
+
const lower = file.toLowerCase();
|
|
275
|
+
return PACK_FACTION.find(([frag]) => lower.includes(frag))?.[1];
|
|
276
|
+
}
|
|
277
|
+
function main() {
|
|
278
|
+
const argv = process.argv.slice(2);
|
|
279
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
280
|
+
let jobs = [];
|
|
281
|
+
if (argv[0] === "--all") {
|
|
282
|
+
const dirIdx = argv.indexOf("--dir");
|
|
283
|
+
const dir = dirIdx === -1 ? undefined : argv[dirIdx + 1];
|
|
284
|
+
if (!dir) {
|
|
285
|
+
console.error("Usage: extract-faction-pack.ts --all --dir <pack-pdf-dir>");
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
for (const f of readdirSync(dir)) {
|
|
289
|
+
if (!/faction_pack.*\.pdf$/i.test(f))
|
|
290
|
+
continue;
|
|
291
|
+
const faction = factionForPack(f);
|
|
292
|
+
if (faction)
|
|
293
|
+
jobs.push({ pdf: resolve(dir, f), faction });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const facIdx = argv.indexOf("--faction");
|
|
298
|
+
const pdf = argv[0];
|
|
299
|
+
if (!pdf || facIdx === -1) {
|
|
300
|
+
console.error("Usage: extract-faction-pack.ts <pdf-path> --faction <faction-id>");
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
jobs = [{ pdf: resolve(pdf), faction: argv[facIdx + 1] }];
|
|
304
|
+
}
|
|
305
|
+
for (const { pdf, faction } of jobs) {
|
|
306
|
+
const result = extractPack(pdf, faction);
|
|
307
|
+
const out = resolve(OUT_DIR, `${faction}.json`);
|
|
308
|
+
writeFileSync(out, JSON.stringify(result, null, 2) + "\n");
|
|
309
|
+
const nStrat = result.detachments.reduce((a, d) => a + d.stratagems.length, 0);
|
|
310
|
+
const nEnh = result.detachments.reduce((a, d) => a + d.enhancements.length, 0);
|
|
311
|
+
console.log(` ${faction}: ${result.detachments.length} detachments, ${nStrat} stratagems, ${nEnh} enhancements → ${out}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const isMain = process.argv[1] &&
|
|
315
|
+
resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
|
|
316
|
+
if (isMain)
|
|
317
|
+
main();
|
|
318
|
+
//# 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 { readdirSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { resolve, basename } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport {\n type Block,\n runPdftotext,\n allBlocks,\n slug,\n titleCase,\n normCaps,\n isCapsHeader,\n sameColumn,\n stripCp,\n} from \"./pack-blocks.js\";\n\nexport { slug, titleCase } from \"./pack-blocks.js\";\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;\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/** 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\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\n/** One detachment's slice of the pack: its id, display name, and bounded blocks. */\nexport interface DetachmentSegment {\n id: string;\n name: string;\n blocks: Block[];\n}\n\n/**\n * Segment a pack into per-detachment block slices. Detachment names come (ordered)\n * from the TOC; content boundaries come from block positions: each detachment runs\n * from its title block to the next detachment, the next non-detachment section\n * marker, or a ~2-page backstop — whichever comes first. Shared by `extractPack`\n * (names/metadata) and `author-input-pack` (rule bodies) so both segment identically.\n */\nexport function detachmentSegments(pdf: string): DetachmentSegment[] {\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 segments: DetachmentSegment[] = [];\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 segments.push({\n id: slug(starts[i].name),\n name: starts[i].name,\n blocks: blocks.filter((b) => b.gy >= start && b.gy < end),\n });\n }\n return segments;\n}\n\nexport function extractPack(pdf: string, faction: string): PackExtract {\n const detachments: ExtractedDetachment[] = detachmentSegments(pdf).map((seg) => ({\n id: seg.id,\n name: seg.name,\n ...parseDetachmentBlocks(seg.blocks, seg.id),\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"]}
|
package/dist/gen-conformance.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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)`);
|