@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.
Files changed (115) hide show
  1. package/dist/abilities-resolver/resolver.d.ts.map +1 -1
  2. package/dist/abilities-resolver/resolver.js +6 -2
  3. package/dist/abilities-resolver/resolver.js.map +1 -1
  4. package/dist/audit-coverage.d.ts.map +1 -1
  5. package/dist/audit-coverage.js +90 -1
  6. package/dist/audit-coverage.js.map +1 -1
  7. package/dist/audit-phrasing.d.ts +64 -0
  8. package/dist/audit-phrasing.d.ts.map +1 -0
  9. package/dist/audit-phrasing.js +208 -0
  10. package/dist/audit-phrasing.js.map +1 -0
  11. package/dist/author-input-pack.d.ts +27 -0
  12. package/dist/author-input-pack.d.ts.map +1 -0
  13. package/dist/author-input-pack.js +198 -0
  14. package/dist/author-input-pack.js.map +1 -0
  15. package/dist/build-share-registry.d.ts +2 -0
  16. package/dist/build-share-registry.d.ts.map +1 -0
  17. package/dist/build-share-registry.js +115 -0
  18. package/dist/build-share-registry.js.map +1 -0
  19. package/dist/cli.js +7 -0
  20. package/dist/cli.js.map +1 -1
  21. package/dist/codegen-data.js +23 -0
  22. package/dist/codegen-data.js.map +1 -1
  23. package/dist/commands/translate.d.ts.map +1 -1
  24. package/dist/commands/translate.js +2 -5
  25. package/dist/commands/translate.js.map +1 -1
  26. package/dist/commands/validate-all.d.ts.map +1 -1
  27. package/dist/commands/validate-all.js +8 -3
  28. package/dist/commands/validate-all.js.map +1 -1
  29. package/dist/data/bundle.generated.js +1 -1
  30. package/dist/data/bundle.generated.js.map +1 -1
  31. package/dist/data/dataset.d.ts +33 -1
  32. package/dist/data/dataset.d.ts.map +1 -1
  33. package/dist/data/dataset.js +107 -1
  34. package/dist/data/dataset.js.map +1 -1
  35. package/dist/data/index.d.ts +5 -1
  36. package/dist/data/index.d.ts.map +1 -1
  37. package/dist/data/index.js +5 -1
  38. package/dist/data/index.js.map +1 -1
  39. package/dist/data/roster-resolve.d.ts +16 -1
  40. package/dist/data/roster-resolve.d.ts.map +1 -1
  41. package/dist/data/roster-resolve.js +19 -0
  42. package/dist/data/roster-resolve.js.map +1 -1
  43. package/dist/data/types.d.ts +3 -1
  44. package/dist/data/types.d.ts.map +1 -1
  45. package/dist/data/types.js +1 -0
  46. package/dist/data/types.js.map +1 -1
  47. package/dist/extract-faction-pack.d.ts +16 -11
  48. package/dist/extract-faction-pack.d.ts.map +1 -1
  49. package/dist/extract-faction-pack.js +24 -80
  50. package/dist/extract-faction-pack.js.map +1 -1
  51. package/dist/gen-conformance.js +162 -0
  52. package/dist/gen-conformance.js.map +1 -1
  53. package/dist/generated.d.ts +129 -0
  54. package/dist/generated.d.ts.map +1 -1
  55. package/dist/generated.js.map +1 -1
  56. package/dist/import/gw-headerless.d.ts +70 -0
  57. package/dist/import/gw-headerless.d.ts.map +1 -0
  58. package/dist/import/gw-headerless.js +343 -0
  59. package/dist/import/gw-headerless.js.map +1 -0
  60. package/dist/import/import-roster.d.ts.map +1 -1
  61. package/dist/import/import-roster.js +8 -0
  62. package/dist/import/import-roster.js.map +1 -1
  63. package/dist/integrity.d.ts +32 -0
  64. package/dist/integrity.d.ts.map +1 -0
  65. package/dist/integrity.js +120 -0
  66. package/dist/integrity.js.map +1 -0
  67. package/dist/merge-faction-pack.d.ts +2 -0
  68. package/dist/merge-faction-pack.d.ts.map +1 -0
  69. package/dist/merge-faction-pack.js +157 -0
  70. package/dist/merge-faction-pack.js.map +1 -0
  71. package/dist/pack-blocks.d.ts +32 -0
  72. package/dist/pack-blocks.d.ts.map +1 -0
  73. package/dist/pack-blocks.js +99 -0
  74. package/dist/pack-blocks.js.map +1 -0
  75. package/dist/report.d.ts +1 -1
  76. package/dist/report.d.ts.map +1 -1
  77. package/dist/report.js +2 -2
  78. package/dist/report.js.map +1 -1
  79. package/dist/runner.d.ts.map +1 -1
  80. package/dist/runner.js +42 -0
  81. package/dist/runner.js.map +1 -1
  82. package/dist/seed-detachment-abilities.d.ts +2 -0
  83. package/dist/seed-detachment-abilities.d.ts.map +1 -0
  84. package/dist/seed-detachment-abilities.js +137 -0
  85. package/dist/seed-detachment-abilities.js.map +1 -0
  86. package/dist/share/codec.d.ts +83 -0
  87. package/dist/share/codec.d.ts.map +1 -0
  88. package/dist/share/codec.js +230 -0
  89. package/dist/share/codec.js.map +1 -0
  90. package/dist/share/index.d.ts +30 -0
  91. package/dist/share/index.d.ts.map +1 -0
  92. package/dist/share/index.js +36 -0
  93. package/dist/share/index.js.map +1 -0
  94. package/dist/share/registry.d.ts +42 -0
  95. package/dist/share/registry.d.ts.map +1 -0
  96. package/dist/share/registry.generated.d.ts +4 -0
  97. package/dist/share/registry.generated.d.ts.map +1 -0
  98. package/dist/share/registry.generated.js +4 -0
  99. package/dist/share/registry.generated.js.map +1 -0
  100. package/dist/share/registry.js +68 -0
  101. package/dist/share/registry.js.map +1 -0
  102. package/dist/translate/condition.d.ts +1 -0
  103. package/dist/translate/condition.d.ts.map +1 -1
  104. package/dist/translate/condition.js +101 -7
  105. package/dist/translate/condition.js.map +1 -1
  106. package/dist/translate/effect.d.ts +28 -20
  107. package/dist/translate/effect.d.ts.map +1 -1
  108. package/dist/translate/effect.js +483 -114
  109. package/dist/translate/effect.js.map +1 -1
  110. package/dist/validate.d.ts.map +1 -1
  111. package/dist/validate.js +1 -0
  112. package/dist/validate.js.map +1 -1
  113. package/package.json +4 -1
  114. package/schemas/core/allied-rule.schema.json +115 -0
  115. package/schemas/core/detachment.schema.json +44 -1
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Phrasing audit: catalogue the generated English text for *every* authored
3
+ * ability and flag mechanical phrasing defects, so an across-the-board prose
4
+ * cleanup has a worklist.
5
+ *
6
+ * The DSL→English describer (`describeAbility`, a.k.a. `ability.print()`) is the
7
+ * user-facing rules readout in downstream consumers. This tool runs that *real*
8
+ * describer over each `data/enrichment/<faction>/abilities.json` entry and runs
9
+ * a set of regex heuristics over the output, flagging the defect classes a
10
+ * manual audit turned up: leftover `?` placeholders (modifier field-name
11
+ * mismatches), unmapped `[type]` fallbacks, raw `snake_case` identifiers that
12
+ * leaked into prose, doubled prepositions ("at on model destroyed"), `0"` noise
13
+ * on movement grants, `(s)` pluralization stubs, and empty output.
14
+ *
15
+ * This is a TS-only analysis utility — it *consumes* the conformance-pinned
16
+ * describer, it does not change it — so it needs no SPEC_VERSION bump and no
17
+ * Rust/Python port (mirrors `audit-coverage.ts`).
18
+ *
19
+ * Usage:
20
+ * npx tsx tools/src/audit-phrasing.ts (pretty report to stdout)
21
+ * npx tsx tools/src/audit-phrasing.ts --json (machine-readable)
22
+ * npx tsx tools/src/audit-phrasing.ts --write (also emit data/_audit/phrasing.*)
23
+ *
24
+ * Wired as `40kdc-validate audit-phrasing` and `npm run audit:phrasing`.
25
+ */
26
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
27
+ import { resolve, basename } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import chalk from "chalk";
30
+ import { describeAbility } from "./translate/index.js";
31
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
32
+ const DATA_ROOT = resolve(__dirname, "../../data");
33
+ const ENRICHMENT_ROOT = resolve(DATA_ROOT, "enrichment");
34
+ const AUDIT_DIR = resolve(DATA_ROOT, "_audit");
35
+ const PREP = "(?:at|on|in|of|to|by|for|from|with)";
36
+ const DOUBLED_PREP = new RegExp(`\\b${PREP}\\s+${PREP}\\b`, "i");
37
+ // The unknown-effect-type fallback emits a lowercase kebab token in brackets
38
+ // (`[some-type]`); intentional GW weapon keywords are bracketed UPPERCASE
39
+ // (`[LETHAL HITS]`) and must NOT be flagged.
40
+ const TYPE_FALLBACK = /\[[a-z][a-z0-9-]*\]/;
41
+ const SNAKE_CASE = /[a-z]+_[a-z]+/;
42
+ const ZERO_INCH = /\b0"/;
43
+ /**
44
+ * Classify one piece of generated text. Pure and order-stable so it is directly
45
+ * unit-testable. The order of the returned flags follows the {@link PhrasingFlag}
46
+ * union for deterministic output.
47
+ */
48
+ export function classifyPhrasing(text) {
49
+ const flags = [];
50
+ if (text.trim() === "") {
51
+ flags.push("empty");
52
+ return flags;
53
+ }
54
+ if (text.includes("?"))
55
+ flags.push("placeholder");
56
+ if (TYPE_FALLBACK.test(text))
57
+ flags.push("type_fallback");
58
+ if (SNAKE_CASE.test(text))
59
+ flags.push("snake_case_leftover");
60
+ if (DOUBLED_PREP.test(text))
61
+ flags.push("doubled_preposition");
62
+ if (ZERO_INCH.test(text))
63
+ flags.push("zero_inch");
64
+ if (text.includes("(s)"))
65
+ flags.push("paren_plural");
66
+ return flags;
67
+ }
68
+ /** Compute the phrasing report for a set of factions. Pure — IO lives in the command. */
69
+ export function auditPhrasing(input) {
70
+ const rows = [];
71
+ const byFaction = [];
72
+ const flagCounts = new Map();
73
+ for (const { faction, abilities } of input) {
74
+ let factionFlagged = 0;
75
+ for (const a of abilities) {
76
+ const text = describeAbility({
77
+ ...(a.effect ? { effect: a.effect } : {}),
78
+ ...(a.scope ? { scope: a.scope } : {}),
79
+ ...(a.applies_to ? { applies_to: a.applies_to } : {}),
80
+ });
81
+ const flags = classifyPhrasing(text);
82
+ if (flags.length > 0) {
83
+ factionFlagged++;
84
+ for (const f of flags)
85
+ flagCounts.set(f, (flagCounts.get(f) ?? 0) + 1);
86
+ }
87
+ rows.push({
88
+ faction,
89
+ ability_id: a.ability_id,
90
+ name: a.name ?? a.ability_id,
91
+ unit_ids: a.unit_ids ?? [],
92
+ text,
93
+ flags,
94
+ });
95
+ }
96
+ byFaction.push({ faction, total: abilities.length, flagged: factionFlagged });
97
+ }
98
+ byFaction.sort((a, b) => a.faction.localeCompare(b.faction));
99
+ rows.sort((a, b) => a.faction.localeCompare(b.faction) || a.ability_id.localeCompare(b.ability_id));
100
+ const byFlag = [...flagCounts.entries()]
101
+ .map(([flag, count]) => ({ flag, count }))
102
+ .sort((a, b) => b.count - a.count || a.flag.localeCompare(b.flag));
103
+ return {
104
+ rows,
105
+ total: rows.length,
106
+ flagged: rows.filter((r) => r.flags.length > 0).length,
107
+ byFlag,
108
+ byFaction,
109
+ };
110
+ }
111
+ /** Read every `data/enrichment/<faction>/abilities.json` (skips `_`-prefixed dirs). */
112
+ export function loadFactions() {
113
+ const out = [];
114
+ for (const entry of readdirSync(ENRICHMENT_ROOT, { withFileTypes: true })) {
115
+ if (!entry.isDirectory() || entry.name.startsWith("_"))
116
+ continue;
117
+ const file = resolve(ENRICHMENT_ROOT, entry.name, "abilities.json");
118
+ let abilities;
119
+ try {
120
+ abilities = JSON.parse(readFileSync(file, "utf-8"));
121
+ }
122
+ catch {
123
+ continue; // no abilities.json in this folder
124
+ }
125
+ if (!Array.isArray(abilities))
126
+ continue;
127
+ out.push({ faction: entry.name, abilities });
128
+ }
129
+ return out;
130
+ }
131
+ const pct = (n, d) => (d === 0 ? "—" : `${Math.round((100 * n) / d)}%`);
132
+ /** CSV-escape one field (RFC-4180: wrap in quotes, double internal quotes). */
133
+ function csvField(v) {
134
+ return `"${v.replace(/"/g, '""')}"`;
135
+ }
136
+ /**
137
+ * One row per ability. The multi-line describer output is flattened to ` / ` so
138
+ * each catalogued ability stays on a single CSV line (greppable); the JSON
139
+ * artifact keeps the original newlines.
140
+ */
141
+ function csvReport(r) {
142
+ const lines = ["faction,ability_id,name,unit_ids,flags,text"];
143
+ for (const row of r.rows) {
144
+ lines.push([
145
+ csvField(row.faction),
146
+ csvField(row.ability_id),
147
+ csvField(row.name),
148
+ csvField(row.unit_ids.join(";")),
149
+ csvField(row.flags.join(";")),
150
+ csvField(row.text.replace(/\n/g, " / ")),
151
+ ].join(","));
152
+ }
153
+ return lines.join("\n") + "\n";
154
+ }
155
+ function prettyReport(r) {
156
+ const lines = [];
157
+ lines.push("");
158
+ lines.push(chalk.bold("40kdc Ability Phrasing Audit"));
159
+ lines.push(chalk.gray("─".repeat(60)));
160
+ lines.push(`Abilities catalogued: ${chalk.cyan(String(r.total))}`);
161
+ lines.push(`Flagged for review: ${chalk.yellow(String(r.flagged))} (${pct(r.flagged, r.total)})`);
162
+ lines.push("");
163
+ lines.push(chalk.bold("Defect flags:"));
164
+ if (r.byFlag.length === 0) {
165
+ lines.push(chalk.green(" none — every ability's generated text is clean"));
166
+ }
167
+ else {
168
+ for (const { flag, count } of r.byFlag) {
169
+ lines.push(` ${String(count).padStart(5)} ${chalk.gray(flag)}`);
170
+ }
171
+ }
172
+ lines.push("");
173
+ lines.push(chalk.bold("Most-flagged factions:"));
174
+ const worst = [...r.byFaction].filter((f) => f.flagged > 0).sort((a, b) => b.flagged - a.flagged);
175
+ for (const f of worst.slice(0, 15)) {
176
+ lines.push(` ${String(f.flagged).padStart(4)}/${String(f.total).padEnd(4)} ${chalk.gray(f.faction)}`);
177
+ }
178
+ lines.push("");
179
+ return lines.join("\n");
180
+ }
181
+ /** Commander action: run the phrasing audit over the repo's enrichment data. */
182
+ export function auditPhrasingCommand(opts = {}) {
183
+ const report = auditPhrasing(loadFactions());
184
+ if (opts.write) {
185
+ mkdirSync(AUDIT_DIR, { recursive: true });
186
+ writeFileSync(resolve(AUDIT_DIR, "phrasing.json"), JSON.stringify(report, null, 2) + "\n");
187
+ writeFileSync(resolve(AUDIT_DIR, "phrasing.csv"), csvReport(report));
188
+ }
189
+ if (opts.reporter === "json") {
190
+ console.log(JSON.stringify(report, null, 2));
191
+ }
192
+ else {
193
+ console.log(prettyReport(report));
194
+ if (opts.write)
195
+ console.log(chalk.gray(`Wrote ${basename(AUDIT_DIR)}/phrasing.csv + phrasing.json`));
196
+ }
197
+ }
198
+ // Direct-invocation entry point (`npx tsx tools/src/audit-phrasing.ts [--json] [--write]`).
199
+ const isMain = process.argv[1] &&
200
+ resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
201
+ if (isMain) {
202
+ const argv = process.argv.slice(2);
203
+ auditPhrasingCommand({
204
+ reporter: argv.includes("--json") ? "json" : "pretty",
205
+ write: argv.includes("--write"),
206
+ });
207
+ }
208
+ //# sourceMappingURL=audit-phrasing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-phrasing.js","sourceRoot":"","sources":["../src/audit-phrasing.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Phrasing audit: catalogue the generated English text for *every* authored\n * ability and flag mechanical phrasing defects, so an across-the-board prose\n * cleanup has a worklist.\n *\n * The DSL→English describer (`describeAbility`, a.k.a. `ability.print()`) is the\n * user-facing rules readout in downstream consumers. This tool runs that *real*\n * describer over each `data/enrichment/<faction>/abilities.json` entry and runs\n * a set of regex heuristics over the output, flagging the defect classes a\n * manual audit turned up: leftover `?` placeholders (modifier field-name\n * mismatches), unmapped `[type]` fallbacks, raw `snake_case` identifiers that\n * leaked into prose, doubled prepositions (\"at on model destroyed\"), `0\"` noise\n * on movement grants, `(s)` pluralization stubs, and empty output.\n *\n * This is a TS-only analysis utility — it *consumes* the conformance-pinned\n * describer, it does not change it — so it needs no SPEC_VERSION bump and no\n * Rust/Python port (mirrors `audit-coverage.ts`).\n *\n * Usage:\n * npx tsx tools/src/audit-phrasing.ts (pretty report to stdout)\n * npx tsx tools/src/audit-phrasing.ts --json (machine-readable)\n * npx tsx tools/src/audit-phrasing.ts --write (also emit data/_audit/phrasing.*)\n *\n * Wired as `40kdc-validate audit-phrasing` and `npm run audit:phrasing`.\n */\nimport { readdirSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { resolve, basename } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport chalk from \"chalk\";\nimport { describeAbility, type Effect, type AbilityScope, type AbilityAppliesTo } from \"./translate/index.js\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\nconst ENRICHMENT_ROOT = resolve(DATA_ROOT, \"enrichment\");\nconst AUDIT_DIR = resolve(DATA_ROOT, \"_audit\");\n\n/** Shape of an authored ability entry (only the fields the describer reads). */\ninterface AbilityEntry {\n ability_id: string;\n name?: string;\n effect?: Effect;\n scope?: AbilityScope;\n applies_to?: AbilityAppliesTo | null;\n /** Units that carry this ability — surfaced so a worklist row points at the datasheet. */\n unit_ids?: string[];\n}\n\n/** One catalogued ability: its identity, the generated text, and any defect flags. */\nexport interface PhrasingRow {\n faction: string;\n ability_id: string;\n name: string;\n /** Slugs of the units carrying this ability (for datasheet/Wahapedia lookup). */\n unit_ids: string[];\n /** Full multi-line `describeAbility` output (effect + scope + applies). */\n text: string;\n flags: PhrasingFlag[];\n}\n\nexport interface PhrasingReport {\n rows: PhrasingRow[];\n total: number;\n /** Rows carrying ≥1 flag. */\n flagged: number;\n /** Per-flag counts, descending. */\n byFlag: { flag: PhrasingFlag; count: number }[];\n /** Per-faction totals + flagged counts, faction-sorted. */\n byFaction: { faction: string; total: number; flagged: number }[];\n}\n\nexport type PhrasingFlag =\n | \"placeholder\"\n | \"type_fallback\"\n | \"snake_case_leftover\"\n | \"doubled_preposition\"\n | \"zero_inch\"\n | \"paren_plural\"\n | \"empty\";\n\nconst PREP = \"(?:at|on|in|of|to|by|for|from|with)\";\nconst DOUBLED_PREP = new RegExp(`\\\\b${PREP}\\\\s+${PREP}\\\\b`, \"i\");\n// The unknown-effect-type fallback emits a lowercase kebab token in brackets\n// (`[some-type]`); intentional GW weapon keywords are bracketed UPPERCASE\n// (`[LETHAL HITS]`) and must NOT be flagged.\nconst TYPE_FALLBACK = /\\[[a-z][a-z0-9-]*\\]/;\nconst SNAKE_CASE = /[a-z]+_[a-z]+/;\nconst ZERO_INCH = /\\b0\"/;\n\n/**\n * Classify one piece of generated text. Pure and order-stable so it is directly\n * unit-testable. The order of the returned flags follows the {@link PhrasingFlag}\n * union for deterministic output.\n */\nexport function classifyPhrasing(text: string): PhrasingFlag[] {\n const flags: PhrasingFlag[] = [];\n if (text.trim() === \"\") {\n flags.push(\"empty\");\n return flags;\n }\n if (text.includes(\"?\")) flags.push(\"placeholder\");\n if (TYPE_FALLBACK.test(text)) flags.push(\"type_fallback\");\n if (SNAKE_CASE.test(text)) flags.push(\"snake_case_leftover\");\n if (DOUBLED_PREP.test(text)) flags.push(\"doubled_preposition\");\n if (ZERO_INCH.test(text)) flags.push(\"zero_inch\");\n if (text.includes(\"(s)\")) flags.push(\"paren_plural\");\n return flags;\n}\n\n/** Compute the phrasing report for a set of factions. Pure — IO lives in the command. */\nexport function auditPhrasing(\n input: { faction: string; abilities: AbilityEntry[] }[],\n): PhrasingReport {\n const rows: PhrasingRow[] = [];\n const byFaction: { faction: string; total: number; flagged: number }[] = [];\n const flagCounts = new Map<PhrasingFlag, number>();\n\n for (const { faction, abilities } of input) {\n let factionFlagged = 0;\n for (const a of abilities) {\n const text = describeAbility({\n ...(a.effect ? { effect: a.effect } : {}),\n ...(a.scope ? { scope: a.scope } : {}),\n ...(a.applies_to ? { applies_to: a.applies_to } : {}),\n });\n const flags = classifyPhrasing(text);\n if (flags.length > 0) {\n factionFlagged++;\n for (const f of flags) flagCounts.set(f, (flagCounts.get(f) ?? 0) + 1);\n }\n rows.push({\n faction,\n ability_id: a.ability_id,\n name: a.name ?? a.ability_id,\n unit_ids: a.unit_ids ?? [],\n text,\n flags,\n });\n }\n byFaction.push({ faction, total: abilities.length, flagged: factionFlagged });\n }\n\n byFaction.sort((a, b) => a.faction.localeCompare(b.faction));\n rows.sort(\n (a, b) => a.faction.localeCompare(b.faction) || a.ability_id.localeCompare(b.ability_id),\n );\n\n const byFlag = [...flagCounts.entries()]\n .map(([flag, count]) => ({ flag, count }))\n .sort((a, b) => b.count - a.count || a.flag.localeCompare(b.flag));\n\n return {\n rows,\n total: rows.length,\n flagged: rows.filter((r) => r.flags.length > 0).length,\n byFlag,\n byFaction,\n };\n}\n\n/** Read every `data/enrichment/<faction>/abilities.json` (skips `_`-prefixed dirs). */\nexport function loadFactions(): { faction: string; abilities: AbilityEntry[] }[] {\n const out: { faction: string; abilities: AbilityEntry[] }[] = [];\n for (const entry of readdirSync(ENRICHMENT_ROOT, { withFileTypes: true })) {\n if (!entry.isDirectory() || entry.name.startsWith(\"_\")) continue;\n const file = resolve(ENRICHMENT_ROOT, entry.name, \"abilities.json\");\n let abilities: AbilityEntry[];\n try {\n abilities = JSON.parse(readFileSync(file, \"utf-8\"));\n } catch {\n continue; // no abilities.json in this folder\n }\n if (!Array.isArray(abilities)) continue;\n out.push({ faction: entry.name, abilities });\n }\n return out;\n}\n\nconst pct = (n: number, d: number): string => (d === 0 ? \"—\" : `${Math.round((100 * n) / d)}%`);\n\n/** CSV-escape one field (RFC-4180: wrap in quotes, double internal quotes). */\nfunction csvField(v: string): string {\n return `\"${v.replace(/\"/g, '\"\"')}\"`;\n}\n\n/**\n * One row per ability. The multi-line describer output is flattened to ` / ` so\n * each catalogued ability stays on a single CSV line (greppable); the JSON\n * artifact keeps the original newlines.\n */\nfunction csvReport(r: PhrasingReport): string {\n const lines = [\"faction,ability_id,name,unit_ids,flags,text\"];\n for (const row of r.rows) {\n lines.push(\n [\n csvField(row.faction),\n csvField(row.ability_id),\n csvField(row.name),\n csvField(row.unit_ids.join(\";\")),\n csvField(row.flags.join(\";\")),\n csvField(row.text.replace(/\\n/g, \" / \")),\n ].join(\",\"),\n );\n }\n return lines.join(\"\\n\") + \"\\n\";\n}\n\nfunction prettyReport(r: PhrasingReport): string {\n const lines: string[] = [];\n lines.push(\"\");\n lines.push(chalk.bold(\"40kdc Ability Phrasing Audit\"));\n lines.push(chalk.gray(\"─\".repeat(60)));\n lines.push(`Abilities catalogued: ${chalk.cyan(String(r.total))}`);\n lines.push(`Flagged for review: ${chalk.yellow(String(r.flagged))} (${pct(r.flagged, r.total)})`);\n lines.push(\"\");\n lines.push(chalk.bold(\"Defect flags:\"));\n if (r.byFlag.length === 0) {\n lines.push(chalk.green(\" none — every ability's generated text is clean\"));\n } else {\n for (const { flag, count } of r.byFlag) {\n lines.push(` ${String(count).padStart(5)} ${chalk.gray(flag)}`);\n }\n }\n lines.push(\"\");\n lines.push(chalk.bold(\"Most-flagged factions:\"));\n const worst = [...r.byFaction].filter((f) => f.flagged > 0).sort((a, b) => b.flagged - a.flagged);\n for (const f of worst.slice(0, 15)) {\n lines.push(` ${String(f.flagged).padStart(4)}/${String(f.total).padEnd(4)} ${chalk.gray(f.faction)}`);\n }\n lines.push(\"\");\n return lines.join(\"\\n\");\n}\n\nexport interface AuditPhrasingOptions {\n reporter?: \"pretty\" | \"json\";\n write?: boolean;\n}\n\n/** Commander action: run the phrasing audit over the repo's enrichment data. */\nexport function auditPhrasingCommand(opts: AuditPhrasingOptions = {}): void {\n const report = auditPhrasing(loadFactions());\n\n if (opts.write) {\n mkdirSync(AUDIT_DIR, { recursive: true });\n writeFileSync(resolve(AUDIT_DIR, \"phrasing.json\"), JSON.stringify(report, null, 2) + \"\\n\");\n writeFileSync(resolve(AUDIT_DIR, \"phrasing.csv\"), csvReport(report));\n }\n\n if (opts.reporter === \"json\") {\n console.log(JSON.stringify(report, null, 2));\n } else {\n console.log(prettyReport(report));\n if (opts.write) console.log(chalk.gray(`Wrote ${basename(AUDIT_DIR)}/phrasing.csv + phrasing.json`));\n }\n}\n\n// Direct-invocation entry point (`npx tsx tools/src/audit-phrasing.ts [--json] [--write]`).\nconst isMain =\n process.argv[1] &&\n resolve(process.argv[1]).replace(/\\.\\w+$/, \"\") === fileURLToPath(import.meta.url).replace(/\\.\\w+$/, \"\");\nif (isMain) {\n const argv = process.argv.slice(2);\n auditPhrasingCommand({\n reporter: argv.includes(\"--json\") ? \"json\" : \"pretty\",\n write: argv.includes(\"--write\"),\n });\n}\n"]}
@@ -0,0 +1,27 @@
1
+ import { type Block } from "./pack-blocks.js";
2
+ import type { AuthorInputEntry } from "./author-input.js";
3
+ /** The start of one card within a detachment: its id, name, column, and top y. */
4
+ export interface CardAnchor {
5
+ id: string;
6
+ name: string;
7
+ kind: "detachment" | "stratagem" | "enhancement";
8
+ x: number;
9
+ topGy: number;
10
+ }
11
+ /**
12
+ * Locate every card-start anchor in a detachment's blocks: the detachment rule
13
+ * (under a DETACHMENT RULE(S) header), each stratagem (its name block above a
14
+ * "<NAME> – <TYPE> STRATAGEM" label), and each enhancement (a caps header with a
15
+ * prose body below). Anchors bound each other's bodies by same-column y order.
16
+ */
17
+ export declare function findAnchors(blocks: Block[]): CardAnchor[];
18
+ /**
19
+ * Capture a card's rule body: every same-column block strictly below the anchor's
20
+ * name/header, up to the next same-column anchor (the next card's start). Joined
21
+ * in reading order. Column-scoping is what keeps the adjacent column's text out —
22
+ * the whole reason bbox beats `-layout` on these 2-column pages.
23
+ */
24
+ export declare function captureBody(blocks: Block[], anchor: CardAnchor, anchors: CardAnchor[]): string;
25
+ /** Build author-input entries for one faction's new-detachment stubs from its pack. */
26
+ export declare function buildFactionFromPack(faction: string, pdf: string): AuthorInputEntry[];
27
+ //# sourceMappingURL=author-input-pack.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"author-input-pack.d.ts","sourceRoot":"","sources":["../src/author-input-pack.ts"],"names":[],"mappings":"AA8BA,OAAO,EACL,KAAK,KAAK,EAOX,MAAM,kBAAkB,CAAC;AAG1B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAW1D,kFAAkF;AAClF,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,GAAG,WAAW,GAAG,aAAa,CAAC;IACjD,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACf;AAkBD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,UAAU,EAAE,CAgDzD;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,CAa9F;AAID,uFAAuF;AACvF,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAmDrF"}
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Build the DSL-authoring input for *new 11e detachments* from a Faction Pack PDF.
3
+ *
4
+ * The stock `author-input.ts` resolves each empty-modifier ability stub to its
5
+ * source rule in the **10e army-assist archive** (unit→datasheet→ability chain).
6
+ * That archive predates the 11e Faction Packs, so the new detachments' rules /
7
+ * stratagems / enhancements aren't in it — the chain returns nothing and the
8
+ * authoring pipeline has no text to classify.
9
+ *
10
+ * This sibling tool fills the gap from the *primary* 11e source: the pack PDF.
11
+ * It captures each card's rule body by bbox coordinate (column-scoped, so the
12
+ * packs' 2-column stratagem layout never interleaves — the failure mode that
13
+ * killed the `-layout` approach) and writes the identical `AuthorInputEntry`
14
+ * shape that `author-batch.ts propose` consumes. Nothing downstream changes.
15
+ *
16
+ * IP firewall: rule prose is captured ONLY into the gitignored authoring worklist
17
+ * `data/_audit/author-input/<faction>.json` (.gitignore). It is never written to
18
+ * the committed `faction-pack-input/` staging (that stays names + metadata only).
19
+ *
20
+ * Scope: only empty-modifier stubs that belong to a detachment (`detachment_id`
21
+ * set) — i.e. the new-detachment rules/stratagems/enhancements the pack covers.
22
+ * Unit-scoped stubs (`detachment_id` null) remain the 10e-archive path's job.
23
+ *
24
+ * Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.
25
+ *
26
+ * Usage: npx tsx tools/src/author-input-pack.ts --pdf <pack.pdf> --faction <id>
27
+ */
28
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
29
+ import { resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { isCapsHeader, sameColumn, stripCp, slug, titleCase, normCaps, } from "./pack-blocks.js";
32
+ import { detachmentSegments } from "./extract-faction-pack.js";
33
+ import { hasEmptyModifier } from "./audit-coverage.js";
34
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
35
+ const DATA_ROOT = resolve(__dirname, "../../data");
36
+ const ENRICHMENT_ROOT = resolve(DATA_ROOT, "enrichment");
37
+ const OUT_DIR = resolve(DATA_ROOT, "_audit", "author-input");
38
+ const readJSON = (p) => JSON.parse(readFileSync(p, "utf-8"));
39
+ const STRAT_LABEL = /\bSTRATAGEM$/;
40
+ const STRAT_SECTION = /^STRATAGEMS?$/;
41
+ /** Table headers that masquerade as caps "enhancement" names (mirror of extractor). */
42
+ const NOISE = new Set([
43
+ "RANGED WEAPONS", "MELEE WEAPONS", "RANGE", "WARGEAR OPTIONS", "UNIT COMPOSITION",
44
+ "KEYWORDS", "LEADER", "TRANSPORT", "DAMAGED", "SUPREME COMMANDER",
45
+ ]);
46
+ /** True when the nearest same-column block below `b` is mixed-case prose. */
47
+ function hasProseBelow(blocks, b) {
48
+ return blocks
49
+ .filter((o) => sameColumn(o.x, b.x) && o.gy > b.gy)
50
+ .sort((p, q) => p.gy - q.gy)
51
+ .some((o, i) => i === 0 && /[a-z]/.test(o.text));
52
+ }
53
+ /**
54
+ * Locate every card-start anchor in a detachment's blocks: the detachment rule
55
+ * (under a DETACHMENT RULE(S) header), each stratagem (its name block above a
56
+ * "<NAME> – <TYPE> STRATAGEM" label), and each enhancement (a caps header with a
57
+ * prose body below). Anchors bound each other's bodies by same-column y order.
58
+ */
59
+ export function findAnchors(blocks) {
60
+ const anchors = [];
61
+ const seen = new Set();
62
+ const push = (a) => {
63
+ if (a.id && !seen.has(a.id)) {
64
+ seen.add(a.id);
65
+ anchors.push(a);
66
+ }
67
+ };
68
+ // detachment rule: first caps header beneath the DETACHMENT RULE(S) marker.
69
+ const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];
70
+ let ruleSlug = "";
71
+ if (ruleHeader) {
72
+ const rn = blocks
73
+ .filter((b) => sameColumn(b.x, ruleHeader.x) && b.gy > ruleHeader.gy && isCapsHeader(b.text))
74
+ .sort((a, b) => a.gy - b.gy)[0];
75
+ if (rn) {
76
+ ruleSlug = slug(rn.text);
77
+ push({ id: ruleSlug, name: titleCase(rn.text), kind: "detachment", x: rn.x, topGy: rn.gy });
78
+ }
79
+ }
80
+ // stratagems: name block directly above each "… STRATAGEM" label, same column.
81
+ const stratIds = new Set();
82
+ for (const label of blocks.filter((b) => STRAT_LABEL.test(b.text) && !STRAT_SECTION.test(b.text))) {
83
+ const nameBlock = blocks
84
+ .filter((b) => sameColumn(b.x, label.x) && b.gy < label.gy && isCapsHeader(stripCp(b.text).name))
85
+ .sort((a, b) => b.gy - a.gy)[0];
86
+ if (!nameBlock)
87
+ continue;
88
+ const { name } = stripCp(nameBlock.text);
89
+ const id = slug(name);
90
+ stratIds.add(id);
91
+ push({ id, name: titleCase(name), kind: "stratagem", x: nameBlock.x, topGy: nameBlock.gy });
92
+ }
93
+ // enhancements: remaining caps headers with a prose body below (subtractive,
94
+ // mirroring the extractor) — not stratagem names, the rule, or table noise.
95
+ for (const b of blocks) {
96
+ if (!isCapsHeader(b.text) || NOISE.has(normCaps(b.text)))
97
+ continue;
98
+ const id = slug(b.text.replace(/ UPGRADE$/, "").trim());
99
+ if (!id || id === ruleSlug || stratIds.has(id) || seen.has(id))
100
+ continue;
101
+ if (!hasProseBelow(blocks, b))
102
+ continue;
103
+ const name = titleCase(b.text.replace(/ UPGRADE$/, "").trim());
104
+ push({ id, name, kind: "enhancement", x: b.x, topGy: b.gy });
105
+ }
106
+ return anchors;
107
+ }
108
+ /**
109
+ * Capture a card's rule body: every same-column block strictly below the anchor's
110
+ * name/header, up to the next same-column anchor (the next card's start). Joined
111
+ * in reading order. Column-scoping is what keeps the adjacent column's text out —
112
+ * the whole reason bbox beats `-layout` on these 2-column pages.
113
+ */
114
+ export function captureBody(blocks, anchor, anchors) {
115
+ const nextTop = Math.min(...anchors
116
+ .filter((a) => a !== anchor && sameColumn(a.x, anchor.x) && a.topGy > anchor.topGy)
117
+ .map((a) => a.topGy), Infinity);
118
+ return blocks
119
+ .filter((b) => sameColumn(b.x, anchor.x) && b.gy > anchor.topGy && b.gy < nextTop)
120
+ .sort((a, b) => a.gy - b.gy)
121
+ .map((b) => b.text)
122
+ .join("\n")
123
+ .trim();
124
+ }
125
+ const normName = (s) => normCaps(s);
126
+ /** Build author-input entries for one faction's new-detachment stubs from its pack. */
127
+ export function buildFactionFromPack(faction, pdf) {
128
+ const abilitiesPath = resolve(ENRICHMENT_ROOT, faction, "abilities.json");
129
+ if (!existsSync(abilitiesPath))
130
+ return [];
131
+ const abilities = readJSON(abilitiesPath);
132
+ // In scope: empty-modifier stubs that belong to a detachment (the new-detachment
133
+ // rules/stratagems/enhancements). Unit-scoped stubs stay the 10e-archive path's.
134
+ const stubs = abilities.filter((a) => a.detachment_id && hasEmptyModifier(a.effect));
135
+ const segments = detachmentSegments(pdf);
136
+ const segById = new Map(segments.map((s) => [s.id, s]));
137
+ const anchorsBySeg = new Map();
138
+ const out = [];
139
+ for (const a of stubs) {
140
+ const base = {
141
+ faction,
142
+ ability_id: a.ability_id,
143
+ name: a.name,
144
+ unit_ids: (a.unit_ids ?? []),
145
+ target: (a.effect && typeof a.effect === "object" && a.effect.target) || null,
146
+ scope: a.scope ?? null,
147
+ faction_id: a.faction_id ?? null,
148
+ ability_type: a.ability_type ?? null,
149
+ };
150
+ const seg = segById.get(a.detachment_id);
151
+ if (!seg) {
152
+ out.push({ ...base, resolved: false, reason: `detachment "${a.detachment_id}" not found in pack` });
153
+ continue;
154
+ }
155
+ let anchors = anchorsBySeg.get(seg.id);
156
+ if (!anchors)
157
+ anchorsBySeg.set(seg.id, (anchors = findAnchors(seg.blocks)));
158
+ const anchor = anchors.find((c) => c.id === a.ability_id) ??
159
+ anchors.find((c) => normName(c.name) === normName(a.name));
160
+ if (!anchor) {
161
+ out.push({ ...base, resolved: false, reason: `no card matched "${a.name}" in "${a.detachment_id}"` });
162
+ continue;
163
+ }
164
+ const description = captureBody(seg.blocks, anchor, anchors);
165
+ if (!description) {
166
+ out.push({ ...base, resolved: false, reason: `empty body for "${a.name}"` });
167
+ continue;
168
+ }
169
+ out.push({
170
+ ...base,
171
+ resolved: true,
172
+ src: { datasheet_id: "", src_type: null, parameter: null, phases: null, description },
173
+ });
174
+ }
175
+ return out;
176
+ }
177
+ function main() {
178
+ const argv = process.argv.slice(2);
179
+ const pdf = argv[argv.indexOf("--pdf") + 1];
180
+ const faction = argv[argv.indexOf("--faction") + 1];
181
+ if (!argv.includes("--pdf") || !argv.includes("--faction") || !pdf || !faction) {
182
+ console.error("Usage: npx tsx tools/src/author-input-pack.ts --pdf <pack.pdf> --faction <id>");
183
+ process.exit(1);
184
+ }
185
+ const entries = buildFactionFromPack(faction, resolve(pdf));
186
+ mkdirSync(OUT_DIR, { recursive: true });
187
+ const outPath = resolve(OUT_DIR, `${faction}.json`);
188
+ writeFileSync(outPath, JSON.stringify(entries, null, 2) + "\n");
189
+ const resolved = entries.filter((e) => e.resolved).length;
190
+ console.log(` ${faction}: ${entries.length} stubs, ${resolved} source-resolved → ${outPath}`);
191
+ for (const e of entries.filter((e) => !e.resolved))
192
+ console.log(` unresolved ${e.ability_id}: ${e.reason}`);
193
+ }
194
+ const isMain = process.argv[1] &&
195
+ resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
196
+ if (isMain)
197
+ main();
198
+ //# sourceMappingURL=author-input-pack.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"author-input-pack.js","sourceRoot":"","sources":["../src/author-input-pack.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Build the DSL-authoring input for *new 11e detachments* from a Faction Pack PDF.\n *\n * The stock `author-input.ts` resolves each empty-modifier ability stub to its\n * source rule in the **10e army-assist archive** (unit→datasheet→ability chain).\n * That archive predates the 11e Faction Packs, so the new detachments' rules /\n * stratagems / enhancements aren't in it — the chain returns nothing and the\n * authoring pipeline has no text to classify.\n *\n * This sibling tool fills the gap from the *primary* 11e source: the pack PDF.\n * It captures each card's rule body by bbox coordinate (column-scoped, so the\n * packs' 2-column stratagem layout never interleaves — the failure mode that\n * killed the `-layout` approach) and writes the identical `AuthorInputEntry`\n * shape that `author-batch.ts propose` consumes. Nothing downstream changes.\n *\n * IP firewall: rule prose is captured ONLY into the gitignored authoring worklist\n * `data/_audit/author-input/<faction>.json` (.gitignore). It is never written to\n * the committed `faction-pack-input/` staging (that stays names + metadata only).\n *\n * Scope: only empty-modifier stubs that belong to a detachment (`detachment_id`\n * set) — i.e. the new-detachment rules/stratagems/enhancements the pack covers.\n * Unit-scoped stubs (`detachment_id` null) remain the 10e-archive path's job.\n *\n * Requires `pdftotext` (poppler) on PATH — authoring-time only, not CI.\n *\n * Usage: npx tsx tools/src/author-input-pack.ts --pdf <pack.pdf> --faction <id>\n */\nimport { readFileSync, writeFileSync, mkdirSync, existsSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport {\n type Block,\n isCapsHeader,\n sameColumn,\n stripCp,\n slug,\n titleCase,\n normCaps,\n} from \"./pack-blocks.js\";\nimport { detachmentSegments } from \"./extract-faction-pack.js\";\nimport { hasEmptyModifier } from \"./audit-coverage.js\";\nimport type { AuthorInputEntry } from \"./author-input.js\";\n\nconst __dirname = fileURLToPath(new URL(\".\", import.meta.url));\nconst DATA_ROOT = resolve(__dirname, \"../../data\");\nconst ENRICHMENT_ROOT = resolve(DATA_ROOT, \"enrichment\");\nconst OUT_DIR = resolve(DATA_ROOT, \"_audit\", \"author-input\");\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype Json = any;\nconst readJSON = (p: string): Json => JSON.parse(readFileSync(p, \"utf-8\"));\n\n/** The start of one card within a detachment: its id, name, column, and top y. */\nexport interface CardAnchor {\n id: string;\n name: string;\n kind: \"detachment\" | \"stratagem\" | \"enhancement\";\n x: number;\n topGy: number;\n}\n\nconst STRAT_LABEL = /\\bSTRATAGEM$/;\nconst STRAT_SECTION = /^STRATAGEMS?$/;\n/** Table headers that masquerade as caps \"enhancement\" names (mirror of extractor). */\nconst NOISE = new Set([\n \"RANGED WEAPONS\", \"MELEE WEAPONS\", \"RANGE\", \"WARGEAR OPTIONS\", \"UNIT COMPOSITION\",\n \"KEYWORDS\", \"LEADER\", \"TRANSPORT\", \"DAMAGED\", \"SUPREME COMMANDER\",\n]);\n\n/** True when the nearest same-column block below `b` is mixed-case prose. */\nfunction hasProseBelow(blocks: Block[], b: Block): boolean {\n return blocks\n .filter((o) => sameColumn(o.x, b.x) && o.gy > b.gy)\n .sort((p, q) => p.gy - q.gy)\n .some((o, i) => i === 0 && /[a-z]/.test(o.text));\n}\n\n/**\n * Locate every card-start anchor in a detachment's blocks: the detachment rule\n * (under a DETACHMENT RULE(S) header), each stratagem (its name block above a\n * \"<NAME> – <TYPE> STRATAGEM\" label), and each enhancement (a caps header with a\n * prose body below). Anchors bound each other's bodies by same-column y order.\n */\nexport function findAnchors(blocks: Block[]): CardAnchor[] {\n const anchors: CardAnchor[] = [];\n const seen = new Set<string>();\n const push = (a: CardAnchor): void => {\n if (a.id && !seen.has(a.id)) {\n seen.add(a.id);\n anchors.push(a);\n }\n };\n\n // detachment rule: first caps header beneath the DETACHMENT RULE(S) marker.\n const ruleHeader = blocks.filter((b) => /^DETACHMENT RULES?$/.test(b.text)).sort((a, b) => a.gy - b.gy)[0];\n let ruleSlug = \"\";\n if (ruleHeader) {\n const rn = blocks\n .filter((b) => sameColumn(b.x, ruleHeader.x) && b.gy > ruleHeader.gy && isCapsHeader(b.text))\n .sort((a, b) => a.gy - b.gy)[0];\n if (rn) {\n ruleSlug = slug(rn.text);\n push({ id: ruleSlug, name: titleCase(rn.text), kind: \"detachment\", x: rn.x, topGy: rn.gy });\n }\n }\n\n // stratagems: name block directly above each \"… STRATAGEM\" label, same column.\n const stratIds = new Set<string>();\n for (const label of blocks.filter((b) => STRAT_LABEL.test(b.text) && !STRAT_SECTION.test(b.text))) {\n const nameBlock = blocks\n .filter((b) => sameColumn(b.x, label.x) && b.gy < label.gy && isCapsHeader(stripCp(b.text).name))\n .sort((a, b) => b.gy - a.gy)[0];\n if (!nameBlock) continue;\n const { name } = stripCp(nameBlock.text);\n const id = slug(name);\n stratIds.add(id);\n push({ id, name: titleCase(name), kind: \"stratagem\", x: nameBlock.x, topGy: nameBlock.gy });\n }\n\n // enhancements: remaining caps headers with a prose body below (subtractive,\n // mirroring the extractor) — not stratagem names, the rule, or table noise.\n for (const b of blocks) {\n if (!isCapsHeader(b.text) || NOISE.has(normCaps(b.text))) continue;\n const id = slug(b.text.replace(/ UPGRADE$/, \"\").trim());\n if (!id || id === ruleSlug || stratIds.has(id) || seen.has(id)) continue;\n if (!hasProseBelow(blocks, b)) continue;\n const name = titleCase(b.text.replace(/ UPGRADE$/, \"\").trim());\n push({ id, name, kind: \"enhancement\", x: b.x, topGy: b.gy });\n }\n\n return anchors;\n}\n\n/**\n * Capture a card's rule body: every same-column block strictly below the anchor's\n * name/header, up to the next same-column anchor (the next card's start). Joined\n * in reading order. Column-scoping is what keeps the adjacent column's text out —\n * the whole reason bbox beats `-layout` on these 2-column pages.\n */\nexport function captureBody(blocks: Block[], anchor: CardAnchor, anchors: CardAnchor[]): string {\n const nextTop = Math.min(\n ...anchors\n .filter((a) => a !== anchor && sameColumn(a.x, anchor.x) && a.topGy > anchor.topGy)\n .map((a) => a.topGy),\n Infinity,\n );\n return blocks\n .filter((b) => sameColumn(b.x, anchor.x) && b.gy > anchor.topGy && b.gy < nextTop)\n .sort((a, b) => a.gy - b.gy)\n .map((b) => b.text)\n .join(\"\\n\")\n .trim();\n}\n\nconst normName = (s: string): string => normCaps(s);\n\n/** Build author-input entries for one faction's new-detachment stubs from its pack. */\nexport function buildFactionFromPack(faction: string, pdf: string): AuthorInputEntry[] {\n const abilitiesPath = resolve(ENRICHMENT_ROOT, faction, \"abilities.json\");\n if (!existsSync(abilitiesPath)) return [];\n const abilities: Json[] = readJSON(abilitiesPath);\n\n // In scope: empty-modifier stubs that belong to a detachment (the new-detachment\n // rules/stratagems/enhancements). Unit-scoped stubs stay the 10e-archive path's.\n const stubs = abilities.filter((a) => a.detachment_id && hasEmptyModifier(a.effect));\n\n const segments = detachmentSegments(pdf);\n const segById = new Map(segments.map((s) => [s.id, s]));\n const anchorsBySeg = new Map<string, CardAnchor[]>();\n\n const out: AuthorInputEntry[] = [];\n for (const a of stubs) {\n const base = {\n faction,\n ability_id: a.ability_id as string,\n name: a.name as string,\n unit_ids: (a.unit_ids ?? []) as string[],\n target: (a.effect && typeof a.effect === \"object\" && a.effect.target) || null,\n scope: a.scope ?? null,\n faction_id: a.faction_id ?? null,\n ability_type: a.ability_type ?? null,\n };\n const seg = segById.get(a.detachment_id);\n if (!seg) {\n out.push({ ...base, resolved: false, reason: `detachment \"${a.detachment_id}\" not found in pack` });\n continue;\n }\n let anchors = anchorsBySeg.get(seg.id);\n if (!anchors) anchorsBySeg.set(seg.id, (anchors = findAnchors(seg.blocks)));\n const anchor =\n anchors.find((c) => c.id === a.ability_id) ??\n anchors.find((c) => normName(c.name) === normName(a.name));\n if (!anchor) {\n out.push({ ...base, resolved: false, reason: `no card matched \"${a.name}\" in \"${a.detachment_id}\"` });\n continue;\n }\n const description = captureBody(seg.blocks, anchor, anchors);\n if (!description) {\n out.push({ ...base, resolved: false, reason: `empty body for \"${a.name}\"` });\n continue;\n }\n out.push({\n ...base,\n resolved: true,\n src: { datasheet_id: \"\", src_type: null, parameter: null, phases: null, description },\n });\n }\n return out;\n}\n\nfunction main(): void {\n const argv = process.argv.slice(2);\n const pdf = argv[argv.indexOf(\"--pdf\") + 1];\n const faction = argv[argv.indexOf(\"--faction\") + 1];\n if (!argv.includes(\"--pdf\") || !argv.includes(\"--faction\") || !pdf || !faction) {\n console.error(\"Usage: npx tsx tools/src/author-input-pack.ts --pdf <pack.pdf> --faction <id>\");\n process.exit(1);\n }\n\n const entries = buildFactionFromPack(faction, resolve(pdf));\n mkdirSync(OUT_DIR, { recursive: true });\n const outPath = resolve(OUT_DIR, `${faction}.json`);\n writeFileSync(outPath, JSON.stringify(entries, null, 2) + \"\\n\");\n const resolved = entries.filter((e) => e.resolved).length;\n console.log(` ${faction}: ${entries.length} stubs, ${resolved} source-resolved → ${outPath}`);\n for (const e of entries.filter((e) => !e.resolved)) console.log(` unresolved ${e.ability_id}: ${e.reason}`);\n}\n\nconst isMain =\n process.argv[1] &&\n resolve(process.argv[1]).replace(/\\.\\w+$/, \"\") === fileURLToPath(import.meta.url).replace(/\\.\\w+$/, \"\");\nif (isMain) main();\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=build-share-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-share-registry.d.ts","sourceRoot":"","sources":["../src/build-share-registry.ts"],"names":[],"mappings":""}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Regenerates the share-token id registry (`data/share-registry.json`).
3
+ *
4
+ * The registry assigns every shareable entity id a stable integer index so the
5
+ * list-builder share codec can encode a roster as compact varints instead of
6
+ * full id strings (see `src/share/` and `docs/share-token.md`).
7
+ *
8
+ * **Append-only.** This tool never reorders or drops a slot: existing ids keep
9
+ * their index forever, genuinely-new ids are appended (sorted) at the end, and
10
+ * an id that has vanished from the dataset keeps its slot and is recorded in
11
+ * `tombstones`. Renames are author-curated: move the old id from `tombstones`
12
+ * into `aliases` ({ "old-id": "new-id" }) by hand. These invariants guarantee a
13
+ * newer registry can always decode an older token; only the reverse (an old
14
+ * package meeting a newer token) can fail, and it fails safe (stale-registry).
15
+ *
16
+ * Run intentionally via `npm run registry:build` and commit the diff — this is
17
+ * a migration step, not a per-build codegen. `codegen:data` separately inlines
18
+ * the committed JSON into `src/share/registry.generated.ts`.
19
+ */
20
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { alliedRules, detachments, enhancements, factions, forceDispositions, units, wargear, weapons, } from "./data/index.js";
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const REGISTRY_PATH = resolve(__dirname, "../../data/share-registry.json");
26
+ /** Registry kinds, in a fixed order. The wire format references each by name. */
27
+ const KINDS = [
28
+ "faction",
29
+ "detachment",
30
+ "unit",
31
+ "wargear",
32
+ "enhancement",
33
+ "ally_rule",
34
+ "disposition",
35
+ ];
36
+ /** Distinct ids currently in the dataset for each kind, sorted lexically. */
37
+ function currentIds() {
38
+ const distinct = (ids) => new Set(ids);
39
+ return {
40
+ faction: distinct(factions.all.map((f) => f.id)),
41
+ detachment: distinct(detachments.all.map((d) => d.id)),
42
+ unit: distinct(units.all.map((u) => u.id)),
43
+ // Loadout refs are either weapons or non-weapon wargear; they share one id
44
+ // space in the token, so the registry merges both collections.
45
+ wargear: distinct([...weapons.all.map((w) => w.id), ...wargear.all.map((w) => w.id)]),
46
+ enhancement: distinct(enhancements.all.map((e) => e.id)),
47
+ ally_rule: distinct(alliedRules.all.map((r) => r.id)),
48
+ disposition: distinct(forceDispositions.all.map((d) => d.id)),
49
+ };
50
+ }
51
+ function emptyRegistry() {
52
+ return {
53
+ version: 0,
54
+ kinds: Object.fromEntries(KINDS.map((k) => [k, []])),
55
+ aliases: {},
56
+ tombstones: [],
57
+ };
58
+ }
59
+ function loadRegistry() {
60
+ if (!existsSync(REGISTRY_PATH))
61
+ return emptyRegistry();
62
+ const parsed = JSON.parse(readFileSync(REGISTRY_PATH, "utf-8"));
63
+ // Tolerate a registry authored before a kind existed.
64
+ for (const kind of KINDS)
65
+ parsed.kinds[kind] ??= [];
66
+ parsed.aliases ??= {};
67
+ parsed.tombstones ??= [];
68
+ return parsed;
69
+ }
70
+ function main() {
71
+ const prev = loadRegistry();
72
+ const current = currentIds();
73
+ const kinds = {};
74
+ const newlyRemoved = [];
75
+ let appended = 0;
76
+ for (const kind of KINDS) {
77
+ const existing = prev.kinds[kind] ?? [];
78
+ const have = new Set(existing);
79
+ // Append-only: new ids (sorted) after the preserved existing order.
80
+ const added = [...current[kind]].filter((id) => !have.has(id)).sort();
81
+ kinds[kind] = [...existing, ...added];
82
+ appended += added.length;
83
+ // A previously-allocated id absent from the dataset becomes a tombstone
84
+ // (its slot is retained above, so existing indices never shift).
85
+ for (const id of existing) {
86
+ if (!current[kind].has(id))
87
+ newlyRemoved.push(id);
88
+ }
89
+ }
90
+ // Tombstone anything newly removed that the author hasn't already mapped to a
91
+ // replacement via `aliases`. Preserve prior tombstones and aliases verbatim.
92
+ const tombstones = new Set(prev.tombstones);
93
+ for (const id of newlyRemoved) {
94
+ if (!(id in prev.aliases))
95
+ tombstones.add(id);
96
+ }
97
+ const changed = appended > 0 || tombstones.size !== prev.tombstones.length;
98
+ const next = {
99
+ version: changed ? prev.version + 1 : prev.version,
100
+ kinds,
101
+ aliases: prev.aliases,
102
+ tombstones: [...tombstones].sort(),
103
+ };
104
+ writeFileSync(REGISTRY_PATH, JSON.stringify(next, null, 2) + "\n");
105
+ const counts = KINDS.map((k) => `${k}=${kinds[k].length}`).join(", ");
106
+ if (changed) {
107
+ console.log(`Wrote ${REGISTRY_PATH}\n version ${prev.version} → ${next.version} ` +
108
+ `(+${appended} ids, ${newlyRemoved.length} tombstoned)\n ${counts}`);
109
+ }
110
+ else {
111
+ console.log(`No change: ${REGISTRY_PATH} already at version ${next.version}\n ${counts}`);
112
+ }
113
+ }
114
+ main();
115
+ //# sourceMappingURL=build-share-registry.js.map