@alpaca-software/40kdc-data 0.5.12 → 0.5.15
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/abilities-resolver/resolver.d.ts.map +1 -1
- package/dist/abilities-resolver/resolver.js +6 -2
- package/dist/abilities-resolver/resolver.js.map +1 -1
- package/dist/audit-coverage.d.ts.map +1 -1
- package/dist/audit-coverage.js +90 -1
- package/dist/audit-coverage.js.map +1 -1
- package/dist/audit-phrasing.d.ts +64 -0
- package/dist/audit-phrasing.d.ts.map +1 -0
- package/dist/audit-phrasing.js +208 -0
- package/dist/audit-phrasing.js.map +1 -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/build-share-registry.d.ts +2 -0
- package/dist/build-share-registry.d.ts.map +1 -0
- package/dist/build-share-registry.js +115 -0
- package/dist/build-share-registry.js.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/codegen-data.js +23 -0
- package/dist/codegen-data.js.map +1 -1
- package/dist/commands/translate.d.ts.map +1 -1
- package/dist/commands/translate.js +2 -5
- package/dist/commands/translate.js.map +1 -1
- package/dist/commands/validate-all.d.ts.map +1 -1
- package/dist/commands/validate-all.js +8 -3
- package/dist/commands/validate-all.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 +33 -1
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +107 -1
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +5 -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/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/gen-conformance.js +162 -0
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +129 -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/integrity.d.ts +32 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +120 -0
- package/dist/integrity.js.map +1 -0
- 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/report.d.ts +1 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +2 -2
- package/dist/report.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +42 -0
- package/dist/runner.js.map +1 -1
- 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/share/codec.d.ts +83 -0
- package/dist/share/codec.d.ts.map +1 -0
- package/dist/share/codec.js +230 -0
- package/dist/share/codec.js.map +1 -0
- package/dist/share/index.d.ts +30 -0
- package/dist/share/index.d.ts.map +1 -0
- package/dist/share/index.js +36 -0
- package/dist/share/index.js.map +1 -0
- package/dist/share/registry.d.ts +42 -0
- package/dist/share/registry.d.ts.map +1 -0
- package/dist/share/registry.generated.d.ts +4 -0
- package/dist/share/registry.generated.d.ts.map +1 -0
- package/dist/share/registry.generated.js +4 -0
- package/dist/share/registry.generated.js.map +1 -0
- package/dist/share/registry.js +68 -0
- package/dist/share/registry.js.map +1 -0
- package/dist/translate/condition.d.ts +1 -0
- package/dist/translate/condition.d.ts.map +1 -1
- package/dist/translate/condition.js +101 -7
- package/dist/translate/condition.js.map +1 -1
- package/dist/translate/effect.d.ts +28 -20
- package/dist/translate/effect.d.ts.map +1 -1
- package/dist/translate/effect.js +483 -114
- package/dist/translate/effect.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 +4 -1
- package/schemas/core/allied-rule.schema.json +115 -0
- package/schemas/core/detachment.schema.json +44 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { glob } from "glob";
|
|
3
|
+
import { resolve, basename, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
6
|
+
const DATA_ROOT = resolve(__dirname, "../../data");
|
|
7
|
+
/**
|
|
8
|
+
* Factions whose units carry exactly one bare legion/faction faction_keyword.
|
|
9
|
+
*
|
|
10
|
+
* These are the shared-datasheet factions where contamination has been observed:
|
|
11
|
+
* a unit materialized from a parent template keeps the parent's faction keyword
|
|
12
|
+
* (e.g. a World Eaters Chaos Rhino left carrying `Emperor's Children`, or a Chaos
|
|
13
|
+
* Space Marines unit carrying the full multi-legion union line). For a faction in
|
|
14
|
+
* this map, every unit's `faction_keywords` must be a subset of `{home}`.
|
|
15
|
+
*
|
|
16
|
+
* Factions absent from this map (e.g. Space Marine chapters, which legitimately
|
|
17
|
+
* carry several faction_keywords) are not subject to the membership check, so the
|
|
18
|
+
* guard never produces a false positive on them. Extend this map as other
|
|
19
|
+
* single-token factions adopt the convention.
|
|
20
|
+
*/
|
|
21
|
+
export const FACTION_HOME_KEYWORD = {
|
|
22
|
+
"chaos-space-marines": "Heretic Astartes",
|
|
23
|
+
"world-eaters": "World Eaters",
|
|
24
|
+
"death-guard": "Death Guard",
|
|
25
|
+
"thousand-sons": "Thousand Sons",
|
|
26
|
+
"emperors-children": "Emperor’s Children",
|
|
27
|
+
};
|
|
28
|
+
function readArray(file) {
|
|
29
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
function loadAbilityIds(file, into) {
|
|
32
|
+
try {
|
|
33
|
+
for (const a of readArray(file)) {
|
|
34
|
+
if (a.ability_id)
|
|
35
|
+
into.add(a.ability_id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// file absent — faction has no enrichment abilities, or no shared core pool
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Cross-entity referential integrity that per-file JSON Schema validation cannot
|
|
44
|
+
* express:
|
|
45
|
+
*
|
|
46
|
+
* - every unit `ability_id` must resolve to an ability defined in that faction's
|
|
47
|
+
* `enrichment/<faction>/abilities.json` (or the shared `enrichment/_core` pool).
|
|
48
|
+
* Same-faction scoping is deliberate — a union check would pass shared-unit
|
|
49
|
+
* contaminants because they happen to be defined in some *other* faction's
|
|
50
|
+
* enrichment.
|
|
51
|
+
* - every unit `faction_keywords` entry must be permitted for the unit's faction
|
|
52
|
+
* (see {@link FACTION_HOME_KEYWORD}).
|
|
53
|
+
*
|
|
54
|
+
* Results reuse {@link ValidationResult} so the CLI reporter can render them.
|
|
55
|
+
*/
|
|
56
|
+
export async function checkReferentialIntegrity(dataRoot) {
|
|
57
|
+
const root = dataRoot ?? DATA_ROOT;
|
|
58
|
+
const result = {
|
|
59
|
+
totalFiles: 0,
|
|
60
|
+
totalItems: 0,
|
|
61
|
+
passed: 0,
|
|
62
|
+
failed: 0,
|
|
63
|
+
errors: [],
|
|
64
|
+
};
|
|
65
|
+
// Shared core ability pool, available to every faction (optional).
|
|
66
|
+
const coreAbilities = new Set();
|
|
67
|
+
loadAbilityIds(resolve(root, "enrichment/_core/abilities.json"), coreAbilities);
|
|
68
|
+
const unitFiles = await glob("core/*/units.json", { cwd: root, absolute: true });
|
|
69
|
+
unitFiles.sort();
|
|
70
|
+
for (const file of unitFiles) {
|
|
71
|
+
const faction = basename(dirname(file));
|
|
72
|
+
if (faction.startsWith("_"))
|
|
73
|
+
continue; // scratch/example/report dirs
|
|
74
|
+
let units;
|
|
75
|
+
try {
|
|
76
|
+
units = readArray(file);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
continue; // structural problems are the AJV pass's job
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(units))
|
|
82
|
+
continue;
|
|
83
|
+
const defined = new Set(coreAbilities);
|
|
84
|
+
loadAbilityIds(resolve(root, `enrichment/${faction}/abilities.json`), defined);
|
|
85
|
+
const home = FACTION_HOME_KEYWORD[faction];
|
|
86
|
+
result.totalFiles++;
|
|
87
|
+
for (let i = 0; i < units.length; i++) {
|
|
88
|
+
const u = units[i];
|
|
89
|
+
result.totalItems++;
|
|
90
|
+
const errs = [];
|
|
91
|
+
for (const aid of u.ability_ids ?? []) {
|
|
92
|
+
if (!defined.has(aid)) {
|
|
93
|
+
errs.push({
|
|
94
|
+
path: `/${i}/ability_ids`,
|
|
95
|
+
message: `unit "${u.id}": ability_id "${aid}" is not defined in ${faction} enrichment`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (home !== undefined) {
|
|
100
|
+
for (const fk of u.faction_keywords ?? []) {
|
|
101
|
+
if (fk !== home) {
|
|
102
|
+
errs.push({
|
|
103
|
+
path: `/${i}/faction_keywords`,
|
|
104
|
+
message: `unit "${u.id}": faction_keyword "${fk}" is not permitted for ${faction} (expected only "${home}")`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (errs.length > 0) {
|
|
110
|
+
result.failed++;
|
|
111
|
+
result.errors.push({ file, index: i, errors: errs });
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
result.passed++;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=integrity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integrity.js","sourceRoot":"","sources":["../src/integrity.ts"],"names":[],"mappings":"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","sourcesContent":["import { readFileSync } from \"node:fs\";\nimport { glob } from \"glob\";\nimport { resolve, basename, dirname } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport type { ValidationResult } from \"./validate.js\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\n\n/**\n * Factions whose units carry exactly one bare legion/faction faction_keyword.\n *\n * These are the shared-datasheet factions where contamination has been observed:\n * a unit materialized from a parent template keeps the parent's faction keyword\n * (e.g. a World Eaters Chaos Rhino left carrying `Emperor's Children`, or a Chaos\n * Space Marines unit carrying the full multi-legion union line). For a faction in\n * this map, every unit's `faction_keywords` must be a subset of `{home}`.\n *\n * Factions absent from this map (e.g. Space Marine chapters, which legitimately\n * carry several faction_keywords) are not subject to the membership check, so the\n * guard never produces a false positive on them. Extend this map as other\n * single-token factions adopt the convention.\n */\nexport const FACTION_HOME_KEYWORD: Record<string, string> = {\n \"chaos-space-marines\": \"Heretic Astartes\",\n \"world-eaters\": \"World Eaters\",\n \"death-guard\": \"Death Guard\",\n \"thousand-sons\": \"Thousand Sons\",\n \"emperors-children\": \"Emperor’s Children\",\n};\n\ninterface UnitLike {\n id?: string;\n ability_ids?: string[];\n faction_keywords?: string[];\n}\ninterface AbilityLike {\n ability_id?: string;\n}\n\nfunction readArray<T>(file: string): T[] {\n return JSON.parse(readFileSync(file, \"utf-8\")) as T[];\n}\n\nfunction loadAbilityIds(file: string, into: Set<string>): void {\n try {\n for (const a of readArray<AbilityLike>(file)) {\n if (a.ability_id) into.add(a.ability_id);\n }\n } catch {\n // file absent — faction has no enrichment abilities, or no shared core pool\n }\n}\n\n/**\n * Cross-entity referential integrity that per-file JSON Schema validation cannot\n * express:\n *\n * - every unit `ability_id` must resolve to an ability defined in that faction's\n * `enrichment/<faction>/abilities.json` (or the shared `enrichment/_core` pool).\n * Same-faction scoping is deliberate — a union check would pass shared-unit\n * contaminants because they happen to be defined in some *other* faction's\n * enrichment.\n * - every unit `faction_keywords` entry must be permitted for the unit's faction\n * (see {@link FACTION_HOME_KEYWORD}).\n *\n * Results reuse {@link ValidationResult} so the CLI reporter can render them.\n */\nexport async function checkReferentialIntegrity(dataRoot?: string): Promise<ValidationResult> {\n const root = dataRoot ?? DATA_ROOT;\n const result: ValidationResult = {\n totalFiles: 0,\n totalItems: 0,\n passed: 0,\n failed: 0,\n errors: [],\n };\n\n // Shared core ability pool, available to every faction (optional).\n const coreAbilities = new Set<string>();\n loadAbilityIds(resolve(root, \"enrichment/_core/abilities.json\"), coreAbilities);\n\n const unitFiles = await glob(\"core/*/units.json\", { cwd: root, absolute: true });\n unitFiles.sort();\n\n for (const file of unitFiles) {\n const faction = basename(dirname(file));\n if (faction.startsWith(\"_\")) continue; // scratch/example/report dirs\n\n let units: UnitLike[];\n try {\n units = readArray<UnitLike>(file);\n } catch {\n continue; // structural problems are the AJV pass's job\n }\n if (!Array.isArray(units)) continue;\n\n const defined = new Set<string>(coreAbilities);\n loadAbilityIds(resolve(root, `enrichment/${faction}/abilities.json`), defined);\n const home = FACTION_HOME_KEYWORD[faction];\n\n result.totalFiles++;\n for (let i = 0; i < units.length; i++) {\n const u = units[i];\n result.totalItems++;\n const errs: Array<{ path: string; message: string }> = [];\n\n for (const aid of u.ability_ids ?? []) {\n if (!defined.has(aid)) {\n errs.push({\n path: `/${i}/ability_ids`,\n message: `unit \"${u.id}\": ability_id \"${aid}\" is not defined in ${faction} enrichment`,\n });\n }\n }\n\n if (home !== undefined) {\n for (const fk of u.faction_keywords ?? []) {\n if (fk !== home) {\n errs.push({\n path: `/${i}/faction_keywords`,\n message: `unit \"${u.id}\": faction_keyword \"${fk}\" is not permitted for ${faction} (expected only \"${home}\")`,\n });\n }\n }\n }\n\n if (errs.length > 0) {\n result.failed++;\n result.errors.push({ file, index: i, errors: errs });\n } else {\n result.passed++;\n }\n }\n }\n\n return result;\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge-faction-pack.d.ts","sourceRoot":"","sources":["../src/merge-faction-pack.ts"],"names":[],"mappings":""}
|
|
@@ -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"]}
|
package/dist/report.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ValidationResult } from "./validate.js";
|
|
2
2
|
export type ReporterMode = "pretty" | "json";
|
|
3
|
-
export declare function formatReport(result: ValidationResult, mode: ReporterMode): string;
|
|
3
|
+
export declare function formatReport(result: ValidationResult, mode: ReporterMode, title?: string): string;
|
|
4
4
|
//# sourceMappingURL=report.d.ts.map
|
package/dist/report.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE7C,wBAAgB,YAAY,
|
|
1
|
+
{"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE7C,wBAAgB,YAAY,CAC1B,MAAM,EAAE,gBAAgB,EACxB,IAAI,EAAE,YAAY,EAClB,KAAK,SAAiC,GACrC,MAAM,CAgCR"}
|
package/dist/report.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
export function formatReport(result, mode) {
|
|
2
|
+
export function formatReport(result, mode, title = "40kdc Data Validation Report") {
|
|
3
3
|
if (mode === "json") {
|
|
4
4
|
return JSON.stringify(result, null, 2);
|
|
5
5
|
}
|
|
6
6
|
const lines = [];
|
|
7
7
|
lines.push("");
|
|
8
|
-
lines.push(chalk.bold(
|
|
8
|
+
lines.push(chalk.bold(title));
|
|
9
9
|
lines.push(chalk.gray("─".repeat(40)));
|
|
10
10
|
lines.push(`Files scanned: ${result.totalFiles}`);
|
|
11
11
|
lines.push(`Items validated: ${result.totalItems}`);
|
package/dist/report.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"report.js","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,MAAM,UAAU,YAAY,
|
|
1
|
+
{"version":3,"file":"report.js","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAK1B,MAAM,UAAU,YAAY,CAC1B,MAAwB,EACxB,IAAkB,EAClB,KAAK,GAAG,8BAA8B;IAEtC,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;QACpB,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IACzC,CAAC;IAED,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACf,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IAC9B,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACvC,KAAK,CAAC,IAAI,CAAC,mBAAmB,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC;IACnD,KAAK,CAAC,IAAI,CAAC,oBAAoB,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC;IACpD,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAEpD,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QAClD,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAEf,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YAChC,MAAM,GAAG,GAAG,GAAG,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YACnD,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,OAAO,GAAG,CAAC,IAAI,GAAG,GAAG,EAAE,CAAC,CAAC,CAAC;YAC/C,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;gBAC3B,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;YAC1D,CAAC;QACH,CAAC;IACH,CAAC;SAAM,CAAC;QACN,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC;QACrC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,yBAAyB,CAAC,CAAC,CAAC;IACrD,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACf,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC","sourcesContent":["import chalk from \"chalk\";\nimport type { ValidationResult } from \"./validate.js\";\n\nexport type ReporterMode = \"pretty\" | \"json\";\n\nexport function formatReport(\n result: ValidationResult,\n mode: ReporterMode,\n title = \"40kdc Data Validation Report\",\n): string {\n if (mode === \"json\") {\n return JSON.stringify(result, null, 2);\n }\n\n const lines: string[] = [];\n lines.push(\"\");\n lines.push(chalk.bold(title));\n lines.push(chalk.gray(\"─\".repeat(40)));\n lines.push(`Files scanned: ${result.totalFiles}`);\n lines.push(`Items validated: ${result.totalItems}`);\n lines.push(chalk.green(`Passed: ${result.passed}`));\n\n if (result.failed > 0) {\n lines.push(chalk.red(`Failed: ${result.failed}`));\n lines.push(\"\");\n\n for (const err of result.errors) {\n const loc = err.index >= 0 ? `[${err.index}]` : \"\";\n lines.push(chalk.red(` ✗ ${err.file}${loc}`));\n for (const e of err.errors) {\n lines.push(chalk.yellow(` ${e.path}: ${e.message}`));\n }\n }\n } else {\n lines.push(chalk.green(`Failed: 0`));\n lines.push(\"\");\n lines.push(chalk.green(\"All validations passed.\"));\n }\n\n lines.push(\"\");\n return lines.join(\"\\n\");\n}\n"]}
|
package/dist/runner.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":";AAsBA,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":";AAsBA,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAuC5C,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AA8C3B,MAAM,MAAM,cAAc,GACtB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAC5B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,UAAU,EAAE,SAAS,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAElE,QAAA,MAAM,WAAW,oJASP,CAAC;AACX,KAAK,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAgB9C,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,GAAG,CAAC;CACjB;AAED,wBAAgB,iBAAiB,IAAI,WAAW,CAE/C;AAgKD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,CAAC,EACG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GACrG,IAAI,GACJ,SAAS,GACZ,MAAM,GAAG,IAAI,CAQf;AAmnBD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,cAAc,CAsDhG;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc9E"}
|
package/dist/runner.js
CHANGED
|
@@ -23,6 +23,7 @@ import { Dataset } from "./data/dataset.js";
|
|
|
23
23
|
import { normalizeName } from "./data/normalize.js";
|
|
24
24
|
import { maximalLoadout } from "./data/loadout.js";
|
|
25
25
|
import { exportRoster } from "./export/index.js";
|
|
26
|
+
import { decodeShareToken, encodeShareToken } from "./share/index.js";
|
|
26
27
|
import { importRoster, tryImportRoster } from "./import/import-roster.js";
|
|
27
28
|
import { createValidator } from "./schema-loader.js";
|
|
28
29
|
import { attributeStages, crunch } from "./cruncher/index.js";
|
|
@@ -215,6 +216,34 @@ function handleExport(args) {
|
|
|
215
216
|
return err("EXPORT_FAILED", { detail: e.message });
|
|
216
217
|
}
|
|
217
218
|
}
|
|
219
|
+
function handleShareEncode(args) {
|
|
220
|
+
if (typeof args !== "object" || args === null) {
|
|
221
|
+
return err("INVALID_INPUT", { detail: "share_encode args must be an object" });
|
|
222
|
+
}
|
|
223
|
+
const a = args;
|
|
224
|
+
if (typeof a.list !== "object" || a.list === null) {
|
|
225
|
+
return err("INVALID_INPUT", { detail: "share_encode.list must be an object" });
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
return ok(encodeShareToken(a.list));
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
// An id absent from the embedded registry is the only expected throw.
|
|
232
|
+
return err("INVALID_INPUT", { detail: e.message });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function handleShareDecode(args) {
|
|
236
|
+
if (typeof args !== "object" || args === null) {
|
|
237
|
+
return err("INVALID_INPUT", { detail: "share_decode args must be an object" });
|
|
238
|
+
}
|
|
239
|
+
const a = args;
|
|
240
|
+
if (typeof a.token !== "string") {
|
|
241
|
+
return err("INVALID_INPUT", { detail: "share_decode.token must be a string" });
|
|
242
|
+
}
|
|
243
|
+
// The DecodeResult ({ ok, list } | { ok, reason }) is the compared value;
|
|
244
|
+
// a malformed/stale token is a normal result, not a protocol error.
|
|
245
|
+
return ok(decodeShareToken(a.token));
|
|
246
|
+
}
|
|
218
247
|
/**
|
|
219
248
|
* Canonical string encoding of a base size for cross-impl comparison (mirrors the
|
|
220
249
|
* string-encoding used by `maximal_loadout`). Returns null for an absent base.
|
|
@@ -318,6 +347,15 @@ function handleLinkedQuery(state, args) {
|
|
|
318
347
|
return err("UNKNOWN_ENTITY", { kind: "faction", id: input.factionId });
|
|
319
348
|
return ok(f.weapons.map((x) => x.id));
|
|
320
349
|
}
|
|
350
|
+
case "units_with_keyword":
|
|
351
|
+
return ok(ds.unitsWithKeyword(input.keyword ?? "").map((u) => u.id));
|
|
352
|
+
case "allies_for": {
|
|
353
|
+
const raw = input.detachmentIds;
|
|
354
|
+
const detachmentIds = Array.isArray(raw) ? raw : [];
|
|
355
|
+
return ok(ds.alliesFor(input.factionId ?? "", detachmentIds).map((r) => r.id));
|
|
356
|
+
}
|
|
357
|
+
case "ally_units_for":
|
|
358
|
+
return ok(ds.allyUnitsFor(input.ruleId ?? "").map((u) => u.id));
|
|
321
359
|
default:
|
|
322
360
|
return err("INVALID_INPUT", { detail: `unknown linked_query: ${a.query}` });
|
|
323
361
|
}
|
|
@@ -856,6 +894,10 @@ export function dispatch(state, req) {
|
|
|
856
894
|
return handleResolveTerrain(req.args);
|
|
857
895
|
case "keystones":
|
|
858
896
|
return handleKeystones(req.args);
|
|
897
|
+
case "share_encode":
|
|
898
|
+
return handleShareEncode(req.args);
|
|
899
|
+
case "share_decode":
|
|
900
|
+
return handleShareDecode(req.args);
|
|
859
901
|
case "shutdown":
|
|
860
902
|
return ok(null);
|
|
861
903
|
default:
|