@alpaca-software/40kdc-data 0.1.2 → 0.1.3

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 (117) hide show
  1. package/README.md +2 -0
  2. package/dist/abilities-resolver/resolver.d.ts +13 -4
  3. package/dist/abilities-resolver/resolver.d.ts.map +1 -1
  4. package/dist/abilities-resolver/resolver.js +22 -15
  5. package/dist/abilities-resolver/resolver.js.map +1 -1
  6. package/dist/audit-coverage.d.ts +78 -0
  7. package/dist/audit-coverage.d.ts.map +1 -0
  8. package/dist/audit-coverage.js +341 -0
  9. package/dist/audit-coverage.js.map +1 -0
  10. package/dist/author-batch.d.ts +147 -0
  11. package/dist/author-batch.d.ts.map +1 -0
  12. package/dist/author-batch.js +675 -0
  13. package/dist/author-batch.js.map +1 -0
  14. package/dist/author-input.d.ts +37 -0
  15. package/dist/author-input.d.ts.map +1 -0
  16. package/dist/author-input.js +162 -0
  17. package/dist/author-input.js.map +1 -0
  18. package/dist/cli.js +7 -0
  19. package/dist/cli.js.map +1 -1
  20. package/dist/commands/translate.d.ts.map +1 -1
  21. package/dist/commands/translate.js +9 -4
  22. package/dist/commands/translate.js.map +1 -1
  23. package/dist/cruncher/attribution.d.ts +66 -0
  24. package/dist/cruncher/attribution.d.ts.map +1 -0
  25. package/dist/cruncher/attribution.js +88 -0
  26. package/dist/cruncher/attribution.js.map +1 -0
  27. package/dist/cruncher/buffs.d.ts +23 -1
  28. package/dist/cruncher/buffs.d.ts.map +1 -1
  29. package/dist/cruncher/buffs.js +1 -1
  30. package/dist/cruncher/buffs.js.map +1 -1
  31. package/dist/cruncher/from-dsl.d.ts +32 -0
  32. package/dist/cruncher/from-dsl.d.ts.map +1 -1
  33. package/dist/cruncher/from-dsl.js +485 -40
  34. package/dist/cruncher/from-dsl.js.map +1 -1
  35. package/dist/cruncher/index.d.ts +1 -0
  36. package/dist/cruncher/index.d.ts.map +1 -1
  37. package/dist/cruncher/index.js +1 -0
  38. package/dist/cruncher/index.js.map +1 -1
  39. package/dist/data/bundle.generated.js +1 -1
  40. package/dist/data/bundle.generated.js.map +1 -1
  41. package/dist/data/collection.d.ts +9 -0
  42. package/dist/data/collection.d.ts.map +1 -1
  43. package/dist/data/collection.js +14 -0
  44. package/dist/data/collection.js.map +1 -1
  45. package/dist/data/dataset.d.ts +80 -2
  46. package/dist/data/dataset.d.ts.map +1 -1
  47. package/dist/data/dataset.js +143 -6
  48. package/dist/data/dataset.js.map +1 -1
  49. package/dist/data/entities.d.ts +2 -5
  50. package/dist/data/entities.d.ts.map +1 -1
  51. package/dist/data/entities.js.map +1 -1
  52. package/dist/data/index.d.ts +3 -2
  53. package/dist/data/index.d.ts.map +1 -1
  54. package/dist/data/index.js +1 -1
  55. package/dist/data/index.js.map +1 -1
  56. package/dist/data/roster-resolve.d.ts +26 -1
  57. package/dist/data/roster-resolve.d.ts.map +1 -1
  58. package/dist/data/roster-resolve.js +46 -0
  59. package/dist/data/roster-resolve.js.map +1 -1
  60. package/dist/export/index.d.ts +1 -0
  61. package/dist/export/index.d.ts.map +1 -1
  62. package/dist/export/index.js +3 -0
  63. package/dist/export/index.js.map +1 -1
  64. package/dist/export/rosterizer.d.ts +3 -0
  65. package/dist/export/rosterizer.d.ts.map +1 -0
  66. package/dist/export/rosterizer.js +144 -0
  67. package/dist/export/rosterizer.js.map +1 -0
  68. package/dist/export/serializer.d.ts +1 -1
  69. package/dist/export/serializer.d.ts.map +1 -1
  70. package/dist/export/serializer.js.map +1 -1
  71. package/dist/gen-conformance.js +212 -11
  72. package/dist/gen-conformance.js.map +1 -1
  73. package/dist/import/gw.d.ts +69 -0
  74. package/dist/import/gw.d.ts.map +1 -0
  75. package/dist/import/gw.js +245 -0
  76. package/dist/import/gw.js.map +1 -0
  77. package/dist/import/import-roster.d.ts +52 -3
  78. package/dist/import/import-roster.d.ts.map +1 -1
  79. package/dist/import/import-roster.js +114 -4
  80. package/dist/import/import-roster.js.map +1 -1
  81. package/dist/import/index.d.ts +2 -2
  82. package/dist/import/index.d.ts.map +1 -1
  83. package/dist/import/index.js +1 -1
  84. package/dist/import/index.js.map +1 -1
  85. package/dist/import/listforge.d.ts.map +1 -1
  86. package/dist/import/listforge.js +15 -1
  87. package/dist/import/listforge.js.map +1 -1
  88. package/dist/import/newrecruit-text.d.ts +3 -0
  89. package/dist/import/newrecruit-text.d.ts.map +1 -1
  90. package/dist/import/newrecruit-text.js +6 -0
  91. package/dist/import/newrecruit-text.js.map +1 -1
  92. package/dist/import/newrecruit-wtc.d.ts.map +1 -1
  93. package/dist/import/newrecruit-wtc.js +10 -7
  94. package/dist/import/newrecruit-wtc.js.map +1 -1
  95. package/dist/import/rosterizer.d.ts +70 -0
  96. package/dist/import/rosterizer.d.ts.map +1 -0
  97. package/dist/import/rosterizer.js +348 -0
  98. package/dist/import/rosterizer.js.map +1 -0
  99. package/dist/import/types.d.ts +1 -1
  100. package/dist/import/types.d.ts.map +1 -1
  101. package/dist/import/types.js.map +1 -1
  102. package/dist/index.d.ts +3 -3
  103. package/dist/index.d.ts.map +1 -1
  104. package/dist/index.js +2 -2
  105. package/dist/index.js.map +1 -1
  106. package/dist/migrations/2026-weapon-keywords.js +4 -0
  107. package/dist/migrations/2026-weapon-keywords.js.map +1 -1
  108. package/dist/runner.d.ts +38 -0
  109. package/dist/runner.d.ts.map +1 -0
  110. package/dist/runner.js +492 -0
  111. package/dist/runner.js.map +1 -0
  112. package/dist/scrub-ip.d.ts +14 -0
  113. package/dist/scrub-ip.d.ts.map +1 -0
  114. package/dist/scrub-ip.js +88 -0
  115. package/dist/scrub-ip.js.map +1 -0
  116. package/package.json +9 -2
  117. package/schemas/core/roster.schema.json +3 -1
@@ -25,7 +25,9 @@ import { fileURLToPath } from "node:url";
25
25
  import { Dataset } from "./data/dataset.js";
26
26
  import { normalizeName } from "./data/normalize.js";
27
27
  import { exportRoster } from "./export/index.js";
28
- import { importRoster } from "./import/import-roster.js";
28
+ import { importRoster, REGISTERED_ADAPTERS } from "./import/import-roster.js";
29
+ import { selectAdapter } from "./import/adapter.js";
30
+ import { attributeStages } from "./cruncher/attribution.js";
29
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
32
  const REPO_ROOT = join(__dirname, "../..");
31
33
  const CONFORMANCE = join(REPO_ROOT, "conformance");
