@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,343 @@
1
+ import { inferBattleSizeRaw } from "./newrecruit-text.js";
2
+ const CHARACTERS_SECTION = "CHARACTERS";
3
+ const ALLIED_SECTION = "ALLIED UNITS";
4
+ const CHARACTER_SUFFIX = " Character";
5
+ const WARLORD_MARKER = "Warlord";
6
+ /** Title / unit header: `Name (N pts|Points)` with an optional trailing comment
7
+ * (the GW export sometimes appends TO notes). Points may carry thousands
8
+ * commas. Case-insensitive `pts`/`points`. */
9
+ const RE_PTS_LINE = /^(.+?)\s*\(\s*([\d,]+)\s*(?:pts?|points?)\s*\).*$/i;
10
+ /** `## Section [ (N pts) ]` markdown header. */
11
+ const RE_MD_SECTION = /^#{1,6}\s*(.+?)\s*$/;
12
+ /** ALL-CAPS role section (`CHARACTERS`, `OTHER DATASHEETS`, …). */
13
+ const RE_CAPS_SECTION = /^[A-Z][A-Z0-9 \-/&]+$/;
14
+ /** `Title:` colon section (`Epic Hero:`, `Battleline:`). */
15
+ const RE_COLON_SECTION = /^([A-Za-z][\w /&-]*):\s*$/;
16
+ /** Bullet line: leading indent, a `•` or `◦` marker, then the body. */
17
+ const RE_BULLET = /^([\t ]*)[•◦]\s*(.+?)\s*$/u;
18
+ const RE_NX_PREFIX = /^(\d+)x\s+(.+)$/i;
19
+ /** Inline enhancement annotation: `Name (+N pts)`. */
20
+ const RE_ENHANCEMENT_ANNOT = /^(.+?)\s*\(\+\s*(\d+)\s*pts?\s*\)\s*$/i;
21
+ /** `Enhancements: X` / `E: X` enhancement bullet. */
22
+ const RE_ENHANCEMENT_LABEL = /^(?:e|enh|enhancement|enhancements)\s*:\s*(.+)$/i;
23
+ const RE_WITH_LINE = /^[\t ]*\d+\s+with\b/m;
24
+ const RE_BULLET_ANYWHERE = /^[\t ]*[•◦]/mu;
25
+ /** ListForge-text first line: `<name> - <faction> - <detachment> (N Points)`.
26
+ * Used only to *decline* — that framed header belongs to `listForgeTextAdapter`,
27
+ * which runs ahead of us; declining keeps the matchers mutually exclusive. */
28
+ const RE_LISTFORGE_FIRST_LINE = /^(.+)\s\(\s*\d+\s*Points?\s*\)\s*$/i;
29
+ /** Battle-size labels that look like unit headers (`Strike Force (2,000 Points)`)
30
+ * but are army metadata, not datasheets. */
31
+ const BATTLE_SIZE_NAMES = new Set([
32
+ "combat patrol",
33
+ "incursion",
34
+ "strike force",
35
+ "onslaught",
36
+ ]);
37
+ function parsePts(raw) {
38
+ const n = Number.parseInt(raw.replace(/,/g, ""), 10);
39
+ return Number.isNaN(n) ? null : n;
40
+ }
41
+ /** Accept bullet-bearing plain text that no framed adapter claims. */
42
+ function headerlessText(decoded) {
43
+ if (typeof decoded !== "string")
44
+ return null;
45
+ if (!RE_BULLET_ANYWHERE.test(decoded))
46
+ return null; // need a bullet
47
+ if (decoded.includes("+ FACTION KEYWORD:"))
48
+ return null; // framed GW → gwAdapter
49
+ if (RE_WITH_LINE.test(decoded))
50
+ return null; // WTC-full
51
+ const lines = decoded.split(/\r?\n/);
52
+ // ListForge-text's `name - faction - detachment (N Points)` header → defer to
53
+ // listForgeTextAdapter (registered ahead of us). Mirrors its own matcher so
54
+ // the two stay disjoint, per the importer's single-match invariant.
55
+ const firstNonBlank = lines.find((l) => l.trim().length > 0);
56
+ const lf = firstNonBlank && RE_LISTFORGE_FIRST_LINE.exec(firstNonBlank.trim());
57
+ if (lf && lf[1].split(" - ").length >= 3)
58
+ return null;
59
+ // NewRecruit `# ++ Army Roster ++` → newrecruit-simple.
60
+ if (lines.some((l) => {
61
+ const t = l.trim();
62
+ return t.startsWith("# ++") && t.includes("Army Roster");
63
+ })) {
64
+ return null;
65
+ }
66
+ // Require a `Name (N pts|Points)` line somewhere — the unit/title signature.
67
+ return lines.some((l) => RE_PTS_LINE.test(l.trim())) ? decoded : null;
68
+ }
69
+ function parseBullet(indent, body) {
70
+ // Enhancement label first — `Enhancements: X` must not read as a model.
71
+ const label = RE_ENHANCEMENT_LABEL.exec(body);
72
+ if (label) {
73
+ return {
74
+ indent,
75
+ count: null,
76
+ name: "",
77
+ colon_wargear: null,
78
+ is_annotation: true,
79
+ enhancement: [label[1].trim(), null],
80
+ };
81
+ }
82
+ const nx = RE_NX_PREFIX.exec(body);
83
+ const count = nx ? Number.parseInt(nx[1], 10) : null;
84
+ const rest = (nx ? nx[2] : body).trim();
85
+ // `Name (+N pts)` enhancement annotation.
86
+ const annot = RE_ENHANCEMENT_ANNOT.exec(rest);
87
+ if (annot) {
88
+ const pts = Number.parseInt(annot[2], 10);
89
+ return {
90
+ indent,
91
+ count,
92
+ name: rest,
93
+ colon_wargear: null,
94
+ is_annotation: true,
95
+ enhancement: [annot[1].trim(), Number.isNaN(pts) ? null : pts],
96
+ };
97
+ }
98
+ // `ModelType: w1, w2` — a model bullet with inline wargear.
99
+ const idx = rest.indexOf(":");
100
+ if (idx >= 0) {
101
+ const wargear = rest.slice(idx + 1).trim();
102
+ return {
103
+ indent,
104
+ count,
105
+ name: rest.slice(0, idx).trim(),
106
+ colon_wargear: wargear.length > 0 ? wargear : null,
107
+ is_annotation: false,
108
+ enhancement: null,
109
+ };
110
+ }
111
+ // Bare token: annotation iff it has no count (Warlord / Character / wargear).
112
+ return {
113
+ indent,
114
+ count,
115
+ name: rest,
116
+ colon_wargear: null,
117
+ is_annotation: count === null,
118
+ enhancement: null,
119
+ };
120
+ }
121
+ function finishUnit(acc) {
122
+ const topIndent = acc.bullets.length
123
+ ? Math.min(...acc.bullets.map((b) => b.indent))
124
+ : 0;
125
+ const wargear = new Map();
126
+ const addWargear = (raw_name, count) => {
127
+ const name = raw_name.trim();
128
+ if (!name)
129
+ return;
130
+ wargear.set(name, (wargear.get(name) ?? 0) + count);
131
+ };
132
+ let model_count = 0;
133
+ let is_warlord = false;
134
+ let is_character = acc.is_character_section;
135
+ let enhancement_raw_name = null;
136
+ let enhancement_points = null;
137
+ for (let i = 0; i < acc.bullets.length; i += 1) {
138
+ const b = acc.bullets[i];
139
+ // Child bullet: a model group's squad-wide wargear (count already total).
140
+ if (b.indent > topIndent) {
141
+ addWargear(b.name, b.count ?? 1);
142
+ continue;
143
+ }
144
+ // Enhancement annotation (`Enhancements: X` or `X (+N pts)`).
145
+ if (b.enhancement) {
146
+ if (enhancement_raw_name === null) {
147
+ enhancement_raw_name = b.enhancement[0];
148
+ enhancement_points = b.enhancement[1];
149
+ }
150
+ continue;
151
+ }
152
+ // Model with inline `: wargear` (the `##`/fixture dialect).
153
+ if (b.colon_wargear !== null) {
154
+ const n = b.count ?? 1;
155
+ model_count += n;
156
+ for (const item of b.colon_wargear.split(",").map((s) => s.trim())) {
157
+ if (item)
158
+ addWargear(item, n);
159
+ }
160
+ continue;
161
+ }
162
+ // Model group: top-level bullet followed by deeper child bullets.
163
+ const next = acc.bullets[i + 1];
164
+ if (next && next.indent > topIndent) {
165
+ model_count += b.count ?? 1;
166
+ continue;
167
+ }
168
+ // Annotation (no count): Warlord / Character flags, else bare wargear.
169
+ if (b.is_annotation) {
170
+ const leftover = [];
171
+ for (const token of b.name.split(",").map((t) => t.trim())) {
172
+ if (!token)
173
+ continue;
174
+ if (token === WARLORD_MARKER) {
175
+ is_warlord = true;
176
+ }
177
+ else if (token.endsWith(CHARACTER_SUFFIX)) {
178
+ is_character = true;
179
+ }
180
+ else {
181
+ leftover.push(token);
182
+ }
183
+ }
184
+ for (const token of leftover)
185
+ addWargear(token, 1);
186
+ continue;
187
+ }
188
+ // Plain `Nx` wargear on a single-model unit.
189
+ addWargear(b.name, b.count ?? 1);
190
+ }
191
+ if (model_count === 0)
192
+ model_count = 1;
193
+ let points = acc.displayed_pts;
194
+ if (acc.displayed_pts !== null && enhancement_points !== null) {
195
+ points = Math.max(0, acc.displayed_pts - enhancement_points);
196
+ }
197
+ return {
198
+ raw_name: acc.raw_name,
199
+ is_character,
200
+ model_count,
201
+ points,
202
+ is_warlord,
203
+ enhancement_raw_name,
204
+ enhancement_points,
205
+ wargear: [...wargear].map(([raw_name, count]) => ({ raw_name, count })),
206
+ };
207
+ }
208
+ function isBattleSize(name) {
209
+ return BATTLE_SIZE_NAMES.has(name.trim().toLowerCase());
210
+ }
211
+ export const gwHeaderlessAdapter = {
212
+ // Provenance: a GW-family plain-text export. Reuses the `gw` id so no schema
213
+ // churn is needed for a new label (mirrors the Rust adapter).
214
+ id: "gw",
215
+ matches(decoded) {
216
+ return headerlessText(decoded) !== null;
217
+ },
218
+ parse(decoded) {
219
+ const text = headerlessText(decoded);
220
+ if (text === null) {
221
+ throw new Error("gw-headerless: not a headerless plain-text list");
222
+ }
223
+ let name = "Imported roster";
224
+ let declared_limit = null;
225
+ let battle_size_raw = null;
226
+ const units = [];
227
+ let current = null;
228
+ let section = null;
229
+ let allied = 0;
230
+ let consumed_title = false;
231
+ // The GW app export lists faction then detachment as bare lines between the
232
+ // title and the first section. Capture the first two so `resolve` can scope
233
+ // to them; later bare lines (stray notes) are ignored.
234
+ let faction_raw_name = null;
235
+ const detachment_raw_names = [];
236
+ const flush = () => {
237
+ if (current) {
238
+ units.push(finishUnit(current));
239
+ current = null;
240
+ }
241
+ };
242
+ for (const rawLine of text.split("\n")) {
243
+ const raw = rawLine.replace(/\r+$/, "");
244
+ const line = raw.trim();
245
+ if (!line)
246
+ continue;
247
+ // Bullets attach to the open unit.
248
+ const bullet = RE_BULLET.exec(raw);
249
+ if (bullet) {
250
+ if (current)
251
+ current.bullets.push(parseBullet(bullet[1].length, bullet[2]));
252
+ continue;
253
+ }
254
+ // GW export footer.
255
+ if (line.startsWith("Exported with"))
256
+ continue;
257
+ // `## Section` markdown header (strip an optional `(N pts)` tail).
258
+ const md = RE_MD_SECTION.exec(line);
259
+ if (md) {
260
+ flush();
261
+ const pts = RE_PTS_LINE.exec(md[1]);
262
+ section = pts ? pts[1].trim() : md[1].trim();
263
+ continue;
264
+ }
265
+ // First `Name (N pts|Points)` line is the roster title, not a unit.
266
+ const pts = RE_PTS_LINE.exec(line);
267
+ if (pts) {
268
+ const header_name = pts[1].trim();
269
+ const points = parsePts(pts[2]);
270
+ if (!consumed_title && current === null && units.length === 0) {
271
+ consumed_title = true;
272
+ name = header_name;
273
+ declared_limit = points;
274
+ continue;
275
+ }
276
+ // Battle-size metadata (`Strike Force (2,000 Points)`).
277
+ if (isBattleSize(header_name)) {
278
+ battle_size_raw = line;
279
+ if (declared_limit === null)
280
+ declared_limit = points;
281
+ continue;
282
+ }
283
+ // A real unit header.
284
+ flush();
285
+ const inChars = section?.toLowerCase() === CHARACTERS_SECTION.toLowerCase();
286
+ if (section === ALLIED_SECTION)
287
+ allied += 1;
288
+ current = {
289
+ raw_name: header_name,
290
+ displayed_pts: points,
291
+ is_character_section: inChars,
292
+ bullets: [],
293
+ };
294
+ continue;
295
+ }
296
+ // Section headers without points (ALL-CAPS role, `Title:` colon).
297
+ if (RE_CAPS_SECTION.test(line) || RE_COLON_SECTION.test(line)) {
298
+ flush();
299
+ section = line.replace(/:\s*$/, "").trim();
300
+ continue;
301
+ }
302
+ // Anything else (faction/detachment preamble, stray notes).
303
+ if (!consumed_title && current === null && units.length === 0) {
304
+ // Very first content line with no `(N pts)` title → use as name.
305
+ consumed_title = true;
306
+ name = line;
307
+ }
308
+ else if (current === null && units.length === 0) {
309
+ // Preamble after the title, before the first unit: faction then
310
+ // detachment. Names are resolved (and warned on miss) downstream.
311
+ if (faction_raw_name === null) {
312
+ faction_raw_name = line;
313
+ }
314
+ else if (detachment_raw_names.length === 0) {
315
+ detachment_raw_names.push(line);
316
+ }
317
+ }
318
+ }
319
+ flush();
320
+ let total_computed = 0;
321
+ for (const u of units) {
322
+ total_computed += (u.points ?? 0) + (u.enhancement_points ?? 0);
323
+ }
324
+ return {
325
+ name,
326
+ generated_by: null,
327
+ faction_raw_name,
328
+ detachment_raw_names,
329
+ battle_size_raw: battle_size_raw ?? inferBattleSizeRaw(declared_limit),
330
+ declared_limit,
331
+ total_reported: null,
332
+ total_computed,
333
+ units,
334
+ multi_force: allied > 0,
335
+ };
336
+ },
337
+ };
338
+ // Internals re-exported for unit tests.
339
+ export const _internals = {
340
+ headerlessText,
341
+ parseBullet,
342
+ };
343
+ //# sourceMappingURL=gw-headerless.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gw-headerless.js","sourceRoot":"","sources":["../../src/import/gw-headerless.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Headerless plain-text adapter: the GW 40K app's *exported* list (no\n * `++…++` / `+ FACTION KEYWORD:` summary fence), the NewRecruit \"copy as text\"\n * dialect, and the markdown-ish `## Section (N pts)` shape hand-authored lists\n * use. All three share one body grammar; they differ only in cosmetic framing,\n * so a single lenient parser covers them.\n *\n * Shape (any of):\n *\n * ```\n * <list name> (1995 Points) ← title line (consumed, not a unit)\n * World Eaters ← faction (bare preamble line)\n * Berzerker Warband ← detachment (bare preamble line)\n * Strike Force (2,000 Points) ← battle-size metadata\n *\n * CHARACTERS ← ALL-CAPS role section …\n * ## Battleline (200 pts) ← … or `##` markdown section …\n * Epic Hero: ← … or `Title:` colon section\n *\n * Khârn the Betrayer (100 Points) ← unit header: Name (N pts|Points)\n * • Warlord ← annotation\n * • 1x Gorechild ← Nx wargear (single-model unit)\n * • Enhancements: Berzerker Glaive ← enhancement\n * Khorne Berzerkers (180 Points)\n * • 9x Khorne Berzerker ← model group (has ◦ children) …\n * ◦ 8x Bolt pistol ← … children are squad-wide wargear\n * • 4x Intercessor: Bolt rifle ← model group (colon wargear, no children)\n * ```\n *\n * **Model vs wargear** (the crux), unified across dialects: a top-level bullet\n * is a *model group* when it carries a `: wargear` colon **or** is followed by\n * deeper-indented child bullets; its `Nx` count (default 1) adds to the model\n * count. Otherwise it is plain wargear (an `Nx`/bare item) or an annotation\n * (`Warlord`, `… Character`, `Enhancements: …`).\n *\n * **Faction / detachment**: the GW export lists them as bare lines between the\n * title and the first section (`World Eaters` / `Berzerker Warband`). The first\n * two such preamble lines are captured so `resolve` can scope to them.\n *\n * **Disjointness**: this adapter is the fallback for bullet-bearing text that\n * the framed adapters reject — it declines input carrying the GW\n * `+ FACTION KEYWORD:` fence (→ {@link gwAdapter}), the NewRecruit\n * `# ++ Army Roster ++` header (→ newrecruit-simple), or WTC `N with` body\n * lines, and requires at least one `•`/`◦` bullet.\n *\n * @packageDocumentation\n */\nimport type { FormatAdapter } from \"./adapter.js\";\nimport type { ParsedRoster, ParsedUnit, ParsedWargear } from \"./types.js\";\nimport { inferBattleSizeRaw } from \"./newrecruit-text.js\";\n\nconst CHARACTERS_SECTION = \"CHARACTERS\";\nconst ALLIED_SECTION = \"ALLIED UNITS\";\nconst CHARACTER_SUFFIX = \" Character\";\nconst WARLORD_MARKER = \"Warlord\";\n\n/** Title / unit header: `Name (N pts|Points)` with an optional trailing comment\n * (the GW export sometimes appends TO notes). Points may carry thousands\n * commas. Case-insensitive `pts`/`points`. */\nconst RE_PTS_LINE = /^(.+?)\\s*\\(\\s*([\\d,]+)\\s*(?:pts?|points?)\\s*\\).*$/i;\n/** `## Section [ (N pts) ]` markdown header. */\nconst RE_MD_SECTION = /^#{1,6}\\s*(.+?)\\s*$/;\n/** ALL-CAPS role section (`CHARACTERS`, `OTHER DATASHEETS`, …). */\nconst RE_CAPS_SECTION = /^[A-Z][A-Z0-9 \\-/&]+$/;\n/** `Title:` colon section (`Epic Hero:`, `Battleline:`). */\nconst RE_COLON_SECTION = /^([A-Za-z][\\w /&-]*):\\s*$/;\n/** Bullet line: leading indent, a `•` or `◦` marker, then the body. */\nconst RE_BULLET = /^([\\t ]*)[•◦]\\s*(.+?)\\s*$/u;\nconst RE_NX_PREFIX = /^(\\d+)x\\s+(.+)$/i;\n/** Inline enhancement annotation: `Name (+N pts)`. */\nconst RE_ENHANCEMENT_ANNOT = /^(.+?)\\s*\\(\\+\\s*(\\d+)\\s*pts?\\s*\\)\\s*$/i;\n/** `Enhancements: X` / `E: X` enhancement bullet. */\nconst RE_ENHANCEMENT_LABEL = /^(?:e|enh|enhancement|enhancements)\\s*:\\s*(.+)$/i;\nconst RE_WITH_LINE = /^[\\t ]*\\d+\\s+with\\b/m;\nconst RE_BULLET_ANYWHERE = /^[\\t ]*[•◦]/mu;\n/** ListForge-text first line: `<name> - <faction> - <detachment> (N Points)`.\n * Used only to *decline* — that framed header belongs to `listForgeTextAdapter`,\n * which runs ahead of us; declining keeps the matchers mutually exclusive. */\nconst RE_LISTFORGE_FIRST_LINE = /^(.+)\\s\\(\\s*\\d+\\s*Points?\\s*\\)\\s*$/i;\n\n/** Battle-size labels that look like unit headers (`Strike Force (2,000 Points)`)\n * but are army metadata, not datasheets. */\nconst BATTLE_SIZE_NAMES = new Set([\n \"combat patrol\",\n \"incursion\",\n \"strike force\",\n \"onslaught\",\n]);\n\nfunction parsePts(raw: string): number | null {\n const n = Number.parseInt(raw.replace(/,/g, \"\"), 10);\n return Number.isNaN(n) ? null : n;\n}\n\n/** Accept bullet-bearing plain text that no framed adapter claims. */\nfunction headerlessText(decoded: unknown): string | null {\n if (typeof decoded !== \"string\") return null;\n if (!RE_BULLET_ANYWHERE.test(decoded)) return null; // need a bullet\n if (decoded.includes(\"+ FACTION KEYWORD:\")) return null; // framed GW → gwAdapter\n if (RE_WITH_LINE.test(decoded)) return null; // WTC-full\n const lines = decoded.split(/\\r?\\n/);\n // ListForge-text's `name - faction - detachment (N Points)` header → defer to\n // listForgeTextAdapter (registered ahead of us). Mirrors its own matcher so\n // the two stay disjoint, per the importer's single-match invariant.\n const firstNonBlank = lines.find((l) => l.trim().length > 0);\n const lf = firstNonBlank && RE_LISTFORGE_FIRST_LINE.exec(firstNonBlank.trim());\n if (lf && lf[1].split(\" - \").length >= 3) return null;\n // NewRecruit `# ++ Army Roster ++` → newrecruit-simple.\n if (\n lines.some((l) => {\n const t = l.trim();\n return t.startsWith(\"# ++\") && t.includes(\"Army Roster\");\n })\n ) {\n return null;\n }\n // Require a `Name (N pts|Points)` line somewhere — the unit/title signature.\n return lines.some((l) => RE_PTS_LINE.test(l.trim())) ? decoded : null;\n}\n\ninterface Bullet {\n indent: number;\n count: number | null;\n /** Model/wargear name (after any `Nx` and before any `: wargear`). */\n name: string;\n /** Comma-separated wargear listed after a `:` on a model bullet. */\n colon_wargear: string | null;\n /** True for `Warlord` / `… Character` / `Enhancements:` annotations. */\n is_annotation: boolean;\n /** `[name, points]` when this bullet declares an enhancement. */\n enhancement: [string, number | null] | null;\n}\n\ninterface UnitAcc {\n raw_name: string;\n displayed_pts: number | null;\n is_character_section: boolean;\n bullets: Bullet[];\n}\n\nfunction parseBullet(indent: number, body: string): Bullet {\n // Enhancement label first — `Enhancements: X` must not read as a model.\n const label = RE_ENHANCEMENT_LABEL.exec(body);\n if (label) {\n return {\n indent,\n count: null,\n name: \"\",\n colon_wargear: null,\n is_annotation: true,\n enhancement: [label[1].trim(), null],\n };\n }\n\n const nx = RE_NX_PREFIX.exec(body);\n const count = nx ? Number.parseInt(nx[1], 10) : null;\n const rest = (nx ? nx[2] : body).trim();\n\n // `Name (+N pts)` enhancement annotation.\n const annot = RE_ENHANCEMENT_ANNOT.exec(rest);\n if (annot) {\n const pts = Number.parseInt(annot[2], 10);\n return {\n indent,\n count,\n name: rest,\n colon_wargear: null,\n is_annotation: true,\n enhancement: [annot[1].trim(), Number.isNaN(pts) ? null : pts],\n };\n }\n\n // `ModelType: w1, w2` — a model bullet with inline wargear.\n const idx = rest.indexOf(\":\");\n if (idx >= 0) {\n const wargear = rest.slice(idx + 1).trim();\n return {\n indent,\n count,\n name: rest.slice(0, idx).trim(),\n colon_wargear: wargear.length > 0 ? wargear : null,\n is_annotation: false,\n enhancement: null,\n };\n }\n\n // Bare token: annotation iff it has no count (Warlord / Character / wargear).\n return {\n indent,\n count,\n name: rest,\n colon_wargear: null,\n is_annotation: count === null,\n enhancement: null,\n };\n}\n\nfunction finishUnit(acc: UnitAcc): ParsedUnit {\n const topIndent = acc.bullets.length\n ? Math.min(...acc.bullets.map((b) => b.indent))\n : 0;\n\n const wargear = new Map<string, number>();\n const addWargear = (raw_name: string, count: number): void => {\n const name = raw_name.trim();\n if (!name) return;\n wargear.set(name, (wargear.get(name) ?? 0) + count);\n };\n\n let model_count = 0;\n let is_warlord = false;\n let is_character = acc.is_character_section;\n let enhancement_raw_name: string | null = null;\n let enhancement_points: number | null = null;\n\n for (let i = 0; i < acc.bullets.length; i += 1) {\n const b = acc.bullets[i];\n\n // Child bullet: a model group's squad-wide wargear (count already total).\n if (b.indent > topIndent) {\n addWargear(b.name, b.count ?? 1);\n continue;\n }\n\n // Enhancement annotation (`Enhancements: X` or `X (+N pts)`).\n if (b.enhancement) {\n if (enhancement_raw_name === null) {\n enhancement_raw_name = b.enhancement[0];\n enhancement_points = b.enhancement[1];\n }\n continue;\n }\n\n // Model with inline `: wargear` (the `##`/fixture dialect).\n if (b.colon_wargear !== null) {\n const n = b.count ?? 1;\n model_count += n;\n for (const item of b.colon_wargear.split(\",\").map((s) => s.trim())) {\n if (item) addWargear(item, n);\n }\n continue;\n }\n\n // Model group: top-level bullet followed by deeper child bullets.\n const next = acc.bullets[i + 1];\n if (next && next.indent > topIndent) {\n model_count += b.count ?? 1;\n continue;\n }\n\n // Annotation (no count): Warlord / Character flags, else bare wargear.\n if (b.is_annotation) {\n const leftover: string[] = [];\n for (const token of b.name.split(\",\").map((t) => t.trim())) {\n if (!token) continue;\n if (token === WARLORD_MARKER) {\n is_warlord = true;\n } else if (token.endsWith(CHARACTER_SUFFIX)) {\n is_character = true;\n } else {\n leftover.push(token);\n }\n }\n for (const token of leftover) addWargear(token, 1);\n continue;\n }\n\n // Plain `Nx` wargear on a single-model unit.\n addWargear(b.name, b.count ?? 1);\n }\n\n if (model_count === 0) model_count = 1;\n\n let points = acc.displayed_pts;\n if (acc.displayed_pts !== null && enhancement_points !== null) {\n points = Math.max(0, acc.displayed_pts - enhancement_points);\n }\n\n return {\n raw_name: acc.raw_name,\n is_character,\n model_count,\n points,\n is_warlord,\n enhancement_raw_name,\n enhancement_points,\n wargear: [...wargear].map(\n ([raw_name, count]): ParsedWargear => ({ raw_name, count }),\n ),\n };\n}\n\nfunction isBattleSize(name: string): boolean {\n return BATTLE_SIZE_NAMES.has(name.trim().toLowerCase());\n}\n\nexport const gwHeaderlessAdapter: FormatAdapter = {\n // Provenance: a GW-family plain-text export. Reuses the `gw` id so no schema\n // churn is needed for a new label (mirrors the Rust adapter).\n id: \"gw\",\n\n matches(decoded: unknown): boolean {\n return headerlessText(decoded) !== null;\n },\n\n parse(decoded: unknown): ParsedRoster {\n const text = headerlessText(decoded);\n if (text === null) {\n throw new Error(\"gw-headerless: not a headerless plain-text list\");\n }\n\n let name = \"Imported roster\";\n let declared_limit: number | null = null;\n let battle_size_raw: string | null = null;\n const units: ParsedUnit[] = [];\n let current: UnitAcc | null = null;\n let section: string | null = null;\n let allied = 0;\n let consumed_title = false;\n // The GW app export lists faction then detachment as bare lines between the\n // title and the first section. Capture the first two so `resolve` can scope\n // to them; later bare lines (stray notes) are ignored.\n let faction_raw_name: string | null = null;\n const detachment_raw_names: string[] = [];\n\n const flush = (): void => {\n if (current) {\n units.push(finishUnit(current));\n current = null;\n }\n };\n\n for (const rawLine of text.split(\"\\n\")) {\n const raw = rawLine.replace(/\\r+$/, \"\");\n const line = raw.trim();\n if (!line) continue;\n\n // Bullets attach to the open unit.\n const bullet = RE_BULLET.exec(raw);\n if (bullet) {\n if (current) current.bullets.push(parseBullet(bullet[1].length, bullet[2]));\n continue;\n }\n\n // GW export footer.\n if (line.startsWith(\"Exported with\")) continue;\n\n // `## Section` markdown header (strip an optional `(N pts)` tail).\n const md = RE_MD_SECTION.exec(line);\n if (md) {\n flush();\n const pts = RE_PTS_LINE.exec(md[1]);\n section = pts ? pts[1].trim() : md[1].trim();\n continue;\n }\n\n // First `Name (N pts|Points)` line is the roster title, not a unit.\n const pts = RE_PTS_LINE.exec(line);\n if (pts) {\n const header_name = pts[1].trim();\n const points = parsePts(pts[2]);\n if (!consumed_title && current === null && units.length === 0) {\n consumed_title = true;\n name = header_name;\n declared_limit = points;\n continue;\n }\n // Battle-size metadata (`Strike Force (2,000 Points)`).\n if (isBattleSize(header_name)) {\n battle_size_raw = line;\n if (declared_limit === null) declared_limit = points;\n continue;\n }\n // A real unit header.\n flush();\n const inChars = section?.toLowerCase() === CHARACTERS_SECTION.toLowerCase();\n if (section === ALLIED_SECTION) allied += 1;\n current = {\n raw_name: header_name,\n displayed_pts: points,\n is_character_section: inChars,\n bullets: [],\n };\n continue;\n }\n\n // Section headers without points (ALL-CAPS role, `Title:` colon).\n if (RE_CAPS_SECTION.test(line) || RE_COLON_SECTION.test(line)) {\n flush();\n section = line.replace(/:\\s*$/, \"\").trim();\n continue;\n }\n\n // Anything else (faction/detachment preamble, stray notes).\n if (!consumed_title && current === null && units.length === 0) {\n // Very first content line with no `(N pts)` title → use as name.\n consumed_title = true;\n name = line;\n } else if (current === null && units.length === 0) {\n // Preamble after the title, before the first unit: faction then\n // detachment. Names are resolved (and warned on miss) downstream.\n if (faction_raw_name === null) {\n faction_raw_name = line;\n } else if (detachment_raw_names.length === 0) {\n detachment_raw_names.push(line);\n }\n }\n }\n flush();\n\n let total_computed = 0;\n for (const u of units) {\n total_computed += (u.points ?? 0) + (u.enhancement_points ?? 0);\n }\n\n return {\n name,\n generated_by: null,\n faction_raw_name,\n detachment_raw_names,\n battle_size_raw: battle_size_raw ?? inferBattleSizeRaw(declared_limit),\n declared_limit,\n total_reported: null,\n total_computed,\n units,\n multi_force: allied > 0,\n };\n },\n};\n\n// Internals re-exported for unit tests.\nexport const _internals = {\n headerlessText,\n parseBullet,\n};\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"import-roster.d.ts","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAelD,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAkCvD,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAG/E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAUhF;AAyBD,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAM/E;AAMD,mEAAmE;AACnE,MAAM,MAAM,mBAAmB,GAC3B,aAAa,GACb,eAAe,GACf,oBAAoB,GACpB,cAAc,CAAC;AAEnB,mEAAmE;AACnE,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,YAAY,CAAC;IACjB,gFAAgF;IAChF,OAAO,EAAE,OAAO,CAAC;IACjB;6DACyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,gEAAgE;AAChE,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,GAClD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,mBAAmB,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB,CAAC;AAWN;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,aAAkB,GACvB,YAAY,CA+Dd;AAED;uCACuC;AACvC,eAAO,MAAM,mBAAmB,EAAE,SAAS,aAAa,EAAa,CAAC"}
1
+ {"version":3,"file":"import-roster.d.ts","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC7C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAgBlD,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAyCvD,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAG/E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAUhF;AAyBD,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,GAAE,aAAkB,GAAG,MAAM,CAM/E;AAMD,mEAAmE;AACnE,MAAM,MAAM,mBAAmB,GAC3B,aAAa,GACb,eAAe,GACf,oBAAoB,GACpB,cAAc,CAAC;AAEnB,mEAAmE;AACnE,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,YAAY,CAAC;IACjB,gFAAgF;IAChF,OAAO,EAAE,OAAO,CAAC;IACjB;6DACyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,gEAAgE;AAChE,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,GAClD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,mBAAmB,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB,CAAC;AAWN;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,aAAkB,GACvB,YAAY,CA+Dd;AAED;uCACuC;AACvC,eAAO,MAAM,mBAAmB,EAAE,SAAS,aAAa,EAAa,CAAC"}
@@ -14,6 +14,7 @@ import { Dataset } from "../data/dataset.js";
14
14
  import { selectAdapter } from "./adapter.js";
