@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge IP-safe faction-pack staging (`data/_audit/faction-pack-input/<faction>.json`,
|
|
3
|
+
* produced by `extract-faction-pack.ts`) into `data/core/<faction>/`.
|
|
4
|
+
*
|
|
5
|
+
* For every staged detachment whose *core* entry is still a bare shell — empty
|
|
6
|
+
* `stratagem_ids` AND empty `enhancement_ids` — this creates the core stratagem
|
|
7
|
+
* and enhancement entities from the staged metadata and wires their ids onto the
|
|
8
|
+
* detachment. Detachments that already carry entities (converted from army-assist,
|
|
9
|
+
* or merged earlier) are left untouched, so re-running is additive and safe.
|
|
10
|
+
*
|
|
11
|
+
* The DSL link stays open: every created entity gets `ability_id: null`. Authoring
|
|
12
|
+
* the mechanic happens separately via the `author:*` pipeline.
|
|
13
|
+
*
|
|
14
|
+
* Field provenance (mirrors the hand-merged Space Marine packs):
|
|
15
|
+
* - stratagem: name + cp_cost + phases + player_turn straight from staging; the
|
|
16
|
+
* GW `type` is omitted (faction packs don't print it for new detachments) and
|
|
17
|
+
* `timing` defaults to `once-per-phase` when the pack didn't state a frequency.
|
|
18
|
+
* - enhancement: name from staging; `cost: 0` + `points_provisional: true` until
|
|
19
|
+
* the Munitorum Field Manual lands; `keyword_restrictions` is the faction's
|
|
20
|
+
* army keyword; `upgrade_tag: true` (faction-pack enhancements are upgrades).
|
|
21
|
+
*
|
|
22
|
+
* IP firewall: staging carries names + numeric/enum metadata only — never prose —
|
|
23
|
+
* so nothing this tool writes is GW rules text.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* tsx tools/src/merge-faction-pack.ts <faction-id> [<faction-id> …]
|
|
27
|
+
* tsx tools/src/merge-faction-pack.ts --all-xenos
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { resolve } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
33
|
+
const DATA_ROOT = resolve(__dirname, "../../data");
|
|
34
|
+
/** Faction-id → the army keyword that gates its enhancements. */
|
|
35
|
+
const FACTION_KEYWORD = {
|
|
36
|
+
orks: "Orks",
|
|
37
|
+
aeldari: "Aeldari",
|
|
38
|
+
drukhari: "Drukhari",
|
|
39
|
+
tyranids: "Tyranids",
|
|
40
|
+
"genestealer-cults": "Genestealer Cults",
|
|
41
|
+
necrons: "Necrons",
|
|
42
|
+
"leagues-of-votann": "Leagues of Votann",
|
|
43
|
+
"tau-empire": "T’au Empire",
|
|
44
|
+
};
|
|
45
|
+
const XENOS = Object.keys(FACTION_KEYWORD);
|
|
46
|
+
const GAME_VERSION = { edition: "11th", dataslate: "pre-launch-provisional" };
|
|
47
|
+
function readJson(path) {
|
|
48
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
49
|
+
}
|
|
50
|
+
function writeJson(path, value) {
|
|
51
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
|
|
52
|
+
}
|
|
53
|
+
function mergeFaction(faction) {
|
|
54
|
+
const keyword = FACTION_KEYWORD[faction];
|
|
55
|
+
if (!keyword)
|
|
56
|
+
throw new Error(`no faction keyword mapping for ${faction}`);
|
|
57
|
+
const dir = resolve(DATA_ROOT, "core", faction);
|
|
58
|
+
const staging = readJson(resolve(DATA_ROOT, "_audit", "faction-pack-input", `${faction}.json`));
|
|
59
|
+
const detachments = readJson(resolve(dir, "detachments.json"));
|
|
60
|
+
const stratagems = readJson(resolve(dir, "stratagems.json"));
|
|
61
|
+
const enhancements = readJson(resolve(dir, "enhancements.json"));
|
|
62
|
+
const detById = new Map(detachments.map((d) => [d.id, d]));
|
|
63
|
+
const stratIds = new Set(stratagems.map((s) => s.id));
|
|
64
|
+
const enhIds = new Set(enhancements.map((e) => e.id));
|
|
65
|
+
let addedStrat = 0;
|
|
66
|
+
let addedEnh = 0;
|
|
67
|
+
let wiredDets = 0;
|
|
68
|
+
const skipped = [];
|
|
69
|
+
const renames = [];
|
|
70
|
+
/**
|
|
71
|
+
* A globally-unique id for a new entity. GW reuses stratagem/enhancement names
|
|
72
|
+
* across detachments (e.g. Ork "Long, Uncontrolled Bursts" is in both More Dakka!
|
|
73
|
+
* and Rollin' Deff) — distinct mechanics that collide on the bare name kebab. On
|
|
74
|
+
* collision we detachment-scope the id so existing ids stay stable and the entity
|
|
75
|
+
* collection (keyed by id) never overwrites. The staging entry is renamed to match.
|
|
76
|
+
*/
|
|
77
|
+
function uniqueId(baseId, detId, taken) {
|
|
78
|
+
if (!taken.has(baseId))
|
|
79
|
+
return baseId;
|
|
80
|
+
const scoped = `${detId}-${baseId}`;
|
|
81
|
+
if (taken.has(scoped))
|
|
82
|
+
throw new Error(`cannot disambiguate id ${baseId} for ${detId}`);
|
|
83
|
+
renames.push(`${baseId} → ${scoped}`);
|
|
84
|
+
return scoped;
|
|
85
|
+
}
|
|
86
|
+
for (const sd of staging.detachments) {
|
|
87
|
+
const det = detById.get(sd.id);
|
|
88
|
+
if (!det) {
|
|
89
|
+
skipped.push(`${sd.id} (no core detachment)`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const isBareShell = (det.stratagem_ids ?? []).length === 0 &&
|
|
93
|
+
(det.enhancement_ids ?? []).length === 0;
|
|
94
|
+
if (!isBareShell)
|
|
95
|
+
continue; // already populated — leave it.
|
|
96
|
+
for (const s of sd.stratagems) {
|
|
97
|
+
s.id = uniqueId(s.id, sd.id, stratIds); // mutate staging to stay consistent
|
|
98
|
+
stratagems.push({
|
|
99
|
+
id: s.id,
|
|
100
|
+
name: s.name,
|
|
101
|
+
category: "detachment",
|
|
102
|
+
detachment_id: sd.id,
|
|
103
|
+
cp_cost: s.cp_cost ?? 1,
|
|
104
|
+
phases: s.phases,
|
|
105
|
+
player_turn: s.player_turn ?? "either",
|
|
106
|
+
timing: s.timing ?? "once-per-phase",
|
|
107
|
+
target_restrictions: null,
|
|
108
|
+
ability_id: null,
|
|
109
|
+
game_version: { ...GAME_VERSION },
|
|
110
|
+
});
|
|
111
|
+
stratIds.add(s.id);
|
|
112
|
+
addedStrat++;
|
|
113
|
+
}
|
|
114
|
+
for (const e of sd.enhancements) {
|
|
115
|
+
e.id = uniqueId(e.id, sd.id, enhIds);
|
|
116
|
+
enhancements.push({
|
|
117
|
+
id: e.id,
|
|
118
|
+
name: e.name,
|
|
119
|
+
detachment_id: sd.id,
|
|
120
|
+
cost: e.cost ?? 0,
|
|
121
|
+
keyword_restrictions: [keyword],
|
|
122
|
+
ability_id: null,
|
|
123
|
+
is_unique: true,
|
|
124
|
+
game_version: { ...GAME_VERSION },
|
|
125
|
+
points_provisional: true,
|
|
126
|
+
upgrade_tag: true,
|
|
127
|
+
max_targets: 1,
|
|
128
|
+
});
|
|
129
|
+
enhIds.add(e.id);
|
|
130
|
+
addedEnh++;
|
|
131
|
+
}
|
|
132
|
+
det.stratagem_ids = sd.stratagems.map((s) => s.id);
|
|
133
|
+
det.enhancement_ids = sd.enhancements.map((e) => e.id);
|
|
134
|
+
wiredDets++;
|
|
135
|
+
}
|
|
136
|
+
writeJson(resolve(dir, "detachments.json"), detachments);
|
|
137
|
+
writeJson(resolve(dir, "stratagems.json"), stratagems);
|
|
138
|
+
writeJson(resolve(dir, "enhancements.json"), enhancements);
|
|
139
|
+
// Persist any id renames back to staging so it stays the source for authoring.
|
|
140
|
+
if (renames.length)
|
|
141
|
+
writeJson(resolve(DATA_ROOT, "_audit", "faction-pack-input", `${faction}.json`), staging);
|
|
142
|
+
console.log(`${faction}: wired ${wiredDets} detachment(s), +${addedStrat} stratagems, +${addedEnh} enhancements` +
|
|
143
|
+
(renames.length ? `; renamed ${renames.join("; ")}` : "") +
|
|
144
|
+
(skipped.length ? `; skipped ${skipped.join(", ")}` : ""));
|
|
145
|
+
}
|
|
146
|
+
function main() {
|
|
147
|
+
const args = process.argv.slice(2);
|
|
148
|
+
const factions = args.includes("--all-xenos") ? XENOS : args;
|
|
149
|
+
if (factions.length === 0) {
|
|
150
|
+
console.error("usage: merge-faction-pack.ts <faction-id>… | --all-xenos");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
for (const f of factions)
|
|
154
|
+
mergeFaction(f);
|
|
155
|
+
}
|
|
156
|
+
main();
|
|
157
|
+
//# sourceMappingURL=merge-faction-pack.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge-faction-pack.js","sourceRoot":"","sources":["../src/merge-faction-pack.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Merge IP-safe faction-pack staging (`data/_audit/faction-pack-input/<faction>.json`,\n * produced by `extract-faction-pack.ts`) into `data/core/<faction>/`.\n *\n * For every staged detachment whose *core* entry is still a bare shell — empty\n * `stratagem_ids` AND empty `enhancement_ids` — this creates the core stratagem\n * and enhancement entities from the staged metadata and wires their ids onto the\n * detachment. Detachments that already carry entities (converted from army-assist,\n * or merged earlier) are left untouched, so re-running is additive and safe.\n *\n * The DSL link stays open: every created entity gets `ability_id: null`. Authoring\n * the mechanic happens separately via the `author:*` pipeline.\n *\n * Field provenance (mirrors the hand-merged Space Marine packs):\n * - stratagem: name + cp_cost + phases + player_turn straight from staging; the\n * GW `type` is omitted (faction packs don't print it for new detachments) and\n * `timing` defaults to `once-per-phase` when the pack didn't state a frequency.\n * - enhancement: name from staging; `cost: 0` + `points_provisional: true` until\n * the Munitorum Field Manual lands; `keyword_restrictions` is the faction's\n * army keyword; `upgrade_tag: true` (faction-pack enhancements are upgrades).\n *\n * IP firewall: staging carries names + numeric/enum metadata only — never prose —\n * so nothing this tool writes is GW rules text.\n *\n * Usage:\n * tsx tools/src/merge-faction-pack.ts <faction-id> [<faction-id> …]\n * tsx tools/src/merge-faction-pack.ts --all-xenos\n */\nimport { readFileSync, writeFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\n\n/** Faction-id → the army keyword that gates its enhancements. */\nconst FACTION_KEYWORD: Record<string, string> = {\n orks: \"Orks\",\n aeldari: \"Aeldari\",\n drukhari: \"Drukhari\",\n tyranids: \"Tyranids\",\n \"genestealer-cults\": \"Genestealer Cults\",\n necrons: \"Necrons\",\n \"leagues-of-votann\": \"Leagues of Votann\",\n \"tau-empire\": \"T’au Empire\",\n};\n\nconst XENOS = Object.keys(FACTION_KEYWORD);\n\nconst GAME_VERSION = { edition: \"11th\", dataslate: \"pre-launch-provisional\" } as const;\n\ninterface StagedStratagem {\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}\ninterface StagedEnhancement {\n id: string;\n name: string;\n cost: number | null;\n}\ninterface StagedDetachment {\n id: string;\n name: string;\n detachment_rule_name: string | null;\n stratagems: StagedStratagem[];\n enhancements: StagedEnhancement[];\n}\ninterface Staging {\n faction_id: string;\n detachments: StagedDetachment[];\n}\n\nfunction readJson<T>(path: string): T {\n return JSON.parse(readFileSync(path, \"utf-8\")) as T;\n}\nfunction writeJson(path: string, value: unknown): void {\n writeFileSync(path, JSON.stringify(value, null, 2) + \"\\n\");\n}\n\nfunction mergeFaction(faction: string): void {\n const keyword = FACTION_KEYWORD[faction];\n if (!keyword) throw new Error(`no faction keyword mapping for ${faction}`);\n\n const dir = resolve(DATA_ROOT, \"core\", faction);\n const staging = readJson<Staging>(\n resolve(DATA_ROOT, \"_audit\", \"faction-pack-input\", `${faction}.json`),\n );\n const detachments = readJson<Record<string, unknown>[]>(resolve(dir, \"detachments.json\"));\n const stratagems = readJson<Record<string, unknown>[]>(resolve(dir, \"stratagems.json\"));\n const enhancements = readJson<Record<string, unknown>[]>(resolve(dir, \"enhancements.json\"));\n\n const detById = new Map(detachments.map((d) => [d.id as string, d]));\n const stratIds = new Set(stratagems.map((s) => s.id as string));\n const enhIds = new Set(enhancements.map((e) => e.id as string));\n\n let addedStrat = 0;\n let addedEnh = 0;\n let wiredDets = 0;\n const skipped: string[] = [];\n const renames: string[] = [];\n\n /**\n * A globally-unique id for a new entity. GW reuses stratagem/enhancement names\n * across detachments (e.g. Ork \"Long, Uncontrolled Bursts\" is in both More Dakka!\n * and Rollin' Deff) — distinct mechanics that collide on the bare name kebab. On\n * collision we detachment-scope the id so existing ids stay stable and the entity\n * collection (keyed by id) never overwrites. The staging entry is renamed to match.\n */\n function uniqueId(baseId: string, detId: string, taken: Set<string>): string {\n if (!taken.has(baseId)) return baseId;\n const scoped = `${detId}-${baseId}`;\n if (taken.has(scoped)) throw new Error(`cannot disambiguate id ${baseId} for ${detId}`);\n renames.push(`${baseId} → ${scoped}`);\n return scoped;\n }\n\n for (const sd of staging.detachments) {\n const det = detById.get(sd.id);\n if (!det) {\n skipped.push(`${sd.id} (no core detachment)`);\n continue;\n }\n const isBareShell =\n ((det.stratagem_ids as unknown[]) ?? []).length === 0 &&\n ((det.enhancement_ids as unknown[]) ?? []).length === 0;\n if (!isBareShell) continue; // already populated — leave it.\n\n for (const s of sd.stratagems) {\n s.id = uniqueId(s.id, sd.id, stratIds); // mutate staging to stay consistent\n stratagems.push({\n id: s.id,\n name: s.name,\n category: \"detachment\",\n detachment_id: sd.id,\n cp_cost: s.cp_cost ?? 1,\n phases: s.phases,\n player_turn: s.player_turn ?? \"either\",\n timing: s.timing ?? \"once-per-phase\",\n target_restrictions: null,\n ability_id: null,\n game_version: { ...GAME_VERSION },\n });\n stratIds.add(s.id);\n addedStrat++;\n }\n for (const e of sd.enhancements) {\n e.id = uniqueId(e.id, sd.id, enhIds);\n enhancements.push({\n id: e.id,\n name: e.name,\n detachment_id: sd.id,\n cost: e.cost ?? 0,\n keyword_restrictions: [keyword],\n ability_id: null,\n is_unique: true,\n game_version: { ...GAME_VERSION },\n points_provisional: true,\n upgrade_tag: true,\n max_targets: 1,\n });\n enhIds.add(e.id);\n addedEnh++;\n }\n\n det.stratagem_ids = sd.stratagems.map((s) => s.id);\n det.enhancement_ids = sd.enhancements.map((e) => e.id);\n wiredDets++;\n }\n\n writeJson(resolve(dir, \"detachments.json\"), detachments);\n writeJson(resolve(dir, \"stratagems.json\"), stratagems);\n writeJson(resolve(dir, \"enhancements.json\"), enhancements);\n // Persist any id renames back to staging so it stays the source for authoring.\n if (renames.length)\n writeJson(\n resolve(DATA_ROOT, \"_audit\", \"faction-pack-input\", `${faction}.json`),\n staging,\n );\n\n console.log(\n `${faction}: wired ${wiredDets} detachment(s), +${addedStrat} stratagems, +${addedEnh} enhancements` +\n (renames.length ? `; renamed ${renames.join(\"; \")}` : \"\") +\n (skipped.length ? `; skipped ${skipped.join(\", \")}` : \"\"),\n );\n}\n\nfunction main(): void {\n const args = process.argv.slice(2);\n const factions = args.includes(\"--all-xenos\") ? XENOS : args;\n if (factions.length === 0) {\n console.error(\"usage: merge-faction-pack.ts <faction-id>… | --all-xenos\");\n process.exit(1);\n }\n for (const f of factions) mergeFaction(f);\n}\n\nmain();\n"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** A positioned text block from `pdftotext -bbox-layout`. `gy` is page-globalised y. */
|
|
2
|
+
export interface Block {
|
|
3
|
+
x: number;
|
|
4
|
+
gy: number;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
/** Section headers (caps) that are never card/enhancement names. */
|
|
8
|
+
export declare const SECTION_WORDS: RegExp;
|
|
9
|
+
export declare function runPdftotext(args: string[]): string;
|
|
10
|
+
export declare const decodeEntities: (s: string) => string;
|
|
11
|
+
/**
|
|
12
|
+
* Parse the whole pack's bbox-layout HTML into positioned blocks. `gy` globalises
|
|
13
|
+
* y across pages (pageIndex × 100000 + y) so blocks sort in reading order without
|
|
14
|
+
* relying on printed page numbers (which drift from physical pages on later pages
|
|
15
|
+
* when full-bleed art is inserted).
|
|
16
|
+
*/
|
|
17
|
+
export declare function allBlocks(pdf: string): Block[];
|
|
18
|
+
/** Title/caps name → kebab id, matching existing entity-id conventions. */
|
|
19
|
+
export declare function slug(name: string): string;
|
|
20
|
+
/** Title-case a pack ALL-CAPS header for storage as a display name. */
|
|
21
|
+
export declare function titleCase(raw: string): string;
|
|
22
|
+
export declare const normCaps: (s: string) => string;
|
|
23
|
+
/** A card/enhancement name candidate: a real caps header, not a section word or token. */
|
|
24
|
+
export declare const isCapsHeader: (t: string) => boolean;
|
|
25
|
+
/** Two blocks share a column when their left edges are within tolerance. */
|
|
26
|
+
export declare const sameColumn: (a: number, b: number) => boolean;
|
|
27
|
+
/** Split a trailing "… N CP" off a name block. */
|
|
28
|
+
export declare const stripCp: (t: string) => {
|
|
29
|
+
name: string;
|
|
30
|
+
cp: number | null;
|
|
31
|
+
};
|
|
32
|
+
//# sourceMappingURL=pack-blocks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pack-blocks.d.ts","sourceRoot":"","sources":["../src/pack-blocks.ts"],"names":[],"mappings":"AAsBA,wFAAwF;AACxF,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACd;AAED,oEAAoE;AACpE,eAAO,MAAM,aAAa,QAA4E,CAAC;AAEvG,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CASnD;AAED,eAAO,MAAM,cAAc,GAAI,GAAG,MAAM,KAAG,MAMf,CAAC;AAE7B;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,KAAK,EAAE,CAc9C;AAED,2EAA2E;AAC3E,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOzC;AAED,uEAAuE;AACvE,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQ7C;AAED,eAAO,MAAM,QAAQ,GAAI,GAAG,MAAM,KAAG,MAC8B,CAAC;AAEpE,0FAA0F;AAC1F,eAAO,MAAM,YAAY,GAAI,GAAG,MAAM,KAAG,OAQhB,CAAC;AAE1B,4EAA4E;AAC5E,eAAO,MAAM,UAAU,GAAI,GAAG,MAAM,EAAE,GAAG,MAAM,KAAG,OAA+B,CAAC;AAElF,kDAAkD;AAClD,eAAO,MAAM,OAAO,GAAI,GAAG,MAAM,KAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAA;CAGpE,CAAC"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared bbox-coordinate primitives for parsing GW 11e Faction Pack PDFs.
|
|
3
|
+
*
|
|
4
|
+
* GW lays each card out as discrete, positioned text blocks. `pdftotext
|
|
5
|
+
* -bbox-layout` emits per-word coordinates; we group them into blocks and locate
|
|
6
|
+
* cards by coordinate (column + reading-order y) rather than by the linear text
|
|
7
|
+
* stream — `-layout` interleaves the packs' 2-column stratagem cards into garbage.
|
|
8
|
+
*
|
|
9
|
+
* These primitives are layout-independent (column positions differ across packs)
|
|
10
|
+
* and are shared by two tools:
|
|
11
|
+
* - `extract-faction-pack.ts` — emits names + numeric/enum metadata only (the
|
|
12
|
+
* committed, IP-safe staging file). It never writes prose.
|
|
13
|
+
* - `author-input-pack.ts` — captures each card's rule body (prose) into the
|
|
14
|
+
* gitignored authoring worklist. It is the only consumer that emits prose.
|
|
15
|
+
*
|
|
16
|
+
* Keeping the coordinate machinery here lets both share it without weakening the
|
|
17
|
+
* first tool's "never emits prose" property.
|
|
18
|
+
*
|
|
19
|
+
* Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
/** Section headers (caps) that are never card/enhancement names. */
|
|
23
|
+
export const SECTION_WORDS = /^(DETACHMENT RULES?|ENHANCEMENTS?|STRATAGEMS?|KEYWORDS?|RESTRICTIONS?)$/;
|
|
24
|
+
export function runPdftotext(args) {
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync("pdftotext", args, { encoding: "utf-8", maxBuffer: 128 * 1024 * 1024 });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code === "ENOENT") {
|
|
30
|
+
throw new Error("pdftotext not found — install poppler (e.g. `brew install poppler`).");
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export const decodeEntities = (s) => s
|
|
36
|
+
.replace(/&/g, "&")
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(/"/g, '"');
|
|
41
|
+
/**
|
|
42
|
+
* Parse the whole pack's bbox-layout HTML into positioned blocks. `gy` globalises
|
|
43
|
+
* y across pages (pageIndex × 100000 + y) so blocks sort in reading order without
|
|
44
|
+
* relying on printed page numbers (which drift from physical pages on later pages
|
|
45
|
+
* when full-bleed art is inserted).
|
|
46
|
+
*/
|
|
47
|
+
export function allBlocks(pdf) {
|
|
48
|
+
const html = runPdftotext(["-bbox-layout", pdf, "-"]);
|
|
49
|
+
const blocks = [];
|
|
50
|
+
html.split(/<page\b/).slice(1).forEach((pageHtml, pageIdx) => {
|
|
51
|
+
const offset = pageIdx * 100000;
|
|
52
|
+
for (const m of pageHtml.matchAll(/<block\b([^>]*)>([\s\S]*?)<\/block>/g)) {
|
|
53
|
+
const x = Number(/xMin="([\d.]+)"/.exec(m[1])?.[1] ?? "0");
|
|
54
|
+
const y = Number(/yMin="([\d.]+)"/.exec(m[1])?.[1] ?? "0");
|
|
55
|
+
const words = [...m[2].matchAll(/<word[^>]*>([^<]*)<\/word>/g)].map((w) => decodeEntities(w[1]));
|
|
56
|
+
const text = words.join(" ").replace(/\s+/g, " ").trim();
|
|
57
|
+
if (text)
|
|
58
|
+
blocks.push({ x, gy: offset + y, text });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return blocks;
|
|
62
|
+
}
|
|
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
|
+
export const normCaps = (s) => s.toUpperCase().replace(/[’']/g, "'").replace(/\s+/g, " ").trim();
|
|
83
|
+
/** A card/enhancement name candidate: a real caps header, not a section word or token. */
|
|
84
|
+
export const isCapsHeader = (t) => /^[A-Z0-9][A-Z0-9'’.,!&/()\- ]*$/.test(t) &&
|
|
85
|
+
/[A-Z]{3,}/.test(t) && // a real word, not a "1CP"/"D6" token
|
|
86
|
+
t.length >= 3 &&
|
|
87
|
+
t.length <= 46 &&
|
|
88
|
+
t.split(" ").length <= 7 &&
|
|
89
|
+
!SECTION_WORDS.test(t) &&
|
|
90
|
+
!/^\d+\s?CP$/.test(t) &&
|
|
91
|
+
!/\bSTRATAGEM$/.test(t);
|
|
92
|
+
/** Two blocks share a column when their left edges are within tolerance. */
|
|
93
|
+
export const sameColumn = (a, b) => Math.abs(a - b) < 46;
|
|
94
|
+
/** Split a trailing "… N CP" off a name block. */
|
|
95
|
+
export const stripCp = (t) => {
|
|
96
|
+
const m = t.match(/^(.*?)[\s]+(\d+)\s?CP$/);
|
|
97
|
+
return m ? { name: m[1].trim(), cp: Number(m[2]) } : { name: t, cp: null };
|
|
98
|
+
};
|
|
99
|
+
//# sourceMappingURL=pack-blocks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pack-blocks.js","sourceRoot":"","sources":["../src/pack-blocks.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared bbox-coordinate primitives for parsing GW 11e Faction Pack PDFs.\n *\n * GW lays each card out as discrete, positioned text blocks. `pdftotext\n * -bbox-layout` emits per-word coordinates; we group them into blocks and locate\n * cards by coordinate (column + reading-order y) rather than by the linear text\n * stream — `-layout` interleaves the packs' 2-column stratagem cards into garbage.\n *\n * These primitives are layout-independent (column positions differ across packs)\n * and are shared by two tools:\n * - `extract-faction-pack.ts` — emits names + numeric/enum metadata only (the\n * committed, IP-safe staging file). It never writes prose.\n * - `author-input-pack.ts` — captures each card's rule body (prose) into the\n * gitignored authoring worklist. It is the only consumer that emits prose.\n *\n * Keeping the coordinate machinery here lets both share it without weakening the\n * first tool's \"never emits prose\" property.\n *\n * Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.\n */\nimport { execFileSync } from \"node:child_process\";\n\n/** A positioned text block from `pdftotext -bbox-layout`. `gy` is page-globalised y. */\nexport interface Block {\n x: number;\n gy: number;\n text: string;\n}\n\n/** Section headers (caps) that are never card/enhancement names. */\nexport const SECTION_WORDS = /^(DETACHMENT RULES?|ENHANCEMENTS?|STRATAGEMS?|KEYWORDS?|RESTRICTIONS?)$/;\n\nexport function 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\nexport const decodeEntities = (s: string): string =>\n s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/'/g, \"'\")\n .replace(/"/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 */\nexport function 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\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\nexport const normCaps = (s: string): string =>\n s.toUpperCase().replace(/[’']/g, \"'\").replace(/\\s+/g, \" \").trim();\n\n/** A card/enhancement name candidate: a real caps header, not a section word or token. */\nexport const 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\n/** Two blocks share a column when their left edges are within tolerance. */\nexport const sameColumn = (a: number, b: number): boolean => Math.abs(a - b) < 46;\n\n/** Split a trailing \"… N CP\" off a name block. */\nexport const 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"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seed-detachment-abilities.d.ts","sourceRoot":"","sources":["../src/seed-detachment-abilities.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed empty-modifier DSL stubs for new-detachment abilities — the detachment
|
|
3
|
+
* rule, plus every stratagem and enhancement — so they flow through the normal
|
|
4
|
+
* `author-input → author-batch propose/apply` authoring workflow.
|
|
5
|
+
*
|
|
6
|
+
* The authoring pipeline only fleshes stubs that already exist (it iterates
|
|
7
|
+
* `hasEmptyModifier` entries). The faction-pack merge created the *core*
|
|
8
|
+
* stratagem/enhancement entities with `ability_id: null` and left detachment
|
|
9
|
+
* rules unmodelled, so nothing was authorable. This tool closes that gap:
|
|
10
|
+
*
|
|
11
|
+
* - one stub per core stratagem/enhancement (id = the entity id), and the core
|
|
12
|
+
* entity's `ability_id` is wired to it.
|
|
13
|
+
* - one `ability_type:"detachment"` stub per detachment rule (id = a slug of the
|
|
14
|
+
* `detachment_rule_name` from staging), and `detachment.detachment_rule_id`
|
|
15
|
+
* is wired to it.
|
|
16
|
+
*
|
|
17
|
+
* Scope is the faction-pack staging (`faction-pack-input/<faction>.json`, already
|
|
18
|
+
* trimmed to the new detachments). Idempotent: an ability id that already exists
|
|
19
|
+
* is left untouched (so hand-authored rules survive), and an already-wired core
|
|
20
|
+
* entity is skipped. Real mechanics are authored downstream — the stub effect is
|
|
21
|
+
* the empty-modifier placeholder the pipeline recognises. No GW prose is written.
|
|
22
|
+
*
|
|
23
|
+
* Usage: tsx tools/src/seed-detachment-abilities.ts <faction-id>… | --all-xenos
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
26
|
+
import { resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
29
|
+
const DATA_ROOT = resolve(__dirname, "../../data");
|
|
30
|
+
const XENOS = [
|
|
31
|
+
"orks", "aeldari", "drukhari", "tyranids",
|
|
32
|
+
"genestealer-cults", "necrons", "leagues-of-votann", "tau-empire",
|
|
33
|
+
];
|
|
34
|
+
const GAME_VERSION = { edition: "11th", dataslate: "pre-launch-provisional" };
|
|
35
|
+
const readJson = (p) => JSON.parse(readFileSync(p, "utf-8"));
|
|
36
|
+
const writeJson = (p, v) => writeFileSync(p, JSON.stringify(v, null, 2) + "\n");
|
|
37
|
+
/** kebab-case id slug matching `^[a-z0-9][a-z0-9-]*[a-z0-9]$`. */
|
|
38
|
+
const slug = (s) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "")
|
|
39
|
+
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
40
|
+
function emptyStub(abilityId, name, abilityType, factionId, detachmentId) {
|
|
41
|
+
return {
|
|
42
|
+
ability_id: abilityId,
|
|
43
|
+
name,
|
|
44
|
+
authored_by: "40kdc-community",
|
|
45
|
+
game_version: { ...GAME_VERSION },
|
|
46
|
+
version: "2025-q3",
|
|
47
|
+
supersedes: null,
|
|
48
|
+
unit_ids: [],
|
|
49
|
+
faction_id: factionId,
|
|
50
|
+
detachment_id: detachmentId,
|
|
51
|
+
ability_type: abilityType,
|
|
52
|
+
behavior: "passive",
|
|
53
|
+
effect: { type: "stat-modifier", target: "unit", modifier: {} },
|
|
54
|
+
scope: { range: "unit", duration: "permanent" },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function seedFaction(faction) {
|
|
58
|
+
const stagingPath = resolve(DATA_ROOT, "_audit", "faction-pack-input", `${faction}.json`);
|
|
59
|
+
if (!existsSync(stagingPath)) {
|
|
60
|
+
console.log(`${faction}: no staging file, skipped`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const staging = readJson(stagingPath);
|
|
64
|
+
const dir = resolve(DATA_ROOT, "core", faction);
|
|
65
|
+
const detachments = readJson(resolve(dir, "detachments.json"));
|
|
66
|
+
const stratagems = readJson(resolve(dir, "stratagems.json"));
|
|
67
|
+
const enhancements = readJson(resolve(dir, "enhancements.json"));
|
|
68
|
+
const abilitiesPath = resolve(DATA_ROOT, "enrichment", faction, "abilities.json");
|
|
69
|
+
const abilities = readJson(abilitiesPath);
|
|
70
|
+
const detById = new Map(detachments.map((d) => [d.id, d]));
|
|
71
|
+
const abilityIds = new Set(abilities.map((a) => a.ability_id));
|
|
72
|
+
const stratById = new Map(stratagems.map((s) => [s.id, s]));
|
|
73
|
+
const enhById = new Map(enhancements.map((e) => [e.id, e]));
|
|
74
|
+
let added = 0;
|
|
75
|
+
let wired = 0;
|
|
76
|
+
const ensureStub = (id, name, type, detId) => {
|
|
77
|
+
if (abilityIds.has(id))
|
|
78
|
+
return; // already authored or seeded
|
|
79
|
+
abilities.push(emptyStub(id, name, type, faction, detId));
|
|
80
|
+
abilityIds.add(id);
|
|
81
|
+
added++;
|
|
82
|
+
};
|
|
83
|
+
for (const sd of staging.detachments) {
|
|
84
|
+
const det = detById.get(sd.id);
|
|
85
|
+
if (!det)
|
|
86
|
+
continue;
|
|
87
|
+
// Detachment rule → ability_type "detachment".
|
|
88
|
+
if (sd.detachment_rule_name) {
|
|
89
|
+
let ruleId = slug(sd.detachment_rule_name);
|
|
90
|
+
if (abilityIds.has(ruleId) && det.detachment_rule_id !== ruleId)
|
|
91
|
+
ruleId = `${sd.id}-${ruleId}`;
|
|
92
|
+
ensureStub(ruleId, sd.detachment_rule_name, "detachment", sd.id);
|
|
93
|
+
if (!det.detachment_rule_id) {
|
|
94
|
+
det.detachment_rule_id = ruleId;
|
|
95
|
+
wired++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Stratagems + enhancements → stub per core entity, wire its ability_id.
|
|
99
|
+
for (const s of sd.stratagems ?? []) {
|
|
100
|
+
const core = stratById.get(s.id);
|
|
101
|
+
if (!core)
|
|
102
|
+
continue;
|
|
103
|
+
ensureStub(s.id, core.name, "stratagem", sd.id);
|
|
104
|
+
if (core.ability_id == null) {
|
|
105
|
+
core.ability_id = s.id;
|
|
106
|
+
wired++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const e of sd.enhancements ?? []) {
|
|
110
|
+
const core = enhById.get(e.id);
|
|
111
|
+
if (!core)
|
|
112
|
+
continue;
|
|
113
|
+
ensureStub(e.id, core.name, "enhancement", sd.id);
|
|
114
|
+
if (core.ability_id == null) {
|
|
115
|
+
core.ability_id = e.id;
|
|
116
|
+
wired++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
writeJson(abilitiesPath, abilities);
|
|
121
|
+
writeJson(resolve(dir, "detachments.json"), detachments);
|
|
122
|
+
writeJson(resolve(dir, "stratagems.json"), stratagems);
|
|
123
|
+
writeJson(resolve(dir, "enhancements.json"), enhancements);
|
|
124
|
+
console.log(`${faction}: +${added} stub abilities, wired ${wired} link(s)`);
|
|
125
|
+
}
|
|
126
|
+
function main() {
|
|
127
|
+
const args = process.argv.slice(2);
|
|
128
|
+
const factions = args.includes("--all-xenos") ? XENOS : args;
|
|
129
|
+
if (factions.length === 0) {
|
|
130
|
+
console.error("usage: seed-detachment-abilities.ts <faction-id>… | --all-xenos");
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
for (const f of factions)
|
|
134
|
+
seedFaction(f);
|
|
135
|
+
}
|
|
136
|
+
main();
|
|
137
|
+
//# sourceMappingURL=seed-detachment-abilities.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seed-detachment-abilities.js","sourceRoot":"","sources":["../src/seed-detachment-abilities.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Seed empty-modifier DSL stubs for new-detachment abilities — the detachment\n * rule, plus every stratagem and enhancement — so they flow through the normal\n * `author-input → author-batch propose/apply` authoring workflow.\n *\n * The authoring pipeline only fleshes stubs that already exist (it iterates\n * `hasEmptyModifier` entries). The faction-pack merge created the *core*\n * stratagem/enhancement entities with `ability_id: null` and left detachment\n * rules unmodelled, so nothing was authorable. This tool closes that gap:\n *\n * - one stub per core stratagem/enhancement (id = the entity id), and the core\n * entity's `ability_id` is wired to it.\n * - one `ability_type:\"detachment\"` stub per detachment rule (id = a slug of the\n * `detachment_rule_name` from staging), and `detachment.detachment_rule_id`\n * is wired to it.\n *\n * Scope is the faction-pack staging (`faction-pack-input/<faction>.json`, already\n * trimmed to the new detachments). Idempotent: an ability id that already exists\n * is left untouched (so hand-authored rules survive), and an already-wired core\n * entity is skipped. Real mechanics are authored downstream — the stub effect is\n * the empty-modifier placeholder the pipeline recognises. No GW prose is written.\n *\n * Usage: tsx tools/src/seed-detachment-abilities.ts <faction-id>… | --all-xenos\n */\nimport { readFileSync, writeFileSync, existsSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\n\nconst XENOS = [\n \"orks\", \"aeldari\", \"drukhari\", \"tyranids\",\n \"genestealer-cults\", \"necrons\", \"leagues-of-votann\", \"tau-empire\",\n];\n\nconst GAME_VERSION = { edition: \"11th\", dataslate: \"pre-launch-provisional\" } as const;\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\"));\nconst writeJson = (p: string, v: unknown): void => writeFileSync(p, JSON.stringify(v, null, 2) + \"\\n\");\n\n/** kebab-case id slug matching `^[a-z0-9][a-z0-9-]*[a-z0-9]$`. */\nconst slug = (s: string): string =>\n s.toLowerCase().normalize(\"NFKD\").replace(/[̀-ͯ]/g, \"\")\n .replace(/[^a-z0-9]+/g, \"-\").replace(/^-+|-+$/g, \"\");\n\nfunction emptyStub(\n abilityId: string,\n name: string,\n abilityType: string,\n factionId: string,\n detachmentId: string,\n): Json {\n return {\n ability_id: abilityId,\n name,\n authored_by: \"40kdc-community\",\n game_version: { ...GAME_VERSION },\n version: \"2025-q3\",\n supersedes: null,\n unit_ids: [],\n faction_id: factionId,\n detachment_id: detachmentId,\n ability_type: abilityType,\n behavior: \"passive\",\n effect: { type: \"stat-modifier\", target: \"unit\", modifier: {} },\n scope: { range: \"unit\", duration: \"permanent\" },\n };\n}\n\nfunction seedFaction(faction: string): void {\n const stagingPath = resolve(DATA_ROOT, \"_audit\", \"faction-pack-input\", `${faction}.json`);\n if (!existsSync(stagingPath)) {\n console.log(`${faction}: no staging file, skipped`);\n return;\n }\n const staging = readJson(stagingPath);\n const dir = resolve(DATA_ROOT, \"core\", faction);\n const detachments = readJson(resolve(dir, \"detachments.json\"));\n const stratagems = readJson(resolve(dir, \"stratagems.json\"));\n const enhancements = readJson(resolve(dir, \"enhancements.json\"));\n const abilitiesPath = resolve(DATA_ROOT, \"enrichment\", faction, \"abilities.json\");\n const abilities = readJson(abilitiesPath);\n\n const detById = new Map<string, Json>(detachments.map((d: Json) => [d.id, d]));\n const abilityIds = new Set(abilities.map((a: Json) => a.ability_id as string));\n const stratById = new Map<string, Json>(stratagems.map((s: Json) => [s.id, s]));\n const enhById = new Map<string, Json>(enhancements.map((e: Json) => [e.id, e]));\n\n let added = 0;\n let wired = 0;\n\n const ensureStub = (id: string, name: string, type: string, detId: string): void => {\n if (abilityIds.has(id)) return; // already authored or seeded\n abilities.push(emptyStub(id, name, type, faction, detId));\n abilityIds.add(id);\n added++;\n };\n\n for (const sd of staging.detachments) {\n const det = detById.get(sd.id);\n if (!det) continue;\n\n // Detachment rule → ability_type \"detachment\".\n if (sd.detachment_rule_name) {\n let ruleId = slug(sd.detachment_rule_name);\n if (abilityIds.has(ruleId) && det.detachment_rule_id !== ruleId) ruleId = `${sd.id}-${ruleId}`;\n ensureStub(ruleId, sd.detachment_rule_name, \"detachment\", sd.id);\n if (!det.detachment_rule_id) {\n det.detachment_rule_id = ruleId;\n wired++;\n }\n }\n\n // Stratagems + enhancements → stub per core entity, wire its ability_id.\n for (const s of sd.stratagems ?? []) {\n const core = stratById.get(s.id);\n if (!core) continue;\n ensureStub(s.id, core.name, \"stratagem\", sd.id);\n if (core.ability_id == null) {\n core.ability_id = s.id;\n wired++;\n }\n }\n for (const e of sd.enhancements ?? []) {\n const core = enhById.get(e.id);\n if (!core) continue;\n ensureStub(e.id, core.name, \"enhancement\", sd.id);\n if (core.ability_id == null) {\n core.ability_id = e.id;\n wired++;\n }\n }\n }\n\n writeJson(abilitiesPath, abilities);\n writeJson(resolve(dir, \"detachments.json\"), detachments);\n writeJson(resolve(dir, \"stratagems.json\"), stratagems);\n writeJson(resolve(dir, \"enhancements.json\"), enhancements);\n console.log(`${faction}: +${added} stub abilities, wired ${wired} link(s)`);\n}\n\nfunction main(): void {\n const args = process.argv.slice(2);\n const factions = args.includes(\"--all-xenos\") ? XENOS : args;\n if (factions.length === 0) {\n console.error(\"usage: seed-detachment-abilities.ts <faction-id>… | --all-xenos\");\n process.exit(1);\n }\n for (const f of factions) seedFaction(f);\n}\n\nmain();\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alpaca-software/40kdc-data",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The 40kdc Warhammer 40K dataset behind a linked, typed API — find units, follow them to their weapons, abilities, phases, and factions. Also validates data against the canonical JSON Schemas.",
|
|
6
6
|
"keywords": [
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "npm run codegen:data && tsc",
|
|
56
|
+
"postbuild": "npm run audit:coverage",
|
|
56
57
|
"codegen:data": "tsx src/codegen-data.ts",
|
|
57
58
|
"gen:conformance": "tsx src/gen-conformance.ts",
|
|
58
59
|
"docs:api": "typedoc",
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
"uniqueItems": true,
|
|
28
28
|
"description": "11e: ids of the Force Disposition entities this detachment grants. Empty until assigned."
|
|
29
29
|
},
|
|
30
|
+
"tags": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"items": { "type": "string" },
|
|
33
|
+
"uniqueItems": true,
|
|
34
|
+
"description": "11e: detachment-type tags (e.g. 'dynasty', 'kabal'). A roster may include at most one detachment per shared tag — the 'you can only take one of X type of detachment' rule. Empty when the detachment carries no UNIQUE tag."
|
|
35
|
+
},
|
|
30
36
|
"enhancement_ids": {
|
|
31
37
|
"type": "array",
|
|
32
38
|
"items": { "$ref": "../defs/common.schema.json#/$defs/entity-id" }
|