@@ -52,6 +54,20 @@ const NORMALIZE_INPUTS = [
52
54
  // distinctness anchors (must NOT collapse together)
53
55
  "Khorne",
54
56
  "Khârn",
57
+ // Unicode whitespace beyond ASCII — every Unicode whitespace must collapse
58
+ // identically across implementations or `find("Khorne Lord")` and
59
+ // `find("Khorne Lord")` will silently disagree across ports.
60
+ "Khorne Lord",
61
+ "Khorne Lord",
62
+ // Turkish dotted-I: NFD decomposes to `I` + combining dot above; the dot is
63
+ // stripped, then locale-independent lowercase yields `i`. The case pins that
64
+ // no implementation introduces locale-aware casefolding (which would map
65
+ // `I` → `ı` under Turkish locale and break ASCII-text search).
66
+ "İmperial Fists",
67
+ // Zero-width joiner: passes through every step today. Pinned so behavior
68
+ // does not silently change — if a future commit strips Cf-category chars,
69
+ // this golden updates in the same PR.
70
+ "Khorne‍Lord",
55
71
  ];
56
72
  function writeJson(path, value) {
57
73
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
@@ -65,18 +81,39 @@ function genNormalize() {
65
81
  console.log(`normalize.json: ${table.length} cases`);
66
82
  }
67
83
  /** Locate the canonical input for a fixture dir: prefer `input.json` (legacy
68
- * ListForge), fall back to `input.newrecruit-json.json` (NewRecruit). */
84
+ * ListForge), then `input.newrecruit-json.json` (NewRecruit), then the
85
+ * text-only `input.gw.txt` (GW app export — import-only, like ListForge). */
69
86
  function seedRoster(caseDir, ds) {
70
- const candidates = ["input.json", "input.newrecruit-json.json"];
71
- for (const name of candidates) {
72
- const path = join(caseDir, name);
73
- if (existsSync(path)) {
74
- const decoded = JSON.parse(readFileSync(path, "utf8"));
75
- return importRoster(decoded, { dataset: ds });
76
- }
87
+ const decoded = decodeCanonicalSeed(caseDir);
88
+ return importRoster(decoded, { dataset: ds });
89
+ }
90
+ /** Return the decoded payload for the canonical seed — the same value the
91
+ * import pipeline would dispatch on. JSON seeds come back parsed; text seeds
92
+ * come back as the raw string. */
93
+ function decodeCanonicalSeed(caseDir) {
94
+ const jsonSeed = join(caseDir, "input.json");
95
+ if (existsSync(jsonSeed)) {
96
+ return JSON.parse(readFileSync(jsonSeed, "utf8"));
97
+ }
98
+ const nrSeed = join(caseDir, "input.newrecruit-json.json");
99
+ if (existsSync(nrSeed)) {
100
+ return JSON.parse(readFileSync(nrSeed, "utf8"));
101
+ }
102
+ const gwSeed = join(caseDir, "input.gw.txt");
103
+ if (existsSync(gwSeed)) {
104
+ return readFileSync(gwSeed, "utf8");
77
105
  }
78
106
  throw new Error(`no canonical input found in ${caseDir}`);
79
107
  }
108
+ /** Run a decoded payload through the adapter pipeline up to (but not past)
109
+ * resolution. The result is the format-agnostic ParsedRoster — the same
110
+ * intermediate the resolver consumes. Pinning this layer surfaces parser
111
+ * regressions even when resolution masks them. */
112
+ function parsedFromCanonicalSeed(caseDir) {
113
+ const decoded = decodeCanonicalSeed(caseDir);
114
+ const adapter = selectAdapter(decoded, [...REGISTERED_ADAPTERS]);
115
+ return adapter.parse(decoded);
116
+ }
80
117
  const TEXT_FORMATS = [
81
118
  {
82
119
  format: "newrecruit-wtc-compact",
@@ -103,13 +140,18 @@ function genRosters() {
103
140
  const caseDir = join(rosterDir, entry.name);
104
141
  const seed = seedRoster(caseDir, ds);
105
142
  writeJson(join(caseDir, "expected.roster.json"), seed);
143
+ // Parsed-stage golden — the intermediate ParsedRoster produced by the
144
+ // adapter for the canonical seed, before resolution. Catches parser bugs
145
+ // that resolution would otherwise mask (e.g. wrong unit count from a
146
+ // duplicate cost line that resolves to the same unit twice).
147
+ writeJson(join(caseDir, "expected.parsed.json"), parsedFromCanonicalSeed(caseDir));
106
148
  // JSON export golden — NewRecruit-shaped skeleton.
107
149
  const jsonOut = exportRoster(seed, "newrecruit-json");
108
150
  writeJson(join(caseDir, "expected.newrecruit-json.json"), JSON.parse(jsonOut));
109
151
  // Canonical Roster JSON export — should equal the resolved roster.
110
152
  writeJson(join(caseDir, "expected.roster-json.json"), JSON.parse(exportRoster(seed, "roster-json")));
111
- // Text exports: always write the export golden so the cross-implementation
112
- // byte-equality check has something to compare against. Only write the
153
+ // Text exports: always write the export golden so every fixture exercises
154
+ // the cross-implementation byte-equality check. Only write the
113
155
  // `input.*.txt` round-trip seed when the fixture was authored for the
114
156
  // NewRecruit pipeline — legacy ListForge fixtures carry decoration
115
157
  // (multi-force warnings, leader-attachment inference) that the simple/wtc
@@ -123,9 +165,168 @@ function genRosters() {
123
165
  writeText(join(caseDir, inputName), out);
124
166
  }
125
167
  }
168
+ // Rosterizer JSON export + a derived round-trip input. The exporter is
169
+ // deterministic and round-trips through the adapter, so emitting it as
170
+ // both `expected.rosterizer.json` and `input.rosterizer.json` pins the
171
+ // cross-implementation goldens and the importer regression at the same
172
+ // time. Same NewRecruit-seed gate as the text formats — multi-force
173
+ // ListForge fixtures lose their provisional leader-attachment under
174
+ // round-trip, so they only get the export golden, not the derived input.
175
+ const rosterizerOut = exportRoster(seed, "rosterizer");
176
+ writeJson(join(caseDir, "expected.rosterizer.json"), JSON.parse(rosterizerOut));
177
+ if (isNewRecruitSeed) {
178
+ writeJson(join(caseDir, "input.rosterizer.json"), JSON.parse(rosterizerOut));
179
+ }
126
180
  console.log(`roster/${entry.name}: ${seed.units.length} units, ${seed.diagnostics.warnings.length} warnings`);
127
181
  }
128
182
  }
183
+ const LINKED_API_QUERIES = [
184
+ // find_unit: diacritic-insensitive lookup, miss returns null.
185
+ { name: "find_unit by diacritic name", query: "find_unit", args: { query: "Kharn" }, comparison: "scalar" },
186
+ { name: "find_unit miss returns null", query: "find_unit", args: { query: "not-a-real-unit-xyz" }, comparison: "scalar" },
187
+ // find_weapon: hyphen + space tolerance.
188
+ { name: "find_weapon by name", query: "find_weapon", args: { query: "bolt rifle" }, comparison: "scalar" },
189
+ // find_faction: punctuation/diacritic tolerance.
190
+ { name: "find_faction by display name", query: "find_faction", args: { query: "World Eaters" }, comparison: "scalar" },
191
+ // find_ability: ability name lookup.
192
+ { name: "find_ability by name", query: "find_ability", args: { query: "Berzerker Frenzy" }, comparison: "scalar" },
193
+ // abilities_of(unit): ordered, iterates unit.ability_ids array.
194
+ { name: "abilities_of intercessor-squad", query: "abilities_of", args: { unitId: "intercessor-squad" }, comparison: "ordered" },
195
+ { name: "abilities_of kharn-the-betrayer", query: "abilities_of", args: { unitId: "kharn-the-betrayer" }, comparison: "ordered" },
196
+ // weapons_of(unit): ordered, iterates unit.weapon_ids array.
197
+ { name: "weapons_of intercessor-squad", query: "weapons_of", args: { unitId: "intercessor-squad" }, comparison: "ordered" },
198
+ { name: "weapons_of kharn-the-betrayer", query: "weapons_of", args: { unitId: "kharn-the-betrayer" }, comparison: "ordered" },
199
+ // phases_of(ability): compared as set (phase index iteration order is incidental).
200
+ { name: "phases_of berzerker-frenzy", query: "phases_of", args: { abilityId: "berzerker-frenzy" }, comparison: "set" },
201
+ // faction_of(unit): scalar id or null.
202
+ { name: "faction_of intercessor-squad", query: "faction_of", args: { unitId: "intercessor-squad" }, comparison: "scalar" },
203
+ // abilities_of_faction: compared as set (collection-index order is incidental).
204
+ { name: "abilities_of_faction world-eaters", query: "abilities_of_faction", args: { factionId: "world-eaters" }, comparison: "set" },
205
+ // weapons_of_faction: compared as set.
206
+ { name: "weapons_of_faction world-eaters", query: "weapons_of_faction", args: { factionId: "world-eaters" }, comparison: "set" },
207
+ ];
208
+ function genLinkedApi() {
209
+ const ds = Dataset.embedded();
210
+ const cases = LINKED_API_QUERIES.map((q) => {
211
+ const expected = runLinkedQuery(ds, q);
212
+ return { ...q, expected };
213
+ });
214
+ writeJson(join(CONFORMANCE, "linked-api", "cases.json"), cases);
215
+ console.log(`linked-api/cases.json: ${cases.length} cases`);
216
+ }
217
+ function runLinkedQuery(ds, q) {
218
+ switch (q.query) {
219
+ case "find_unit":
220
+ return ds.units.find(q.args.query)?.id ?? null;
221
+ case "find_weapon":
222
+ return ds.weapons.find(q.args.query)?.id ?? null;
223
+ case "find_faction":
224
+ return ds.factions.find(q.args.query)?.id ?? null;
225
+ case "find_ability":
226
+ return ds.abilities.find(q.args.query)?.id ?? null;
227
+ case "abilities_of": {
228
+ const u = ds.units.get(q.args.unitId);
229
+ if (!u)
230
+ throw new Error(`abilities_of: unknown unit ${q.args.unitId}`);
231
+ return u.abilities.map((a) => a.id);
232
+ }
233
+ case "weapons_of": {
234
+ const u = ds.units.get(q.args.unitId);
235
+ if (!u)
236
+ throw new Error(`weapons_of: unknown unit ${q.args.unitId}`);
237
+ return u.weapons.map((w) => w.id);
238
+ }
239
+ case "phases_of": {
240
+ const a = ds.abilities.get(q.args.abilityId);
241
+ if (!a)
242
+ throw new Error(`phases_of: unknown ability ${q.args.abilityId}`);
243
+ return [...a.phases].sort();
244
+ }
245
+ case "faction_of": {
246
+ const u = ds.units.get(q.args.unitId);
247
+ if (!u)
248
+ throw new Error(`faction_of: unknown unit ${q.args.unitId}`);
249
+ return u.faction?.id ?? null;
250
+ }
251
+ case "abilities_of_faction":
252
+ return ds.abilities.byFaction(q.args.factionId).map((a) => a.id).sort();
253
+ case "weapons_of_faction": {
254
+ // Mirrors Rust `weapons_of_faction`: aggregate weapons across the
255
+ // faction's units and dedupe by id. The collection-level
256
+ // `weapons.byFaction()` is a different operation (it looks up weapons
257
+ // whose own `faction_id` is set, which is empty for most factions).
258
+ const f = ds.factions.get(q.args.factionId);
259
+ if (!f)
260
+ throw new Error(`weapons_of_faction: unknown faction ${q.args.factionId}`);
261
+ return f.weapons.map((w) => w.id).sort();
262
+ }
263
+ }
264
+ }
265
+ /**
266
+ * Attribution corpus: reuses the existing cruncher inputs from the cases that
267
+ * carry at least one groupable buff (ability or manual). The expected shape
268
+ * is the AttributedStage array produced by attributeStages; both
269
+ * implementations of the leave-one-out decomposition must reproduce it
270
+ * within the per-stage float tolerance.
271
+ */
272
+ const ATTRIBUTION_CASE_FILES = [
273
+ "05-anti-infantry-vs-cultist.json",
274
+ "07-twin-linked-heavy-stationary-vs-knight.json",
275
+ ];
276
+ function loadAttributionInput(ds, filename) {
277
+ const path = join(CONFORMANCE, "cruncher", filename);
278
+ const c = JSON.parse(readFileSync(path, "utf8"));
279
+ const weapon = ds.weapons.get(c.attacker.weaponId);
280
+ const unit = ds.units.get(c.target.unitId);
281
+ if (!weapon)
282
+ throw new Error(`attribution: unknown weapon ${c.attacker.weaponId}`);
283
+ if (!unit)
284
+ throw new Error(`attribution: unknown unit ${c.target.unitId}`);
285
+ return {
286
+ name: c.name,
287
+ input: {
288
+ attacker: { weapon: weapon.raw, profileIndex: c.attacker.profileIndex },
289
+ target: {
290
+ unit: unit.raw,
291
+ profileIndex: c.target.profileIndex,
292
+ ...(c.target.modelCount !== undefined ? { modelCount: c.target.modelCount } : {}),
293
+ },
294
+ modelsFiring: c.modelsFiring,
295
+ buffs: c.buffs,
296
+ context: c.context,
297
+ },
298
+ };
299
+ }
300
+ function genAttribution() {
301
+ const ds = Dataset.embedded();
302
+ const cases = ATTRIBUTION_CASE_FILES.map((filename, idx) => {
303
+ const { name, input } = loadAttributionInput(ds, filename);
304
+ const stages = attributeStages(input, ds);
305
+ return {
306
+ // Persist the input by file reference so the corpus stays a single
307
+ // source of truth — the cruncher case file already pins the EngineInput.
308
+ name,
309
+ cruncher_case: filename,
310
+ expected: stages.map((s) => ({
311
+ name: s.name,
312
+ expected: s.expected,
313
+ baseline: s.baseline,
314
+ lifts: s.lifts.map((l) => ({ source: l.source, delta: l.delta })),
315
+ residual: s.residual,
316
+ intrinsics: s.intrinsics,
317
+ })),
318
+ // Stable ordering of cases in the corpus file.
319
+ _order: idx,
320
+ };
321
+ });
322
+ // Sort by _order and strip the helper before writing.
323
+ cases.sort((a, b) => a._order - b._order);
324
+ const serialised = cases.map(({ _order: _o, ...rest }) => rest);
325
+ writeJson(join(CONFORMANCE, "attribution", "cases.json"), serialised);
326
+ console.log(`attribution/cases.json: ${cases.length} cases`);
327
+ }
129
328
  genNormalize();
130
329
  genRosters();
330
+ genLinkedApi();
331
+ genAttribution();
131
332
  //# sourceMappingURL=gen-conformance.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"gen-conformance.js","sourceRoot":"","sources":["../src/gen-conformance.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Generate the cross-implementation conformance corpus under repo-root\n * `conformance/`. The TypeScript package is the reference implementation, so\n * the goldens it emits are what the Rust crate must reproduce byte-for-byte\n * (structurally). Run via `npm run gen:conformance`; CI regenerates and asserts\n * `git diff --exit-code conformance/` is clean.\n *\n * Outputs:\n * - `conformance/normalize.json` — `[{ input, expected }]` for normalizeName.\n * - `conformance/roster/<case>/expected.roster.json` — the resolved Roster.\n * - `conformance/roster/<case>/expected.<fmt>.{txt,json}` — every export\n * target's golden output. The TS exporter is the oracle; the Rust mirror\n * asserts byte-equal output for the same Roster.\n * - `conformance/roster/<case>/input.newrecruit-{wtc-compact,wtc-full,simple}.txt`\n * — text inputs derived from the seed by the exporter, so a re-import\n * regression in either implementation surfaces immediately.\n *\n * Seeding: each `<case>/` carries one canonical input — either the legacy\n * `input.json` (ListForge) or `input.newrecruit-json.json` (NewRecruit). Other\n * inputs are derived.\n */\nimport { readdirSync, readFileSync, writeFileSync, existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport { Dataset } from \"./data/dataset.js\";\nimport { normalizeName } from \"./data/normalize.js\";\nimport { exportRoster, type ExportFormat } from \"./export/index.js\";\nimport { importRoster } from \"./import/import-roster.js\";\nimport type { Roster } from \"./import/types.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst REPO_ROOT = join(__dirname, \"../..\");\nconst CONFORMANCE = join(REPO_ROOT, \"conformance\");\n\nconst NORMALIZE_INPUTS = [\n // NFD diacritic strip\n \"Khârn the Betrayer\",\n \"Brôkhyr\",\n \"Ûthar\",\n \"Magnús\",\n // apostrophe / quote variants\n \"T'au\",\n \"Be’lakor\",\n \"Kor’sarro Khan\",\n \"Aetaos'rau'keres\",\n \"‘quoted’\",\n // whitespace / hyphen collapse + trim\n \"Brôkhyr Iron-master\",\n \" the betrayer \",\n \"space--marines\",\n // casefold\n \"KHÂRN THE BETRAYER\",\n // already-normalized (idempotence)\n \"kharn the betrayer\",\n // distinctness anchors (must NOT collapse together)\n \"Khorne\",\n \"Khârn\",\n];\n\nfunction writeJson(path: string, value: unknown): void {\n writeFileSync(path, `${JSON.stringify(value, null, 2)}\\n`);\n}\n\nfunction writeText(path: string, value: string): void {\n writeFileSync(path, value);\n}\n\nfunction genNormalize(): void {\n const table = NORMALIZE_INPUTS.map((input) => ({ input, expected: normalizeName(input) }));\n writeJson(join(CONFORMANCE, \"normalize.json\"), table);\n console.log(`normalize.json: ${table.length} cases`);\n}\n\n/** Locate the canonical input for a fixture dir: prefer `input.json` (legacy\n * ListForge), fall back to `input.newrecruit-json.json` (NewRecruit). */\nfunction seedRoster(caseDir: string, ds: Dataset): Roster {\n const candidates = [\"input.json\", \"input.newrecruit-json.json\"];\n for (const name of candidates) {\n const path = join(caseDir, name);\n if (existsSync(path)) {\n const decoded = JSON.parse(readFileSync(path, \"utf8\"));\n return importRoster(decoded, { dataset: ds });\n }\n }\n throw new Error(`no canonical input found in ${caseDir}`);\n}\n\nconst TEXT_FORMATS: { format: ExportFormat; inputName: string; goldenName: string }[] = [\n {\n format: \"newrecruit-wtc-compact\",\n inputName: \"input.newrecruit-wtc-compact.txt\",\n goldenName: \"expected.newrecruit-wtc-compact.txt\",\n },\n {\n format: \"newrecruit-wtc-full\",\n inputName: \"input.newrecruit-wtc-full.txt\",\n goldenName: \"expected.newrecruit-wtc-full.txt\",\n },\n {\n format: \"newrecruit-simple\",\n inputName: \"input.newrecruit-simple.txt\",\n goldenName: \"expected.newrecruit-simple.txt\",\n },\n];\n\nfunction genRosters(): void {\n const ds = Dataset.embedded();\n const rosterDir = join(CONFORMANCE, \"roster\");\n for (const entry of readdirSync(rosterDir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const caseDir = join(rosterDir, entry.name);\n\n const seed = seedRoster(caseDir, ds);\n writeJson(join(caseDir, \"expected.roster.json\"), seed);\n\n // JSON export golden — NewRecruit-shaped skeleton.\n const jsonOut = exportRoster(seed, \"newrecruit-json\");\n writeJson(join(caseDir, \"expected.newrecruit-json.json\"), JSON.parse(jsonOut));\n\n // Canonical Roster JSON export — should equal the resolved roster.\n writeJson(join(caseDir, \"expected.roster-json.json\"), JSON.parse(exportRoster(seed, \"roster-json\")));\n\n // Text exports: always write the export golden so the cross-implementation\n // byte-equality check has something to compare against. Only write the\n // `input.*.txt` round-trip seed when the fixture was authored for the\n // NewRecruit pipeline — legacy ListForge fixtures carry decoration\n // (multi-force warnings, leader-attachment inference) that the simple/wtc\n // exporters can't fully preserve, so the round-trip would fail\n // structurally rather than uncover a parser bug.\n const isNewRecruitSeed = existsSync(join(caseDir, \"input.newrecruit-json.json\"));\n for (const { format, inputName, goldenName } of TEXT_FORMATS) {\n const out = exportRoster(seed, format);\n writeText(join(caseDir, goldenName), out);\n if (isNewRecruitSeed) {\n writeText(join(caseDir, inputName), out);\n }\n }\n\n console.log(\n `roster/${entry.name}: ${seed.units.length} units, ${seed.diagnostics.warnings.length} warnings`,\n );\n }\n}\n\ngenNormalize();\ngenRosters();\n"]}
1
+ {"version":3,"file":"gen-conformance.js","sourceRoot":"","sources":["../src/gen-conformance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAC/E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,YAAY,EAAqB,MAAM,mBAAmB,CAAC;AACpE,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAC9E,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAG5D,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAC1D,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAC3C,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,CAAC;AAEnD,MAAM,gBAAgB,GAAG;IACvB,sBAAsB;IACtB,oBAAoB;IACpB,SAAS;IACT,OAAO;IACP,QAAQ;IACR,8BAA8B;IAC9B,MAAM;IACN,UAAU;IACV,gBAAgB;IAChB,kBAAkB;IAClB,UAAU;IACV,sCAAsC;IACtC,qBAAqB;IACrB,oBAAoB;IACpB,gBAAgB;IAChB,WAAW;IACX,oBAAoB;IACpB,mCAAmC;IACnC,oBAAoB;IACpB,oDAAoD;IACpD,QAAQ;IACR,OAAO;IACP,2EAA2E;IAC3E,kEAAkE;IAClE,6DAA6D;IAC7D,aAAa;IACb,aAAa;IACb,4EAA4E;IAC5E,6EAA6E;IAC7E,yEAAyE;IACzE,+DAA+D;IAC/D,gBAAgB;IAChB,yEAAyE;IACzE,0EAA0E;IAC1E,sCAAsC;IACtC,aAAa;CACd,CAAC;AAEF,SAAS,SAAS,CAAC,IAAY,EAAE,KAAc;IAC7C,aAAa,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;AAC7D,CAAC;AAED,SAAS,SAAS,CAAC,IAAY,EAAE,KAAa;IAC5C,aAAa,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC7B,CAAC;AAED,SAAS,YAAY;IACnB,MAAM,KAAK,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3F,SAAS,CAAC,IAAI,CAAC,WAAW,EAAE,gBAAgB,CAAC,EAAE,KAAK,CAAC,CAAC;IACtD,OAAO,CAAC,GAAG,CAAC,mBAAmB,KAAK,CAAC,MAAM,QAAQ,CAAC,CAAC;AACvD,CAAC;AAED;;6EAE6E;AAC7E,SAAS,UAAU,CAAC,OAAe,EAAE,EAAW;IAC9C,MAAM,OAAO,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;IAC7C,OAAO,YAAY,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC;AAChD,CAAC;AAED;;kCAEkC;AAClC,SAAS,mBAAmB,CAAC,OAAe;IAC1C,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC;IAC7C,IAAI,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzB,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,EAAE,4BAA4B,CAAC,CAAC;IAC3D,IAAI,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;QACvB,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC;IAC7C,IAAI,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;QACvB,OAAO,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,+BAA+B,OAAO,EAAE,CAAC,CAAC;AAC5D,CAAC;AAED;;;kDAGkD;AAClD,SAAS,uBAAuB,CAAC,OAAe;IAC9C,MAAM,OAAO,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;IAC7C,MAAM,OAAO,GAAG,aAAa,CAAC,OAAO,EAAE,CAAC,GAAG,mBAAmB,CAAC,CAAC,CAAC;IACjE,OAAO,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;AAChC,CAAC;AAED,MAAM,YAAY,GAAsE;IACtF;QACE,MAAM,EAAE,wBAAwB;QAChC,SAAS,EAAE,kCAAkC;QAC7C,UAAU,EAAE,qCAAqC;KAClD;IACD;QACE,MAAM,EAAE,qBAAqB;QAC7B,SAAS,EAAE,+BAA+B;QAC1C,UAAU,EAAE,kCAAkC;KAC/C;IACD;QACE,MAAM,EAAE,mBAAmB;QAC3B,SAAS,EAAE,6BAA6B;QACxC,UAAU,EAAE,gCAAgC;KAC7C;CACF,CAAC;AAEF,SAAS,UAAU;IACjB,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAC9B,MAAM,SAAS,GAAG,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;IAC9C,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,SAAS,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QACpE,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE;YAAE,SAAS;QACnC,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QAE5C,MAAM,IAAI,GAAG,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACrC,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,sBAAsB,CAAC,EAAE,IAAI,CAAC,CAAC;QAEvD,sEAAsE;QACtE,yEAAyE;QACzE,qEAAqE;QACrE,6DAA6D;QAC7D,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,sBAAsB,CAAC,EAAE,uBAAuB,CAAC,OAAO,CAAC,CAAC,CAAC;QAEnF,mDAAmD;QACnD,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,EAAE,iBAAiB,CAAC,CAAC;QACtD,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,+BAA+B,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;QAE/E,mEAAmE;QACnE,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,2BAA2B,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC;QAErG,0EAA0E;QAC1E,+DAA+D;QAC/D,sEAAsE;QACtE,mEAAmE;QACnE,0EAA0E;QAC1E,+DAA+D;QAC/D,iDAAiD;QACjD,MAAM,gBAAgB,GAAG,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,4BAA4B,CAAC,CAAC,CAAC;QACjF,KAAK,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,IAAI,YAAY,EAAE,CAAC;YAC7D,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YACvC,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC,EAAE,GAAG,CAAC,CAAC;YAC1C,IAAI,gBAAgB,EAAE,CAAC;gBACrB,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,GAAG,CAAC,CAAC;YAC3C,CAAC;QACH,CAAC;QAED,uEAAuE;QACvE,uEAAuE;QACvE,uEAAuE;QACvE,uEAAuE;QACvE,oEAAoE;QACpE,oEAAoE;QACpE,yEAAyE;QACzE,MAAM,aAAa,GAAG,YAAY,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;QACvD,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,0BAA0B,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC;QAChF,IAAI,gBAAgB,EAAE,CAAC;YACrB,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,uBAAuB,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC;QAC/E,CAAC;QAED,OAAO,CAAC,GAAG,CACT,UAAU,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,KAAK,CAAC,MAAM,WAAW,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,MAAM,WAAW,CACjG,CAAC;IACJ,CAAC;AACH,CAAC;AA8BD,MAAM,kBAAkB,GAAqB;IAC3C,8DAA8D;IAC9D,EAAE,IAAI,EAAE,6BAA6B,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE;IAC3G,EAAE,IAAI,EAAE,6BAA6B,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,qBAAqB,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE;IACzH,yCAAyC;IACzC,EAAE,IAAI,EAAE,qBAAqB,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE;IAC1G,iDAAiD;IACjD,EAAE,IAAI,EAAE,8BAA8B,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE;IACtH,qCAAqC;IACrC,EAAE,IAAI,EAAE,sBAAsB,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE;IAClH,gEAAgE;IAChE,EAAE,IAAI,EAAE,gCAAgC,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,mBAAmB,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE;IAC/H,EAAE,IAAI,EAAE,iCAAiC,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,oBAAoB,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE;IACjI,6DAA6D;IAC7D,EAAE,IAAI,EAAE,8BAA8B,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,mBAAmB,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE;IAC3H,EAAE,IAAI,EAAE,+BAA+B,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,oBAAoB,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE;IAC7H,mFAAmF;IACnF,EAAE,IAAI,EAAE,4BAA4B,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE;IACtH,uCAAuC;IACvC,EAAE,IAAI,EAAE,8BAA8B,EAAE,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,mBAAmB,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE;IAC1H,gFAAgF;IAChF,EAAE,IAAI,EAAE,mCAAmC,EAAE,KAAK,EAAE,sBAAsB,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE;IACpI,uCAAuC;IACvC,EAAE,IAAI,EAAE,iCAAiC,EAAE,KAAK,EAAE,oBAAoB,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE;CACjI,CAAC;AAEF,SAAS,YAAY;IACnB,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAC9B,MAAM,KAAK,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACzC,MAAM,QAAQ,GAAG,cAAc,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;QACvC,OAAO,EAAE,GAAG,CAAC,EAAE,QAAQ,EAAE,CAAC;IAC5B,CAAC,CAAC,CAAC;IACH,SAAS,CAAC,IAAI,CAAC,WAAW,EAAE,YAAY,EAAE,YAAY,CAAC,EAAE,KAAK,CAAC,CAAC;IAChE,OAAO,CAAC,GAAG,CAAC,0BAA0B,KAAK,CAAC,MAAM,QAAQ,CAAC,CAAC;AAC9D,CAAC;AAED,SAAS,cAAc,CAAC,EAAW,EAAE,CAAiB;IACpD,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;QAChB,KAAK,WAAW;YACd,OAAO,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,IAAI,CAAC;QACjD,KAAK,aAAa;YAChB,OAAO,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,IAAI,CAAC;QACnD,KAAK,cAAc;YACjB,OAAO,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,IAAI,CAAC;QACpD,KAAK,cAAc;YACjB,OAAO,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,IAAI,CAAC;QACrD,KAAK,cAAc,CAAC,CAAC,CAAC;YACpB,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACtC,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;YACvE,OAAO,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACtC,CAAC;QACD,KAAK,YAAY,CAAC,CAAC,CAAC;YAClB,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACtC,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;YACrE,OAAO,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QACpC,CAAC;QACD,KAAK,WAAW,CAAC,CAAC,CAAC;YACjB,MAAM,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAC7C,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;YAC1E,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;QAC9B,CAAC;QACD,KAAK,YAAY,CAAC,CAAC,CAAC;YAClB,MAAM,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACtC,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;YACrE,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,IAAI,IAAI,CAAC;QAC/B,CAAC;QACD,KAAK,sBAAsB;YACzB,OAAO,EAAE,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC1E,KAAK,oBAAoB,CAAC,CAAC,CAAC;YAC1B,kEAAkE;YAClE,yDAAyD;YACzD,sEAAsE;YACtE,oEAAoE;YACpE,MAAM,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YAC5C,IAAI,CAAC,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,uCAAuC,CAAC,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;YACnF,OAAO,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC3C,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,sBAAsB,GAAG;IAC7B,kCAAkC;IAClC,gDAAgD;CACjD,CAAC;AAWF,SAAS,oBAAoB,CAAC,EAAW,EAAE,QAAgB;IAIzD,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,UAAU,EAAE,QAAQ,CAAC,CAAC;IACrD,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAsB,CAAC;IACtE,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACnD,MAAM,IAAI,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC3C,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,+BAA+B,CAAC,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC;IACnF,IAAI,CAAC,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IAC3E,OAAO;QACL,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,KAAK,EAAE;YACL,QAAQ,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,GAAG,EAAE,YAAY,EAAE,CAAC,CAAC,QAAQ,CAAC,YAAY,EAAE;YACvE,MAAM,EAAE;gBACN,IAAI,EAAE,IAAI,CAAC,GAAG;gBACd,YAAY,EAAE,CAAC,CAAC,MAAM,CAAC,YAAY;gBACnC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAClF;YACD,YAAY,EAAE,CAAC,CAAC,YAAY;YAC5B,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,OAAO,EAAE,CAAC,CAAC,OAAO;SACnB;KACF,CAAC;AACJ,CAAC;AAED,SAAS,cAAc;IACrB,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAC9B,MAAM,KAAK,GAAG,sBAAsB,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,GAAG,EAAE,EAAE;QACzD,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,oBAAoB,CAAC,EAAE,EAAE,QAAQ,CAAC,CAAC;QAC3D,MAAM,MAAM,GAAG,eAAe,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QAC1C,OAAO;YACL,mEAAmE;YACnE,yEAAyE;YACzE,IAAI;YACJ,aAAa,EAAE,QAAQ;YACvB,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;gBAC3B,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,QAAQ,EAAE,CAAC,CAAC,QAAQ;gBACpB,QAAQ,EAAE,CAAC,CAAC,QAAQ;gBACpB,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;gBACjE,QAAQ,EAAE,CAAC,CAAC,QAAQ;gBACpB,UAAU,EAAE,CAAC,CAAC,UAAU;aACzB,CAAC,CAAC;YACH,+CAA+C;YAC/C,MAAM,EAAE,GAAG;SACZ,CAAC;IACJ,CAAC,CAAC,CAAC;IACH,sDAAsD;IACtD,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC;IAC1C,MAAM,UAAU,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;IAChE,SAAS,CAAC,IAAI,CAAC,WAAW,EAAE,aAAa,EAAE,YAAY,CAAC,EAAE,UAAU,CAAC,CAAC;IACtE,OAAO,CAAC,GAAG,CAAC,2BAA2B,KAAK,CAAC,MAAM,QAAQ,CAAC,CAAC;AAC/D,CAAC;AAED,YAAY,EAAE,CAAC;AACf,UAAU,EAAE,CAAC;AACb,YAAY,EAAE,CAAC;AACf,cAAc,EAAE,CAAC","sourcesContent":["/**\n * Generate the cross-implementation conformance corpus under repo-root\n * `conformance/`. The TypeScript package is the reference implementation, so\n * the goldens it emits are what the Rust crate must reproduce byte-for-byte\n * (structurally). Run via `npm run gen:conformance`; CI regenerates and asserts\n * `git diff --exit-code conformance/` is clean.\n *\n * Outputs:\n * - `conformance/normalize.json` — `[{ input, expected }]` for normalizeName.\n * - `conformance/roster/<case>/expected.roster.json` — the resolved Roster.\n * - `conformance/roster/<case>/expected.<fmt>.{txt,json}` — every export\n * target's golden output. The TS exporter is the oracle; the Rust mirror\n * asserts byte-equal output for the same Roster.\n * - `conformance/roster/<case>/input.newrecruit-{wtc-compact,wtc-full,simple}.txt`\n * — text inputs derived from the seed by the exporter, so a re-import\n * regression in either implementation surfaces immediately.\n *\n * Seeding: each `<case>/` carries one canonical input — either the legacy\n * `input.json` (ListForge) or `input.newrecruit-json.json` (NewRecruit). Other\n * inputs are derived.\n */\nimport { readdirSync, readFileSync, writeFileSync, existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport { Dataset } from \"./data/dataset.js\";\nimport { normalizeName } from \"./data/normalize.js\";\nimport { exportRoster, type ExportFormat } from \"./export/index.js\";\nimport { importRoster, REGISTERED_ADAPTERS } from \"./import/import-roster.js\";\nimport { selectAdapter } from \"./import/adapter.js\";\nimport type { ParsedRoster, Roster } from \"./import/types.js\";\nimport { attributeStages } from \"./cruncher/attribution.js\";\nimport type { EngineInput } from \"./cruncher/index.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst REPO_ROOT = join(__dirname, \"../..\");\nconst CONFORMANCE = join(REPO_ROOT, \"conformance\");\n\nconst NORMALIZE_INPUTS = [\n // NFD diacritic strip\n \"Khârn the Betrayer\",\n \"Brôkhyr\",\n \"Ûthar\",\n \"Magnús\",\n // apostrophe / quote variants\n \"T'au\",\n \"Be’lakor\",\n \"Kor’sarro Khan\",\n \"Aetaos'rau'keres\",\n \"‘quoted’\",\n // whitespace / hyphen collapse + trim\n \"Brôkhyr Iron-master\",\n \" the betrayer \",\n \"space--marines\",\n // casefold\n \"KHÂRN THE BETRAYER\",\n // already-normalized (idempotence)\n \"kharn the betrayer\",\n // distinctness anchors (must NOT collapse together)\n \"Khorne\",\n \"Khârn\",\n // Unicode whitespace beyond ASCII — every Unicode whitespace must collapse\n // identically across implementations or `find(\"Khorne Lord\")` and\n // `find(\"Khorne Lord\")` will silently disagree across ports.\n \"Khorne Lord\",\n \"Khorne Lord\",\n // Turkish dotted-I: NFD decomposes to `I` + combining dot above; the dot is\n // stripped, then locale-independent lowercase yields `i`. The case pins that\n // no implementation introduces locale-aware casefolding (which would map\n // `I` → `ı` under Turkish locale and break ASCII-text search).\n \"İmperial Fists\",\n // Zero-width joiner: passes through every step today. Pinned so behavior\n // does not silently change — if a future commit strips Cf-category chars,\n // this golden updates in the same PR.\n \"Khorne‍Lord\",\n];\n\nfunction writeJson(path: string, value: unknown): void {\n writeFileSync(path, `${JSON.stringify(value, null, 2)}\\n`);\n}\n\nfunction writeText(path: string, value: string): void {\n writeFileSync(path, value);\n}\n\nfunction genNormalize(): void {\n const table = NORMALIZE_INPUTS.map((input) => ({ input, expected: normalizeName(input) }));\n writeJson(join(CONFORMANCE, \"normalize.json\"), table);\n console.log(`normalize.json: ${table.length} cases`);\n}\n\n/** Locate the canonical input for a fixture dir: prefer `input.json` (legacy\n * ListForge), then `input.newrecruit-json.json` (NewRecruit), then the\n * text-only `input.gw.txt` (GW app export — import-only, like ListForge). */\nfunction seedRoster(caseDir: string, ds: Dataset): Roster {\n const decoded = decodeCanonicalSeed(caseDir);\n return importRoster(decoded, { dataset: ds });\n}\n\n/** Return the decoded payload for the canonical seed — the same value the\n * import pipeline would dispatch on. JSON seeds come back parsed; text seeds\n * come back as the raw string. */\nfunction decodeCanonicalSeed(caseDir: string): unknown {\n const jsonSeed = join(caseDir, \"input.json\");\n if (existsSync(jsonSeed)) {\n return JSON.parse(readFileSync(jsonSeed, \"utf8\"));\n }\n const nrSeed = join(caseDir, \"input.newrecruit-json.json\");\n if (existsSync(nrSeed)) {\n return JSON.parse(readFileSync(nrSeed, \"utf8\"));\n }\n const gwSeed = join(caseDir, \"input.gw.txt\");\n if (existsSync(gwSeed)) {\n return readFileSync(gwSeed, \"utf8\");\n }\n throw new Error(`no canonical input found in ${caseDir}`);\n}\n\n/** Run a decoded payload through the adapter pipeline up to (but not past)\n * resolution. The result is the format-agnostic ParsedRoster — the same\n * intermediate the resolver consumes. Pinning this layer surfaces parser\n * regressions even when resolution masks them. */\nfunction parsedFromCanonicalSeed(caseDir: string): ParsedRoster {\n const decoded = decodeCanonicalSeed(caseDir);\n const adapter = selectAdapter(decoded, [...REGISTERED_ADAPTERS]);\n return adapter.parse(decoded);\n}\n\nconst TEXT_FORMATS: { format: ExportFormat; inputName: string; goldenName: string }[] = [\n {\n format: \"newrecruit-wtc-compact\",\n inputName: \"input.newrecruit-wtc-compact.txt\",\n goldenName: \"expected.newrecruit-wtc-compact.txt\",\n },\n {\n format: \"newrecruit-wtc-full\",\n inputName: \"input.newrecruit-wtc-full.txt\",\n goldenName: \"expected.newrecruit-wtc-full.txt\",\n },\n {\n format: \"newrecruit-simple\",\n inputName: \"input.newrecruit-simple.txt\",\n goldenName: \"expected.newrecruit-simple.txt\",\n },\n];\n\nfunction genRosters(): void {\n const ds = Dataset.embedded();\n const rosterDir = join(CONFORMANCE, \"roster\");\n for (const entry of readdirSync(rosterDir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const caseDir = join(rosterDir, entry.name);\n\n const seed = seedRoster(caseDir, ds);\n writeJson(join(caseDir, \"expected.roster.json\"), seed);\n\n // Parsed-stage golden — the intermediate ParsedRoster produced by the\n // adapter for the canonical seed, before resolution. Catches parser bugs\n // that resolution would otherwise mask (e.g. wrong unit count from a\n // duplicate cost line that resolves to the same unit twice).\n writeJson(join(caseDir, \"expected.parsed.json\"), parsedFromCanonicalSeed(caseDir));\n\n // JSON export golden — NewRecruit-shaped skeleton.\n const jsonOut = exportRoster(seed, \"newrecruit-json\");\n writeJson(join(caseDir, \"expected.newrecruit-json.json\"), JSON.parse(jsonOut));\n\n // Canonical Roster JSON export — should equal the resolved roster.\n writeJson(join(caseDir, \"expected.roster-json.json\"), JSON.parse(exportRoster(seed, \"roster-json\")));\n\n // Text exports: always write the export golden so every fixture exercises\n // the cross-implementation byte-equality check. Only write the\n // `input.*.txt` round-trip seed when the fixture was authored for the\n // NewRecruit pipeline — legacy ListForge fixtures carry decoration\n // (multi-force warnings, leader-attachment inference) that the simple/wtc\n // exporters can't fully preserve, so the round-trip would fail\n // structurally rather than uncover a parser bug.\n const isNewRecruitSeed = existsSync(join(caseDir, \"input.newrecruit-json.json\"));\n for (const { format, inputName, goldenName } of TEXT_FORMATS) {\n const out = exportRoster(seed, format);\n writeText(join(caseDir, goldenName), out);\n if (isNewRecruitSeed) {\n writeText(join(caseDir, inputName), out);\n }\n }\n\n // Rosterizer JSON export + a derived round-trip input. The exporter is\n // deterministic and round-trips through the adapter, so emitting it as\n // both `expected.rosterizer.json` and `input.rosterizer.json` pins the\n // cross-implementation goldens and the importer regression at the same\n // time. Same NewRecruit-seed gate as the text formats — multi-force\n // ListForge fixtures lose their provisional leader-attachment under\n // round-trip, so they only get the export golden, not the derived input.\n const rosterizerOut = exportRoster(seed, \"rosterizer\");\n writeJson(join(caseDir, \"expected.rosterizer.json\"), JSON.parse(rosterizerOut));\n if (isNewRecruitSeed) {\n writeJson(join(caseDir, \"input.rosterizer.json\"), JSON.parse(rosterizerOut));\n }\n\n console.log(\n `roster/${entry.name}: ${seed.units.length} units, ${seed.diagnostics.warnings.length} warnings`,\n );\n }\n}\n\n/**\n * Linked-API query cases. Each descriptor names a query method on Dataset, the\n * args to call it with, and how the result should be compared.\n *\n * `comparison: \"ordered\"` pins the result order — used for queries that iterate\n * a data-driven array (`unit.ability_ids`, `unit.weapon_ids`) where order is\n * encoded in the data and both implementations iterate it the same way.\n *\n * `comparison: \"set\"` pins only the set of ids — used for queries that walk an\n * index (faction → abilities, ability → phases) where iteration order depends\n * on dataset bundler internals and is incidental. Ids are sorted before\n * comparison.\n *\n * `comparison: \"scalar\"` pins a single id-or-null result (find_* and\n * faction_of(unit)).\n */\ntype LinkedApiQuery =\n | { name: string; query: \"find_unit\"; args: { query: string }; comparison: \"scalar\" }\n | { name: string; query: \"find_weapon\"; args: { query: string }; comparison: \"scalar\" }\n | { name: string; query: \"find_faction\"; args: { query: string }; comparison: \"scalar\" }\n | { name: string; query: \"find_ability\"; args: { query: string }; comparison: \"scalar\" }\n | { name: string; query: \"abilities_of\"; args: { unitId: string }; comparison: \"ordered\" }\n | { name: string; query: \"weapons_of\"; args: { unitId: string }; comparison: \"ordered\" }\n | { name: string; query: \"phases_of\"; args: { abilityId: string }; comparison: \"set\" }\n | { name: string; query: \"faction_of\"; args: { unitId: string }; comparison: \"scalar\" }\n | { name: string; query: \"abilities_of_faction\"; args: { factionId: string }; comparison: \"set\" }\n | { name: string; query: \"weapons_of_faction\"; args: { factionId: string }; comparison: \"set\" };\n\nconst LINKED_API_QUERIES: LinkedApiQuery[] = [\n // find_unit: diacritic-insensitive lookup, miss returns null.\n { name: \"find_unit by diacritic name\", query: \"find_unit\", args: { query: \"Kharn\" }, comparison: \"scalar\" },\n { name: \"find_unit miss returns null\", query: \"find_unit\", args: { query: \"not-a-real-unit-xyz\" }, comparison: \"scalar\" },\n // find_weapon: hyphen + space tolerance.\n { name: \"find_weapon by name\", query: \"find_weapon\", args: { query: \"bolt rifle\" }, comparison: \"scalar\" },\n // find_faction: punctuation/diacritic tolerance.\n { name: \"find_faction by display name\", query: \"find_faction\", args: { query: \"World Eaters\" }, comparison: \"scalar\" },\n // find_ability: ability name lookup.\n { name: \"find_ability by name\", query: \"find_ability\", args: { query: \"Berzerker Frenzy\" }, comparison: \"scalar\" },\n // abilities_of(unit): ordered, iterates unit.ability_ids array.\n { name: \"abilities_of intercessor-squad\", query: \"abilities_of\", args: { unitId: \"intercessor-squad\" }, comparison: \"ordered\" },\n { name: \"abilities_of kharn-the-betrayer\", query: \"abilities_of\", args: { unitId: \"kharn-the-betrayer\" }, comparison: \"ordered\" },\n // weapons_of(unit): ordered, iterates unit.weapon_ids array.\n { name: \"weapons_of intercessor-squad\", query: \"weapons_of\", args: { unitId: \"intercessor-squad\" }, comparison: \"ordered\" },\n { name: \"weapons_of kharn-the-betrayer\", query: \"weapons_of\", args: { unitId: \"kharn-the-betrayer\" }, comparison: \"ordered\" },\n // phases_of(ability): compared as set (phase index iteration order is incidental).\n { name: \"phases_of berzerker-frenzy\", query: \"phases_of\", args: { abilityId: \"berzerker-frenzy\" }, comparison: \"set\" },\n // faction_of(unit): scalar id or null.\n { name: \"faction_of intercessor-squad\", query: \"faction_of\", args: { unitId: \"intercessor-squad\" }, comparison: \"scalar\" },\n // abilities_of_faction: compared as set (collection-index order is incidental).\n { name: \"abilities_of_faction world-eaters\", query: \"abilities_of_faction\", args: { factionId: \"world-eaters\" }, comparison: \"set\" },\n // weapons_of_faction: compared as set.\n { name: \"weapons_of_faction world-eaters\", query: \"weapons_of_faction\", args: { factionId: \"world-eaters\" }, comparison: \"set\" },\n];\n\nfunction genLinkedApi(): void {\n const ds = Dataset.embedded();\n const cases = LINKED_API_QUERIES.map((q) => {\n const expected = runLinkedQuery(ds, q);\n return { ...q, expected };\n });\n writeJson(join(CONFORMANCE, \"linked-api\", \"cases.json\"), cases);\n console.log(`linked-api/cases.json: ${cases.length} cases`);\n}\n\nfunction runLinkedQuery(ds: Dataset, q: LinkedApiQuery): string | null | string[] {\n switch (q.query) {\n case \"find_unit\":\n return ds.units.find(q.args.query)?.id ?? null;\n case \"find_weapon\":\n return ds.weapons.find(q.args.query)?.id ?? null;\n case \"find_faction\":\n return ds.factions.find(q.args.query)?.id ?? null;\n case \"find_ability\":\n return ds.abilities.find(q.args.query)?.id ?? null;\n case \"abilities_of\": {\n const u = ds.units.get(q.args.unitId);\n if (!u) throw new Error(`abilities_of: unknown unit ${q.args.unitId}`);\n return u.abilities.map((a) => a.id);\n }\n case \"weapons_of\": {\n const u = ds.units.get(q.args.unitId);\n if (!u) throw new Error(`weapons_of: unknown unit ${q.args.unitId}`);\n return u.weapons.map((w) => w.id);\n }\n case \"phases_of\": {\n const a = ds.abilities.get(q.args.abilityId);\n if (!a) throw new Error(`phases_of: unknown ability ${q.args.abilityId}`);\n return [...a.phases].sort();\n }\n case \"faction_of\": {\n const u = ds.units.get(q.args.unitId);\n if (!u) throw new Error(`faction_of: unknown unit ${q.args.unitId}`);\n return u.faction?.id ?? null;\n }\n case \"abilities_of_faction\":\n return ds.abilities.byFaction(q.args.factionId).map((a) => a.id).sort();\n case \"weapons_of_faction\": {\n // Mirrors Rust `weapons_of_faction`: aggregate weapons across the\n // faction's units and dedupe by id. The collection-level\n // `weapons.byFaction()` is a different operation (it looks up weapons\n // whose own `faction_id` is set, which is empty for most factions).\n const f = ds.factions.get(q.args.factionId);\n if (!f) throw new Error(`weapons_of_faction: unknown faction ${q.args.factionId}`);\n return f.weapons.map((w) => w.id).sort();\n }\n }\n}\n\n/**\n * Attribution corpus: reuses the existing cruncher inputs from the cases that\n * carry at least one groupable buff (ability or manual). The expected shape\n * is the AttributedStage array produced by attributeStages; both\n * implementations of the leave-one-out decomposition must reproduce it\n * within the per-stage float tolerance.\n */\nconst ATTRIBUTION_CASE_FILES = [\n \"05-anti-infantry-vs-cultist.json\",\n \"07-twin-linked-heavy-stationary-vs-knight.json\",\n];\n\ninterface CruncherCaseInput {\n name: string;\n attacker: { weaponId: string; profileIndex: number };\n modelsFiring: number;\n target: { unitId: string; profileIndex: number; modelCount?: number };\n context: EngineInput[\"context\"];\n buffs: EngineInput[\"buffs\"];\n}\n\nfunction loadAttributionInput(ds: Dataset, filename: string): {\n name: string;\n input: EngineInput;\n} {\n const path = join(CONFORMANCE, \"cruncher\", filename);\n const c = JSON.parse(readFileSync(path, \"utf8\")) as CruncherCaseInput;\n const weapon = ds.weapons.get(c.attacker.weaponId);\n const unit = ds.units.get(c.target.unitId);\n if (!weapon) throw new Error(`attribution: unknown weapon ${c.attacker.weaponId}`);\n if (!unit) throw new Error(`attribution: unknown unit ${c.target.unitId}`);\n return {\n name: c.name,\n input: {\n attacker: { weapon: weapon.raw, profileIndex: c.attacker.profileIndex },\n target: {\n unit: unit.raw,\n profileIndex: c.target.profileIndex,\n ...(c.target.modelCount !== undefined ? { modelCount: c.target.modelCount } : {}),\n },\n modelsFiring: c.modelsFiring,\n buffs: c.buffs,\n context: c.context,\n },\n };\n}\n\nfunction genAttribution(): void {\n const ds = Dataset.embedded();\n const cases = ATTRIBUTION_CASE_FILES.map((filename, idx) => {\n const { name, input } = loadAttributionInput(ds, filename);\n const stages = attributeStages(input, ds);\n return {\n // Persist the input by file reference so the corpus stays a single\n // source of truth — the cruncher case file already pins the EngineInput.\n name,\n cruncher_case: filename,\n expected: stages.map((s) => ({\n name: s.name,\n expected: s.expected,\n baseline: s.baseline,\n lifts: s.lifts.map((l) => ({ source: l.source, delta: l.delta })),\n residual: s.residual,\n intrinsics: s.intrinsics,\n })),\n // Stable ordering of cases in the corpus file.\n _order: idx,\n };\n });\n // Sort by _order and strip the helper before writing.\n cases.sort((a, b) => a._order - b._order);\n const serialised = cases.map(({ _order: _o, ...rest }) => rest);\n writeJson(join(CONFORMANCE, \"attribution\", \"cases.json\"), serialised);\n console.log(`attribution/cases.json: ${cases.length} cases`);\n}\n\ngenNormalize();\ngenRosters();\ngenLinkedApi();\ngenAttribution();\n"]}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * GW adapter: lower the Games Workshop 40K app's plain-text army-list export to
3
+ * a {@link ParsedRoster}.
4
+ *
5
+ * The format opens with the same `++++…++++` summary fence as the NewRecruit WTC
6
+ * formats (FACTION KEYWORD / DETACHMENT / TOTAL ARMY POINTS / WARLORD /
7
+ * ENHANCEMENT / NUMBER OF UNITS / SECONDARY), then lists units grouped under
8
+ * ALL-CAPS battlefield-role sections (`BATTLELINE`, `CHARACTERS`,
9
+ * `ALLIED UNITS`, …). Each unit is a header line `Name (N pts)` followed by
10
+ * `•`-bulleted entries:
11
+ *
12
+ * ```
13
+ * War Dog Executioner (130 pts)
14
+ * • 1x Armoured feet
15
+ * • 2x War Dog autocannon
16
+ * • Houndpack Lance Character, Warlord
17
+ *
18
+ * Nurglings (40 pts)
19
+ * • 3x Nurgling Swarm
20
+ * • 3x Diseased claws and teeth
21
+ * ```
22
+ *
23
+ * Bullet classification (the parsing crux):
24
+ * - A top-level `• Nx Thing` *with* further-indented child bullets is a **model
25
+ * group** — `N` adds to the model count and the children are that group's
26
+ * wargear (Nurglings, Beasts of Nurgle).
27
+ * - A top-level `• Nx Thing` *without* children is plain **wargear**.
28
+ * - A bullet *without* an `Nx` count is an **annotation**: `… Character` flags a
29
+ * character, `Warlord` flags the warlord, `Name (+N pts)` is the enhancement.
30
+ *
31
+ * **Disjointness from the WTC matchers**: the GW format always carries `•`
32
+ * bullets and never the WTC `N with` lines. wtc-full always has `N with` (so it
33
+ * never collides), and wtc-compact never has bullets (its matcher now excludes
34
+ * them). This adapter therefore matches on *bullets present* + *no `N with`*.
35
+ *
36
+ * The GW export carries no separate POINTS LIMIT line, so `declared_limit`
37
+ * falls back to TOTAL ARMY POINTS (the round-trippable battle-size signal).
38
+ *
39
+ * @packageDocumentation
40
+ */
41
+ import type { FormatAdapter } from "./adapter.js";
42
+ import type { ParsedUnit } from "./types.js";
43
+ /** Accept the input only when it carries the FACTION KEYWORD summary header,
44
+ * has `•` bullets, and lacks the WTC `N with` body lines. */
45
+ declare function isGwText(decoded: unknown): string | null;
46
+ interface GwHeader {
47
+ name: string;
48
+ faction_raw_name: string | null;
49
+ detachment_raw_name: string | null;
50
+ total_reported: number | null;
51
+ declared_limit: number | null;
52
+ battle_size_raw: string | null;
53
+ }
54
+ declare function parseHeader(lines: string[]): {
55
+ header: GwHeader;
56
+ bodyStart: number;
57
+ } | null;
58
+ declare function parseBody(lines: string[], bodyStart: number): {
59
+ units: ParsedUnit[];
60
+ multi_force: boolean;
61
+ };
62
+ export declare const gwAdapter: FormatAdapter;
63
+ export declare const _internals: {
64
+ isGwText: typeof isGwText;
65
+ parseHeader: typeof parseHeader;
66
+ parseBody: typeof parseBody;
67
+ };
68
+ export {};
69
+ //# sourceMappingURL=gw.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gw.d.ts","sourceRoot":"","sources":["../../src/import/gw.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAgB,UAAU,EAAiB,MAAM,YAAY,CAAC;AA8B1E;6DAC6D;AAC7D,iBAAS,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAMjD;AAED,UAAU,QAAQ;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,iBAAS,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA+CpF;AA8FD,iBAAS,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG;IACtD,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,WAAW,EAAE,OAAO,CAAC;CACtB,CAsDA;AAED,eAAO,MAAM,SAAS,EAAE,aAqCvB,CAAC;AAGF,eAAO,MAAM,UAAU;;;;CAItB,CAAC"}