15
15
  import { decodeListForge } from "./decode.js";
16
16
  import { gwAdapter } from "./gw.js";
17
+ import { gwHeaderlessAdapter } from "./gw-headerless.js";
17
18
  import { listForgeAdapter } from "./listforge.js";
18
19
  import { listForgeTextAdapter } from "./listforge-text.js";
19
20
  import { newRecruitJsonAdapter } from "./newrecruit-json.js";
@@ -50,7 +51,14 @@ const ADAPTERS = [
50
51
  newRecruitWtcFullAdapter,
51
52
  newRecruitWtcCompactAdapter,
52
53
  newRecruitSimpleAdapter,
54
+ // listforge-text requires the `name - faction - detachment (N Points)` first
55
+ // line none of the others accept; it runs right before the headerless
56
+ // fallback so its framed header wins when present.
53
57
  listForgeTextAdapter,
58
+ // Fallback for bullet-bearing plain text without a summary fence (GW app
59
+ // export, NewRecruit copy-text, `##` markdown lists). Placed after the framed
60
+ // text adapters so they win when their headers are present.
61
+ gwHeaderlessAdapter,
54
62
  listForgeAdapter,
55
63
  ];
56
64
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"import-roster.js","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates an army-list import: decode → parse → resolve.\n *\n * The adapter seam ({@link FormatAdapter}) lets every supported source format\n * plug in here without touching {@link decode} or {@link resolve}. Adapters are\n * registered in priority order, and every adapter's `matches()` predicate is\n * tight enough that **at most one** matches any given decoded payload —\n * {@link tryImportRoster} relies on that disjointness to short-circuit on the\n * first match.\n *\n * @packageDocumentation\n */\nimport { Dataset } from \"../data/dataset.js\";\nimport type { FormatAdapter } from \"./adapter.js\";\nimport { selectAdapter } from \"./adapter.js\";\nimport { decodeListForge } from \"./decode.js\";\nimport { gwAdapter } from \"./gw.js\";\nimport { listForgeAdapter } from \"./listforge.js\";\nimport { listForgeTextAdapter } from \"./listforge-text.js\";\nimport { newRecruitJsonAdapter } from \"./newrecruit-json.js\";\nimport { newRecruitSimpleAdapter } from \"./newrecruit-simple.js\";\nimport {\n newRecruitWtcCompactAdapter,\n newRecruitWtcFullAdapter,\n} from \"./newrecruit-wtc.js\";\nimport { resolve } from \"./resolve.js\";\nimport { rosterJsonAdapter } from \"./roster-json.js\";\nimport { rosterizerAdapter } from \"./rosterizer.js\";\nimport type { Roster, RosterFormat } from \"./types.js\";\n\n/**\n * Adapters available to {@link importRoster}, in match-priority order.\n *\n * NewRecruit-JSON runs ahead of ListForge because both recognise a\n * `roster.forces` BattleScribe payload, and the NewRecruit signature is more\n * specific (`xmlns: rosterSchema` or `generatedBy: newrecruit.eu`). The text\n * adapters (`gw` / `wtc-compact` / `wtc-full` / `simple` / `listforge-text`)\n * only match strings and disambiguate among themselves via structural cues, so\n * their order amongst each other doesn't matter; wtc-full goes before\n * wtc-compact because its matcher is the more specific of the two. GW shares\n * the WTC summary header but carries `•` bullets and no `N with` lines, so it\n * stays disjoint from both wtc matchers; listforge-text requires the\n * `name - faction - detachment (N Points)` first line none of the others\n * accept. Rosterizer rides at the top of the JSON dispatch — its `rulebook` +\n * `snapshot` signature is structurally distinct from the BattleScribe\n * `roster.forces` shape.\n */\nconst ADAPTERS: readonly FormatAdapter[] = [\n // roster-json runs first: its matcher is the most specific (the canonical\n // source.format + game_version + diagnostics envelope), so a 40kdc-native\n // export is never mis-detected by a looser matcher.\n rosterJsonAdapter,\n rosterizerAdapter,\n newRecruitJsonAdapter,\n gwAdapter,\n newRecruitWtcFullAdapter,\n newRecruitWtcCompactAdapter,\n newRecruitSimpleAdapter,\n listForgeTextAdapter,\n listForgeAdapter,\n];\n\nexport interface ImportOptions {\n /** Dataset to resolve against. Defaults to the package's embedded dataset. */\n dataset?: Dataset;\n}\n\n/**\n * Import a ListForge share payload into a resolved 40kdc {@link Roster}.\n *\n * `input` may be a full ListForge URL, a bare base64 segment, or an\n * already-decoded JSON string — all are handled transparently. For NewRecruit\n * sources, use {@link importNewRecruit} (no base64/gzip decode).\n */\nexport function importListForge(input: string, opts: ImportOptions = {}): Roster {\n const decoded = decodeListForge(input);\n return importRoster(decoded, opts);\n}\n\n/**\n * Import a NewRecruit export (any of the four formats — JSON, wtc-compact,\n * wtc-full, simple) into a resolved 40kdc {@link Roster}.\n *\n * The JSON form is parsed when `input` is valid JSON; the text forms are\n * dispatched on string content. No base64/gzip decoding is attempted —\n * NewRecruit exports are not encoded.\n */\nexport function importNewRecruit(input: string, opts: ImportOptions = {}): Roster {\n const trimmed = input.trimStart();\n if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n return importRoster(JSON.parse(input), opts);\n } catch {\n // Fall through to treating the input as raw text.\n }\n }\n return importRoster(input, opts);\n}\n\n/**\n * Import an already-decoded payload. Selects the matching format adapter and\n * resolves the result against the dataset. Accepts either a parsed JSON object\n * (NewRecruit JSON / ListForge) or a string (the three NewRecruit text formats).\n */\n/**\n * Detect an already-resolved canonical {@link Roster} (the JSON shape produced\n * by `rosterJsonSerializer`). Lets a downstream consumer round-trip canonical\n * Roster JSON through `importRoster` without going through an adapter.\n */\nfunction isCanonicalRoster(decoded: unknown): decoded is Roster {\n if (typeof decoded !== \"object\" || decoded === null) return false;\n const r = decoded as Record<string, unknown>;\n const source = r.source as Record<string, unknown> | undefined;\n return (\n typeof source === \"object\" &&\n source !== null &&\n typeof source.format === \"string\" &&\n Array.isArray(r.units) &&\n \"diagnostics\" in r\n );\n}\n\nexport function importRoster(decoded: unknown, opts: ImportOptions = {}): Roster {\n if (isCanonicalRoster(decoded)) return decoded;\n const ds = opts.dataset ?? Dataset.embedded();\n const adapter = selectAdapter(decoded, [...ADAPTERS]);\n const parsed = adapter.parse(decoded);\n return resolve(parsed, ds, adapter.id);\n}\n\n// ---------------------------------------------------------------------------\n// tryImportRoster — single string-in, structured-result-out entry point.\n// ---------------------------------------------------------------------------\n\n/** Why a {@link tryImportRoster} call did not produce a roster. */\nexport type ImportFailureReason =\n | \"empty-input\"\n | \"decode-failed\"\n | \"no-adapter-matched\"\n | \"parse-failed\";\n\n/** Per-adapter outcome from a {@link tryImportRoster} dispatch. */\nexport interface AdapterTrial {\n id: RosterFormat;\n /** True iff this adapter's `matches()` predicate accepted the decoded input. */\n matched: boolean;\n /** Present when {@link matched} is true and `parse()` then threw — the matcher\n * violated its contract. Absent for clean rejections. */\n reason?: string;\n}\n\n/** Discriminated result returned by {@link tryImportRoster}. */\nexport type ImportResult =\n | { ok: true; roster: Roster; format: RosterFormat }\n | {\n ok: false;\n reason: ImportFailureReason;\n message: string;\n trials: AdapterTrial[];\n };\n\n/** Cheap predicate: does the input look like ListForge's URL-or-base64 wrapper? */\nfunction looksLikeListForgeEncoded(input: string): boolean {\n if (input.includes(\"/listforge/\")) return true;\n if (/^https?:\\/\\//i.test(input)) return true;\n // Every gzip-then-base64 payload starts with this prefix.\n if (input.startsWith(\"H4sIA\")) return true;\n return false;\n}\n\n/**\n * Auto-detect and import any supported roster format from a single string.\n *\n * Pipeline:\n * 1. Empty input → `empty-input`.\n * 2. Looks like a ListForge URL / base64 payload → decode (base64 + gunzip + JSON.parse).\n * 3. Looks like raw JSON (starts with `{`/`[`) → JSON.parse.\n * 4. Otherwise treat as text.\n * 5. Greedy first-match adapter dispatch. The first adapter whose `matches()`\n * accepts the decoded value wins; subsequent adapters are not tried.\n * 6. If the matched adapter's `parse()` throws, that's a matcher contract\n * violation — surfaced as `parse-failed`, not silently retried.\n *\n * Caller never sees an exception; the discriminated {@link ImportResult} carries\n * either the resolved {@link Roster} (with the detected {@link RosterFormat})\n * or a typed failure plus per-adapter trial info for diagnostics.\n *\n * Prefer this over {@link importListForge} / {@link importNewRecruit} when the\n * caller doesn't know which format the user pasted.\n */\nexport function tryImportRoster(\n input: string,\n opts: ImportOptions = {},\n): ImportResult {\n const trimmed = input.trim();\n if (trimmed === \"\") {\n return { ok: false, reason: \"empty-input\", message: \"input is empty\", trials: [] };\n }\n\n let decoded: unknown;\n if (looksLikeListForgeEncoded(trimmed)) {\n try {\n decoded = decodeListForge(trimmed);\n } catch (err) {\n const message = (err as Error).message;\n return {\n ok: false,\n reason: \"decode-failed\",\n message: `failed to decode ListForge payload: ${message}`,\n trials: [{ id: \"listforge\", matched: false, reason: message }],\n };\n }\n } else if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n decoded = JSON.parse(trimmed);\n } catch (err) {\n return {\n ok: false,\n reason: \"decode-failed\",\n message: `input looks like JSON but failed to parse: ${(err as Error).message}`,\n trials: [],\n };\n }\n } else {\n decoded = input;\n }\n\n const ds = opts.dataset ?? Dataset.embedded();\n const trials: AdapterTrial[] = [];\n for (const adapter of ADAPTERS) {\n if (!adapter.matches(decoded)) {\n trials.push({ id: adapter.id, matched: false });\n continue;\n }\n try {\n const parsed = adapter.parse(decoded);\n const roster = resolve(parsed, ds, adapter.id);\n return { ok: true, roster, format: adapter.id };\n } catch (err) {\n const message = (err as Error).message;\n trials.push({ id: adapter.id, matched: true, reason: message });\n return {\n ok: false,\n reason: \"parse-failed\",\n message: `${adapter.id}: ${message}`,\n trials,\n };\n }\n }\n\n return {\n ok: false,\n reason: \"no-adapter-matched\",\n message: `tried ${ADAPTERS.length} formats, none recognised the input`,\n trials,\n };\n}\n\n/** The adapter list, exposed for tests that need to walk every matcher (e.g.\n * the disjointness invariant test). */\nexport const REGISTERED_ADAPTERS: readonly FormatAdapter[] = ADAPTERS;\n"]}
1
+ {"version":3,"file":"import-roster.js","sourceRoot":"","sources":["../../src/import/import-roster.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Orchestrates an army-list import: decode → parse → resolve.\n *\n * The adapter seam ({@link FormatAdapter}) lets every supported source format\n * plug in here without touching {@link decode} or {@link resolve}. Adapters are\n * registered in priority order, and every adapter's `matches()` predicate is\n * tight enough that **at most one** matches any given decoded payload —\n * {@link tryImportRoster} relies on that disjointness to short-circuit on the\n * first match.\n *\n * @packageDocumentation\n */\nimport { Dataset } from \"../data/dataset.js\";\nimport type { FormatAdapter } from \"./adapter.js\";\nimport { selectAdapter } from \"./adapter.js\";\nimport { decodeListForge } from \"./decode.js\";\nimport { gwAdapter } from \"./gw.js\";\nimport { gwHeaderlessAdapter } from \"./gw-headerless.js\";\nimport { listForgeAdapter } from \"./listforge.js\";\nimport { listForgeTextAdapter } from \"./listforge-text.js\";\nimport { newRecruitJsonAdapter } from \"./newrecruit-json.js\";\nimport { newRecruitSimpleAdapter } from \"./newrecruit-simple.js\";\nimport {\n newRecruitWtcCompactAdapter,\n newRecruitWtcFullAdapter,\n} from \"./newrecruit-wtc.js\";\nimport { resolve } from \"./resolve.js\";\nimport { rosterJsonAdapter } from \"./roster-json.js\";\nimport { rosterizerAdapter } from \"./rosterizer.js\";\nimport type { Roster, RosterFormat } from \"./types.js\";\n\n/**\n * Adapters available to {@link importRoster}, in match-priority order.\n *\n * NewRecruit-JSON runs ahead of ListForge because both recognise a\n * `roster.forces` BattleScribe payload, and the NewRecruit signature is more\n * specific (`xmlns: rosterSchema` or `generatedBy: newrecruit.eu`). The text\n * adapters (`gw` / `wtc-compact` / `wtc-full` / `simple` / `listforge-text`)\n * only match strings and disambiguate among themselves via structural cues, so\n * their order amongst each other doesn't matter; wtc-full goes before\n * wtc-compact because its matcher is the more specific of the two. GW shares\n * the WTC summary header but carries `•` bullets and no `N with` lines, so it\n * stays disjoint from both wtc matchers; listforge-text requires the\n * `name - faction - detachment (N Points)` first line none of the others\n * accept. Rosterizer rides at the top of the JSON dispatch — its `rulebook` +\n * `snapshot` signature is structurally distinct from the BattleScribe\n * `roster.forces` shape.\n */\nconst ADAPTERS: readonly FormatAdapter[] = [\n // roster-json runs first: its matcher is the most specific (the canonical\n // source.format + game_version + diagnostics envelope), so a 40kdc-native\n // export is never mis-detected by a looser matcher.\n rosterJsonAdapter,\n rosterizerAdapter,\n newRecruitJsonAdapter,\n gwAdapter,\n newRecruitWtcFullAdapter,\n newRecruitWtcCompactAdapter,\n newRecruitSimpleAdapter,\n // listforge-text requires the `name - faction - detachment (N Points)` first\n // line none of the others accept; it runs right before the headerless\n // fallback so its framed header wins when present.\n listForgeTextAdapter,\n // Fallback for bullet-bearing plain text without a summary fence (GW app\n // export, NewRecruit copy-text, `##` markdown lists). Placed after the framed\n // text adapters so they win when their headers are present.\n gwHeaderlessAdapter,\n listForgeAdapter,\n];\n\nexport interface ImportOptions {\n /** Dataset to resolve against. Defaults to the package's embedded dataset. */\n dataset?: Dataset;\n}\n\n/**\n * Import a ListForge share payload into a resolved 40kdc {@link Roster}.\n *\n * `input` may be a full ListForge URL, a bare base64 segment, or an\n * already-decoded JSON string — all are handled transparently. For NewRecruit\n * sources, use {@link importNewRecruit} (no base64/gzip decode).\n */\nexport function importListForge(input: string, opts: ImportOptions = {}): Roster {\n const decoded = decodeListForge(input);\n return importRoster(decoded, opts);\n}\n\n/**\n * Import a NewRecruit export (any of the four formats — JSON, wtc-compact,\n * wtc-full, simple) into a resolved 40kdc {@link Roster}.\n *\n * The JSON form is parsed when `input` is valid JSON; the text forms are\n * dispatched on string content. No base64/gzip decoding is attempted —\n * NewRecruit exports are not encoded.\n */\nexport function importNewRecruit(input: string, opts: ImportOptions = {}): Roster {\n const trimmed = input.trimStart();\n if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n return importRoster(JSON.parse(input), opts);\n } catch {\n // Fall through to treating the input as raw text.\n }\n }\n return importRoster(input, opts);\n}\n\n/**\n * Import an already-decoded payload. Selects the matching format adapter and\n * resolves the result against the dataset. Accepts either a parsed JSON object\n * (NewRecruit JSON / ListForge) or a string (the three NewRecruit text formats).\n */\n/**\n * Detect an already-resolved canonical {@link Roster} (the JSON shape produced\n * by `rosterJsonSerializer`). Lets a downstream consumer round-trip canonical\n * Roster JSON through `importRoster` without going through an adapter.\n */\nfunction isCanonicalRoster(decoded: unknown): decoded is Roster {\n if (typeof decoded !== \"object\" || decoded === null) return false;\n const r = decoded as Record<string, unknown>;\n const source = r.source as Record<string, unknown> | undefined;\n return (\n typeof source === \"object\" &&\n source !== null &&\n typeof source.format === \"string\" &&\n Array.isArray(r.units) &&\n \"diagnostics\" in r\n );\n}\n\nexport function importRoster(decoded: unknown, opts: ImportOptions = {}): Roster {\n if (isCanonicalRoster(decoded)) return decoded;\n const ds = opts.dataset ?? Dataset.embedded();\n const adapter = selectAdapter(decoded, [...ADAPTERS]);\n const parsed = adapter.parse(decoded);\n return resolve(parsed, ds, adapter.id);\n}\n\n// ---------------------------------------------------------------------------\n// tryImportRoster — single string-in, structured-result-out entry point.\n// ---------------------------------------------------------------------------\n\n/** Why a {@link tryImportRoster} call did not produce a roster. */\nexport type ImportFailureReason =\n | \"empty-input\"\n | \"decode-failed\"\n | \"no-adapter-matched\"\n | \"parse-failed\";\n\n/** Per-adapter outcome from a {@link tryImportRoster} dispatch. */\nexport interface AdapterTrial {\n id: RosterFormat;\n /** True iff this adapter's `matches()` predicate accepted the decoded input. */\n matched: boolean;\n /** Present when {@link matched} is true and `parse()` then threw — the matcher\n * violated its contract. Absent for clean rejections. */\n reason?: string;\n}\n\n/** Discriminated result returned by {@link tryImportRoster}. */\nexport type ImportResult =\n | { ok: true; roster: Roster; format: RosterFormat }\n | {\n ok: false;\n reason: ImportFailureReason;\n message: string;\n trials: AdapterTrial[];\n };\n\n/** Cheap predicate: does the input look like ListForge's URL-or-base64 wrapper? */\nfunction looksLikeListForgeEncoded(input: string): boolean {\n if (input.includes(\"/listforge/\")) return true;\n if (/^https?:\\/\\//i.test(input)) return true;\n // Every gzip-then-base64 payload starts with this prefix.\n if (input.startsWith(\"H4sIA\")) return true;\n return false;\n}\n\n/**\n * Auto-detect and import any supported roster format from a single string.\n *\n * Pipeline:\n * 1. Empty input → `empty-input`.\n * 2. Looks like a ListForge URL / base64 payload → decode (base64 + gunzip + JSON.parse).\n * 3. Looks like raw JSON (starts with `{`/`[`) → JSON.parse.\n * 4. Otherwise treat as text.\n * 5. Greedy first-match adapter dispatch. The first adapter whose `matches()`\n * accepts the decoded value wins; subsequent adapters are not tried.\n * 6. If the matched adapter's `parse()` throws, that's a matcher contract\n * violation — surfaced as `parse-failed`, not silently retried.\n *\n * Caller never sees an exception; the discriminated {@link ImportResult} carries\n * either the resolved {@link Roster} (with the detected {@link RosterFormat})\n * or a typed failure plus per-adapter trial info for diagnostics.\n *\n * Prefer this over {@link importListForge} / {@link importNewRecruit} when the\n * caller doesn't know which format the user pasted.\n */\nexport function tryImportRoster(\n input: string,\n opts: ImportOptions = {},\n): ImportResult {\n const trimmed = input.trim();\n if (trimmed === \"\") {\n return { ok: false, reason: \"empty-input\", message: \"input is empty\", trials: [] };\n }\n\n let decoded: unknown;\n if (looksLikeListForgeEncoded(trimmed)) {\n try {\n decoded = decodeListForge(trimmed);\n } catch (err) {\n const message = (err as Error).message;\n return {\n ok: false,\n reason: \"decode-failed\",\n message: `failed to decode ListForge payload: ${message}`,\n trials: [{ id: \"listforge\", matched: false, reason: message }],\n };\n }\n } else if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n try {\n decoded = JSON.parse(trimmed);\n } catch (err) {\n return {\n ok: false,\n reason: \"decode-failed\",\n message: `input looks like JSON but failed to parse: ${(err as Error).message}`,\n trials: [],\n };\n }\n } else {\n decoded = input;\n }\n\n const ds = opts.dataset ?? Dataset.embedded();\n const trials: AdapterTrial[] = [];\n for (const adapter of ADAPTERS) {\n if (!adapter.matches(decoded)) {\n trials.push({ id: adapter.id, matched: false });\n continue;\n }\n try {\n const parsed = adapter.parse(decoded);\n const roster = resolve(parsed, ds, adapter.id);\n return { ok: true, roster, format: adapter.id };\n } catch (err) {\n const message = (err as Error).message;\n trials.push({ id: adapter.id, matched: true, reason: message });\n return {\n ok: false,\n reason: \"parse-failed\",\n message: `${adapter.id}: ${message}`,\n trials,\n };\n }\n }\n\n return {\n ok: false,\n reason: \"no-adapter-matched\",\n message: `tried ${ADAPTERS.length} formats, none recognised the input`,\n trials,\n };\n}\n\n/** The adapter list, exposed for tests that need to walk every matcher (e.g.\n * the disjointness invariant test). */\nexport const REGISTERED_ADAPTERS: readonly FormatAdapter[] = ADAPTERS;\n"]}
@@ -0,0 +1,32 @@
1
+ import type { ValidationResult } from "./validate.js";
2
+ /**
3
+ * Factions whose units carry exactly one bare legion/faction faction_keyword.
4
+ *
5
+ * These are the shared-datasheet factions where contamination has been observed:
6
+ * a unit materialized from a parent template keeps the parent's faction keyword
7
+ * (e.g. a World Eaters Chaos Rhino left carrying `Emperor's Children`, or a Chaos
8
+ * Space Marines unit carrying the full multi-legion union line). For a faction in
9
+ * this map, every unit's `faction_keywords` must be a subset of `{home}`.
10
+ *
11
+ * Factions absent from this map (e.g. Space Marine chapters, which legitimately
12
+ * carry several faction_keywords) are not subject to the membership check, so the
13
+ * guard never produces a false positive on them. Extend this map as other
14
+ * single-token factions adopt the convention.
15
+ */
16
+ export declare const FACTION_HOME_KEYWORD: Record<string, string>;
17
+ /**
18
+ * Cross-entity referential integrity that per-file JSON Schema validation cannot
19
+ * express:
20
+ *
21
+ * - every unit `ability_id` must resolve to an ability defined in that faction's
22
+ * `enrichment/<faction>/abilities.json` (or the shared `enrichment/_core` pool).
23
+ * Same-faction scoping is deliberate — a union check would pass shared-unit
24
+ * contaminants because they happen to be defined in some *other* faction's
25
+ * enrichment.
26
+ * - every unit `faction_keywords` entry must be permitted for the unit's faction
27
+ * (see {@link FACTION_HOME_KEYWORD}).
28
+ *
29
+ * Results reuse {@link ValidationResult} so the CLI reporter can render them.
30
+ */
31
+ export declare function checkReferentialIntegrity(dataRoot?: string): Promise<ValidationResult>;
32
+ //# sourceMappingURL=integrity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integrity.d.ts","sourceRoot":"","sources":["../src/integrity.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAKtD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAMvD,CAAC;AAyBF;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAqE5F"}