@alpaca-software/40kdc-data 0.5.5 → 0.5.12

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 (126) hide show
  1. package/README.md +10 -0
  2. package/dist/codegen-data.js +2 -0
  3. package/dist/codegen-data.js.map +1 -1
  4. package/dist/commands/import.d.ts.map +1 -1
  5. package/dist/commands/import.js +3 -1
  6. package/dist/commands/import.js.map +1 -1
  7. package/dist/compare.d.ts +167 -0
  8. package/dist/compare.d.ts.map +1 -0
  9. package/dist/compare.js +302 -0
  10. package/dist/compare.js.map +1 -0
  11. package/dist/convert-faction.d.ts.map +1 -1
  12. package/dist/convert-faction.js +22 -2
  13. package/dist/convert-faction.js.map +1 -1
  14. package/dist/cruncher/buffs.d.ts +13 -0
  15. package/dist/cruncher/buffs.d.ts.map +1 -1
  16. package/dist/cruncher/buffs.js +7 -0
  17. package/dist/cruncher/buffs.js.map +1 -1
  18. package/dist/cruncher/engine.d.ts.map +1 -1
  19. package/dist/cruncher/engine.js +14 -10
  20. package/dist/cruncher/engine.js.map +1 -1
  21. package/dist/data/bundle.generated.js +1 -1
  22. package/dist/data/bundle.generated.js.map +1 -1
  23. package/dist/data/dataset.d.ts +3 -1
  24. package/dist/data/dataset.d.ts.map +1 -1
  25. package/dist/data/dataset.js +4 -0
  26. package/dist/data/dataset.js.map +1 -1
  27. package/dist/data/entities.d.ts +22 -0
  28. package/dist/data/entities.d.ts.map +1 -1
  29. package/dist/data/entities.js +45 -1
  30. package/dist/data/entities.js.map +1 -1
  31. package/dist/data/index.d.ts +4 -0
  32. package/dist/data/index.d.ts.map +1 -1
  33. package/dist/data/index.js +4 -0
  34. package/dist/data/index.js.map +1 -1
  35. package/dist/data/types.d.ts +5 -1
  36. package/dist/data/types.d.ts.map +1 -1
  37. package/dist/data/types.js +2 -0
  38. package/dist/data/types.js.map +1 -1
  39. package/dist/export/newrecruit-json.d.ts.map +1 -1
  40. package/dist/export/newrecruit-json.js +4 -3
  41. package/dist/export/newrecruit-json.js.map +1 -1
  42. package/dist/export/newrecruit-simple.js +2 -2
  43. package/dist/export/newrecruit-simple.js.map +1 -1
  44. package/dist/export/newrecruit-wtc.d.ts.map +1 -1
  45. package/dist/export/newrecruit-wtc.js +3 -1
  46. package/dist/export/newrecruit-wtc.js.map +1 -1
  47. package/dist/export/rosterizer.d.ts.map +1 -1
  48. package/dist/export/rosterizer.js +6 -7
  49. package/dist/export/rosterizer.js.map +1 -1
  50. package/dist/extract-faction-pack.d.ts +56 -0
  51. package/dist/extract-faction-pack.d.ts.map +1 -0
  52. package/dist/extract-faction-pack.js +374 -0
  53. package/dist/extract-faction-pack.js.map +1 -0
  54. package/dist/gen-conformance.js +37 -4
  55. package/dist/gen-conformance.js.map +1 -1
  56. package/dist/generated.d.ts +66 -2
  57. package/dist/generated.d.ts.map +1 -1
  58. package/dist/generated.js.map +1 -1
  59. package/dist/import/gw.js +1 -1
  60. package/dist/import/gw.js.map +1 -1
  61. package/dist/import/import-roster.d.ts.map +1 -1
  62. package/dist/import/import-roster.js +5 -0
  63. package/dist/import/import-roster.js.map +1 -1
  64. package/dist/import/index.d.ts +1 -0
  65. package/dist/import/index.d.ts.map +1 -1
  66. package/dist/import/index.js +1 -0
  67. package/dist/import/index.js.map +1 -1
  68. package/dist/import/listforge-text.js +1 -1
  69. package/dist/import/listforge-text.js.map +1 -1
  70. package/dist/import/listforge.d.ts.map +1 -1
  71. package/dist/import/listforge.js +18 -3
  72. package/dist/import/listforge.js.map +1 -1
  73. package/dist/import/newrecruit-json.d.ts.map +1 -1
  74. package/dist/import/newrecruit-json.js +21 -3
  75. package/dist/import/newrecruit-json.js.map +1 -1
  76. package/dist/import/newrecruit-simple.js +1 -1
  77. package/dist/import/newrecruit-simple.js.map +1 -1
  78. package/dist/import/newrecruit-wtc.js +1 -1
  79. package/dist/import/newrecruit-wtc.js.map +1 -1
  80. package/dist/import/resolve.d.ts.map +1 -1
  81. package/dist/import/resolve.js +46 -18
  82. package/dist/import/resolve.js.map +1 -1
  83. package/dist/import/roster-json.d.ts +32 -0
  84. package/dist/import/roster-json.d.ts.map +1 -0
  85. package/dist/import/roster-json.js +92 -0
  86. package/dist/import/roster-json.js.map +1 -0
  87. package/dist/import/rosterizer.js +3 -3
  88. package/dist/import/rosterizer.js.map +1 -1
  89. package/dist/import/types.d.ts +16 -5
  90. package/dist/import/types.d.ts.map +1 -1
  91. package/dist/import/types.js.map +1 -1
  92. package/dist/index.d.ts +2 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +8 -0
  95. package/dist/index.js.map +1 -1
  96. package/dist/migrate-keystone-twins.d.ts +23 -0
  97. package/dist/migrate-keystone-twins.d.ts.map +1 -0
  98. package/dist/migrate-keystone-twins.js +184 -0
  99. package/dist/migrate-keystone-twins.js.map +1 -0
  100. package/dist/rube-goldberg.js +1 -1
  101. package/dist/rube-goldberg.js.map +1 -1
  102. package/dist/runner.d.ts.map +1 -1
  103. package/dist/runner.js +94 -1
  104. package/dist/runner.js.map +1 -1
  105. package/dist/scope.d.ts +31 -0
  106. package/dist/scope.d.ts.map +1 -0
  107. package/dist/scope.js +34 -0
  108. package/dist/scope.js.map +1 -0
  109. package/dist/translate/effect.d.ts +17 -3
  110. package/dist/translate/effect.d.ts.map +1 -1
  111. package/dist/translate/effect.js +23 -4
  112. package/dist/translate/effect.js.map +1 -1
  113. package/dist/translate/index.d.ts +1 -1
  114. package/dist/translate/index.d.ts.map +1 -1
  115. package/dist/translate/index.js +1 -1
  116. package/dist/translate/index.js.map +1 -1
  117. package/dist/validate.d.ts.map +1 -1
  118. package/dist/validate.js +2 -0
  119. package/dist/validate.js.map +1 -1
  120. package/package.json +2 -1
  121. package/schemas/core/hull-shape.schema.json +30 -0
  122. package/schemas/core/roster.schema.json +26 -9
  123. package/schemas/core/stratagem.schema.json +2 -2
  124. package/schemas/core/target-profile.schema.json +30 -0
  125. package/schemas/core/unit-composition.schema.json +7 -0
  126. package/schemas/enrichment/ability-dsl/ability.schema.json +14 -0
package/README.md CHANGED
@@ -61,6 +61,16 @@ as `normalizeName` so you can reproduce it in your own search UI.
61
61
 
62
62
  Auto-generated from the source: [`docs/api/`](docs/api/README.md).
63
63
 
64
+ ## See it in action
65
+
66
+ Open-source example apps built on this package:
67
+
68
+ - [List Builder](https://list-builder.alpacasoft.dev) — build an army list and export to ListForge / NewRecruit.
69
+ - [Salvo](https://salvo.alpacasoft.dev) — damage calculator over the cruncher and abilities-resolver.
70
+ - [Mission Matrix](https://mission-matrix.alpacasoft.dev) — the 11th-edition Force Disposition matchup grid.
71
+ - [Layout Editor](https://layout-editor.alpacasoft.dev) — author terrain layouts on a board; exports terrain-layout JSON.
72
+ - [Hull Tracer](https://hull-tracer.alpacasoft.dev) — trace a model's collision hull from a top-down photo; exports geometry-only hull-shape JSON.
73
+
64
74
  ## Also: schema validation
65
75
 
66
76
  This package also ships the canonical JSON Schemas and an AJV-based validator
@@ -24,6 +24,7 @@ const EXCLUDED_DIRS = new Set(["_example", "_port-audit"]);
24
24
  /** Map a data file's base name (sans `.json`) to its `RawData` collection key. */
25
25
  const FILE_TO_COLLECTION = {
26
26
  units: "units",
27
+ "target-profiles": "targetProfiles",
27
28
  weapons: "weapons",
28
29
  "weapon-keywords": "weaponKeywords",
29
30
  factions: "factions",
@@ -44,6 +45,7 @@ const FILE_TO_COLLECTION = {
44
45
  "force-dispositions": "forceDispositions",
45
46
  "terrain-templates": "terrainTemplates",
46
47
  "terrain-layouts": "terrainLayouts",
48
+ "hull-shapes": "hullShapes",
47
49
  "resource-pools": "resourcePools",
48
50
  "timing-flags": "timingFlags",
49
51
  "interaction-flags": "interactionFlags",
@@ -1 +1 @@
1
- {"version":3,"file":"codegen-data.js","sourceRoot":"","sources":["../src/codegen-data.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Bundles every authored data file under `data/` into a single embedded module,\n * `src/data/bundle.generated.ts`.\n *\n * The bundle is inlined as an escaped JSON *string* that is `JSON.parse`d at load\n * time (mirroring the Rust crate's `include_str!`): tsc typechecks it instantly\n * (it is just a string), it parses once at import, and it compiles into `dist`\n * with no runtime filesystem access — so the published package works in Node,\n * bundlers, and browsers alike, where `data/` is not shipped.\n *\n * Run via `npm run codegen:data`. The output is gitignored and regenerated on\n * build/test/pack.\n */\nimport { readdirSync, readFileSync, statSync, writeFileSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport { emptyRawData, type RawData } from \"./data/types.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst REPO_ROOT = resolve(__dirname, \"../..\");\nconst DATA_ROOTS = [join(REPO_ROOT, \"data\", \"core\"), join(REPO_ROOT, \"data\", \"enrichment\")];\nconst OUT_FILE = join(__dirname, \"data\", \"bundle.generated.ts\");\n\n/** Directory names that hold examples/scratch data and must never be bundled. */\nconst EXCLUDED_DIRS = new Set([\"_example\", \"_port-audit\"]);\n\n/** Map a data file's base name (sans `.json`) to its `RawData` collection key. */\nconst FILE_TO_COLLECTION: Record<string, keyof RawData> = {\n units: \"units\",\n weapons: \"weapons\",\n \"weapon-keywords\": \"weaponKeywords\",\n factions: \"factions\",\n abilities: \"abilities\",\n \"phase-mappings\": \"phaseMappings\",\n detachments: \"detachments\",\n stratagems: \"stratagems\",\n enhancements: \"enhancements\",\n \"leader-attachments\": \"leaderAttachments\",\n \"unit-compositions\": \"unitCompositions\",\n \"wargear-options\": \"wargearOptions\",\n wargear: \"wargear\",\n \"game-versions\": \"gameVersions\",\n missions: \"missions\",\n \"mission-matchups\": \"missionMatchups\",\n \"secondary-cards\": \"missionCards\",\n \"deployment-patterns\": \"deploymentPatterns\",\n \"force-dispositions\": \"forceDispositions\",\n \"terrain-templates\": \"terrainTemplates\",\n \"terrain-layouts\": \"terrainLayouts\",\n \"resource-pools\": \"resourcePools\",\n \"timing-flags\": \"timingFlags\",\n \"interaction-flags\": \"interactionFlags\",\n};\n\n/** The id-bearing key for a collection, used only for duplicate-id reporting. */\nconst ID_KEY: Partial<Record<keyof RawData, string>> = {\n abilities: \"ability_id\",\n};\n\n/** Recursively collect bundleable `.json` files, skipping excluded dirs/examples. */\nfunction collectFiles(dir: string): string[] {\n const out: string[] = [];\n for (const entry of readdirSync(dir)) {\n if (EXCLUDED_DIRS.has(entry)) continue;\n const full = join(dir, entry);\n if (statSync(full).isDirectory()) {\n out.push(...collectFiles(full));\n } else if (entry.endsWith(\".json\") && !entry.endsWith(\".example.json\")) {\n out.push(full);\n }\n }\n return out;\n}\n\nfunction baseName(file: string): string {\n return file.slice(file.lastIndexOf(\"/\") + 1, -\".json\".length);\n}\n\nfunction build(): RawData {\n const data = emptyRawData();\n for (const root of DATA_ROOTS) {\n for (const file of collectFiles(root)) {\n const collection = FILE_TO_COLLECTION[baseName(file)];\n if (!collection) continue; // schema/scratch json we don't bundle\n const parsed = JSON.parse(readFileSync(file, \"utf-8\")) as unknown;\n if (!Array.isArray(parsed)) {\n throw new Error(`expected a JSON array in ${file}`);\n }\n (data[collection] as unknown[]).push(...parsed);\n }\n }\n return data;\n}\n\n/** Warn (do not fail) on duplicate primary ids — a data-hygiene signal. */\nfunction reportDuplicateIds(data: RawData): void {\n for (const [collection, key] of Object.entries(ID_KEY) as [keyof RawData, string][]) {\n const seen = new Set<string>();\n const dupes = new Set<string>();\n for (const item of data[collection] as Record<string, unknown>[]) {\n const id = item[key] as string | undefined;\n if (id === undefined) continue;\n if (seen.has(id)) dupes.add(id);\n else seen.add(id);\n }\n if (dupes.size > 0) {\n console.warn(` ⚠ ${collection}: ${dupes.size} duplicate ${key}(s), e.g. ${[...dupes].slice(0, 3).join(\", \")}`);\n }\n }\n}\n\nfunction emit(data: RawData): string {\n // JSON.stringify of the JSON text yields a valid, fully-escaped JS string\n // literal — safe to drop straight into the generated source.\n const jsonText = JSON.stringify(data);\n const literal = JSON.stringify(jsonText);\n return `/* GENERATED by 'npm run codegen:data' from the repository's data/ tree. DO NOT EDIT BY HAND. */\nimport type { RawData } from \"./types.js\";\n\nconst JSON_TEXT = ${literal};\n\n/** The full 40kdc dataset, embedded at build time and parsed once at load. */\nexport const RAW_DATA: RawData = JSON.parse(JSON_TEXT) as RawData;\n`;\n}\n\nfunction main(): void {\n const data = build();\n reportDuplicateIds(data);\n writeFileSync(OUT_FILE, emit(data));\n const counts = (Object.keys(data) as (keyof RawData)[])\n .map((k) => `${k}=${data[k].length}`)\n .join(\", \");\n console.log(`Wrote ${OUT_FILE}\\n ${counts}`);\n}\n\nmain();\n"]}
1
+ {"version":3,"file":"codegen-data.js","sourceRoot":"","sources":["../src/codegen-data.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC7E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,YAAY,EAAgB,MAAM,iBAAiB,CAAC;AAE7D,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAC1D,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAC9C,MAAM,UAAU,GAAG,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC;AAC5F,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,qBAAqB,CAAC,CAAC;AAEhE,iFAAiF;AACjF,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,CAAC,UAAU,EAAE,aAAa,CAAC,CAAC,CAAC;AAE3D,kFAAkF;AAClF,MAAM,kBAAkB,GAAkC;IACxD,KAAK,EAAE,OAAO;IACd,iBAAiB,EAAE,gBAAgB;IACnC,OAAO,EAAE,SAAS;IAClB,iBAAiB,EAAE,gBAAgB;IACnC,QAAQ,EAAE,UAAU;IACpB,SAAS,EAAE,WAAW;IACtB,gBAAgB,EAAE,eAAe;IACjC,WAAW,EAAE,aAAa;IAC1B,UAAU,EAAE,YAAY;IACxB,YAAY,EAAE,cAAc;IAC5B,oBAAoB,EAAE,mBAAmB;IACzC,mBAAmB,EAAE,kBAAkB;IACvC,iBAAiB,EAAE,gBAAgB;IACnC,OAAO,EAAE,SAAS;IAClB,eAAe,EAAE,cAAc;IAC/B,QAAQ,EAAE,UAAU;IACpB,kBAAkB,EAAE,iBAAiB;IACrC,iBAAiB,EAAE,cAAc;IACjC,qBAAqB,EAAE,oBAAoB;IAC3C,oBAAoB,EAAE,mBAAmB;IACzC,mBAAmB,EAAE,kBAAkB;IACvC,iBAAiB,EAAE,gBAAgB;IACnC,aAAa,EAAE,YAAY;IAC3B,gBAAgB,EAAE,eAAe;IACjC,cAAc,EAAE,aAAa;IAC7B,mBAAmB,EAAE,kBAAkB;CACxC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,GAA2C;IACrD,SAAS,EAAE,YAAY;CACxB,CAAC;AAEF,qFAAqF;AACrF,SAAS,YAAY,CAAC,GAAW;IAC/B,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,GAAG,CAAC,EAAE,CAAC;QACrC,IAAI,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC;YAAE,SAAS;QACvC,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QAC9B,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,EAAE,CAAC;YACjC,GAAG,CAAC,IAAI,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;QAClC,CAAC;aAAM,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC;YACvE,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjB,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY;IAC5B,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;AAChE,CAAC;AAED,SAAS,KAAK;IACZ,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;IAC5B,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;QAC9B,KAAK,MAAM,IAAI,IAAI,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;YACtC,MAAM,UAAU,GAAG,kBAAkB,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;YACtD,IAAI,CAAC,UAAU;gBAAE,SAAS,CAAC,sCAAsC;YACjE,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAY,CAAC;YAClE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC3B,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,EAAE,CAAC,CAAC;YACtD,CAAC;YACA,IAAI,CAAC,UAAU,CAAe,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,CAAC;QAClD,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,2EAA2E;AAC3E,SAAS,kBAAkB,CAAC,IAAa;IACvC,KAAK,MAAM,CAAC,UAAU,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAA8B,EAAE,CAAC;QACpF,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;QAC/B,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;QAChC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,UAAU,CAA8B,EAAE,CAAC;YACjE,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAuB,CAAC;YAC3C,IAAI,EAAE,KAAK,SAAS;gBAAE,SAAS;YAC/B,IAAI,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBAAE,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;;gBAC3B,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACpB,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC;YACnB,OAAO,CAAC,IAAI,CAAC,OAAO,UAAU,KAAK,KAAK,CAAC,IAAI,cAAc,GAAG,aAAa,CAAC,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAClH,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,IAAI,CAAC,IAAa;IACzB,0EAA0E;IAC1E,6DAA6D;IAC7D,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IACtC,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IACzC,OAAO;;;oBAGW,OAAO;;;;CAI1B,CAAC;AACF,CAAC;AAED,SAAS,IAAI;IACX,MAAM,IAAI,GAAG,KAAK,EAAE,CAAC;IACrB,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACzB,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IACpC,MAAM,MAAM,GAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAuB;SACpD,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;SACpC,IAAI,CAAC,IAAI,CAAC,CAAC;IACd,OAAO,CAAC,GAAG,CAAC,SAAS,QAAQ,OAAO,MAAM,EAAE,CAAC,CAAC;AAChD,CAAC;AAED,IAAI,EAAE,CAAC","sourcesContent":["/**\n * Bundles every authored data file under `data/` into a single embedded module,\n * `src/data/bundle.generated.ts`.\n *\n * The bundle is inlined as an escaped JSON *string* that is `JSON.parse`d at load\n * time (mirroring the Rust crate's `include_str!`): tsc typechecks it instantly\n * (it is just a string), it parses once at import, and it compiles into `dist`\n * with no runtime filesystem access — so the published package works in Node,\n * bundlers, and browsers alike, where `data/` is not shipped.\n *\n * Run via `npm run codegen:data`. The output is gitignored and regenerated on\n * build/test/pack.\n */\nimport { readdirSync, readFileSync, statSync, writeFileSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport { emptyRawData, type RawData } from \"./data/types.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst REPO_ROOT = resolve(__dirname, \"../..\");\nconst DATA_ROOTS = [join(REPO_ROOT, \"data\", \"core\"), join(REPO_ROOT, \"data\", \"enrichment\")];\nconst OUT_FILE = join(__dirname, \"data\", \"bundle.generated.ts\");\n\n/** Directory names that hold examples/scratch data and must never be bundled. */\nconst EXCLUDED_DIRS = new Set([\"_example\", \"_port-audit\"]);\n\n/** Map a data file's base name (sans `.json`) to its `RawData` collection key. */\nconst FILE_TO_COLLECTION: Record<string, keyof RawData> = {\n units: \"units\",\n \"target-profiles\": \"targetProfiles\",\n weapons: \"weapons\",\n \"weapon-keywords\": \"weaponKeywords\",\n factions: \"factions\",\n abilities: \"abilities\",\n \"phase-mappings\": \"phaseMappings\",\n detachments: \"detachments\",\n stratagems: \"stratagems\",\n enhancements: \"enhancements\",\n \"leader-attachments\": \"leaderAttachments\",\n \"unit-compositions\": \"unitCompositions\",\n \"wargear-options\": \"wargearOptions\",\n wargear: \"wargear\",\n \"game-versions\": \"gameVersions\",\n missions: \"missions\",\n \"mission-matchups\": \"missionMatchups\",\n \"secondary-cards\": \"missionCards\",\n \"deployment-patterns\": \"deploymentPatterns\",\n \"force-dispositions\": \"forceDispositions\",\n \"terrain-templates\": \"terrainTemplates\",\n \"terrain-layouts\": \"terrainLayouts\",\n \"hull-shapes\": \"hullShapes\",\n \"resource-pools\": \"resourcePools\",\n \"timing-flags\": \"timingFlags\",\n \"interaction-flags\": \"interactionFlags\",\n};\n\n/** The id-bearing key for a collection, used only for duplicate-id reporting. */\nconst ID_KEY: Partial<Record<keyof RawData, string>> = {\n abilities: \"ability_id\",\n};\n\n/** Recursively collect bundleable `.json` files, skipping excluded dirs/examples. */\nfunction collectFiles(dir: string): string[] {\n const out: string[] = [];\n for (const entry of readdirSync(dir)) {\n if (EXCLUDED_DIRS.has(entry)) continue;\n const full = join(dir, entry);\n if (statSync(full).isDirectory()) {\n out.push(...collectFiles(full));\n } else if (entry.endsWith(\".json\") && !entry.endsWith(\".example.json\")) {\n out.push(full);\n }\n }\n return out;\n}\n\nfunction baseName(file: string): string {\n return file.slice(file.lastIndexOf(\"/\") + 1, -\".json\".length);\n}\n\nfunction build(): RawData {\n const data = emptyRawData();\n for (const root of DATA_ROOTS) {\n for (const file of collectFiles(root)) {\n const collection = FILE_TO_COLLECTION[baseName(file)];\n if (!collection) continue; // schema/scratch json we don't bundle\n const parsed = JSON.parse(readFileSync(file, \"utf-8\")) as unknown;\n if (!Array.isArray(parsed)) {\n throw new Error(`expected a JSON array in ${file}`);\n }\n (data[collection] as unknown[]).push(...parsed);\n }\n }\n return data;\n}\n\n/** Warn (do not fail) on duplicate primary ids — a data-hygiene signal. */\nfunction reportDuplicateIds(data: RawData): void {\n for (const [collection, key] of Object.entries(ID_KEY) as [keyof RawData, string][]) {\n const seen = new Set<string>();\n const dupes = new Set<string>();\n for (const item of data[collection] as Record<string, unknown>[]) {\n const id = item[key] as string | undefined;\n if (id === undefined) continue;\n if (seen.has(id)) dupes.add(id);\n else seen.add(id);\n }\n if (dupes.size > 0) {\n console.warn(` ⚠ ${collection}: ${dupes.size} duplicate ${key}(s), e.g. ${[...dupes].slice(0, 3).join(\", \")}`);\n }\n }\n}\n\nfunction emit(data: RawData): string {\n // JSON.stringify of the JSON text yields a valid, fully-escaped JS string\n // literal — safe to drop straight into the generated source.\n const jsonText = JSON.stringify(data);\n const literal = JSON.stringify(jsonText);\n return `/* GENERATED by 'npm run codegen:data' from the repository's data/ tree. DO NOT EDIT BY HAND. */\nimport type { RawData } from \"./types.js\";\n\nconst JSON_TEXT = ${literal};\n\n/** The full 40kdc dataset, embedded at build time and parsed once at load. */\nexport const RAW_DATA: RawData = JSON.parse(JSON_TEXT) as RawData;\n`;\n}\n\nfunction main(): void {\n const data = build();\n reportDuplicateIds(data);\n writeFileSync(OUT_FILE, emit(data));\n const counts = (Object.keys(data) as (keyof RawData)[])\n .map((k) => `${k}=${data[k].length}`)\n .join(\", \");\n console.log(`Wrote ${OUT_FILE}\\n ${counts}`);\n}\n\nmain();\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../src/commands/import.ts"],"names":[],"mappings":"AAkBA,UAAU,UAAU;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAqDD,wBAAsB,aAAa,CACjC,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,IAAI,EAAE,UAAU,GACf,OAAO,CAAC,IAAI,CAAC,CAsCf"}
1
+ {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../src/commands/import.ts"],"names":[],"mappings":"AAkBA,UAAU,UAAU;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AA2DD,wBAAsB,aAAa,CACjC,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,IAAI,EAAE,UAAU,GACf,OAAO,CAAC,IAAI,CAAC,CAsCf"}
@@ -34,7 +34,9 @@ function formatPretty(roster) {
34
34
  const lines = [];
35
35
  lines.push(`Roster: ${roster.name}`);
36
36
  lines.push(` Faction: ${roster.faction_id ?? "(unresolved)"}`);
37
- lines.push(` Detachment: ${roster.detachment_id ?? "(none/unresolved)"}`);
37
+ lines.push(` Detachments: ${roster.detachments
38
+ .map((d) => `${d.ref.id ?? d.ref.raw_name}${d.dp_cost != null ? ` (${d.dp_cost} DP)` : ""}`)
39
+ .join(", ") || "(none/unresolved)"}`);
38
40
  lines.push(` Battle size: ${roster.battle_size ?? "(unmapped)"}`);
39
41
  lines.push(` Points: computed ${roster.points.total_computed}` +
40
42
  (roster.points.total_reported !== null ? `, reported ${roster.points.total_reported}` : "") +
@@ -1 +1 @@
1
- {"version":3,"file":"import.js","sourceRoot":"","sources":["../../src/commands/import.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `import` command: turn a ListForge army-list export into a 40kdc roster.\n *\n * Input may be a ListForge URL, a bare base64 segment, an already-decoded JSON\n * string, or a path to a file containing any of those (or `-`/omitted for stdin).\n * The resolved roster is validated against `roster.schema.json` before output —\n * a guard that the importer only ever emits schema-valid rosters.\n *\n * The import is lenient: unresolved entries do not fail the command (exit 0).\n * Only a decode failure or schema-invalid output is fatal (exit 1).\n */\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { importListForge } from \"../import/index.js\";\nimport { createValidator } from \"../schema-loader.js\";\nimport type { Roster } from \"../import/index.js\";\n\nconst ROSTER_SCHEMA_ID = \"https://40kdc.dev/schemas/core/roster.schema.json\";\n\ninterface ImportOpts {\n reporter: string;\n out?: string;\n}\n\nfunction readStdin(): string {\n try {\n return readFileSync(0, \"utf8\");\n } catch {\n return \"\";\n }\n}\n\n/** Resolve the command's input source to a raw payload string. */\nfunction resolveInput(input: string | undefined): string {\n if (!input || input === \"-\") return readStdin().trim();\n if (existsSync(input)) return readFileSync(input, \"utf8\").trim();\n return input;\n}\n\nfunction formatPretty(roster: Roster): string {\n const d = roster.diagnostics;\n const lines: string[] = [];\n lines.push(`Roster: ${roster.name}`);\n lines.push(` Faction: ${roster.faction_id ?? \"(unresolved)\"}`);\n lines.push(` Detachment: ${roster.detachment_id ?? \"(none/unresolved)\"}`);\n lines.push(` Battle size: ${roster.battle_size ?? \"(unmapped)\"}`);\n lines.push(\n ` Points: computed ${roster.points.total_computed}` +\n (roster.points.total_reported !== null ? `, reported ${roster.points.total_reported}` : \"\") +\n (roster.points.declared_limit !== null ? `, limit ${roster.points.declared_limit}` : \"\"),\n );\n lines.push(` Units (${roster.units.length}):`);\n for (const u of roster.units) {\n const mark = u.ref.resolved ? \"✓\" : \"✗\";\n const id = u.ref.resolved ? u.ref.id : `${u.ref.raw_name} → unresolved`;\n const extras: string[] = [];\n if (u.is_warlord) extras.push(\"warlord\");\n if (u.enhancement) extras.push(`enh:${u.enhancement.resolved ? u.enhancement.id : \"?\"}`);\n if (u.leader_attachment) extras.push(`leads:${u.leader_attachment.bodyguard_ref.id}?`);\n const suffix = extras.length ? ` [${extras.join(\", \")}]` : \"\";\n lines.push(` ${mark} ${id} ×${u.model_count}${u.points !== null ? ` (${u.points}pts)` : \"\"}${suffix}`);\n }\n lines.push(\n ` Resolved: ${d.resolved_units} units / ${d.resolved_weapons} weapons; ` +\n `unresolved: ${d.unresolved_units} units / ${d.unresolved_weapons} weapons`,\n );\n if (d.warnings.length) {\n lines.push(` Warnings (${d.warnings.length}):`);\n for (const w of d.warnings) {\n lines.push(` - [${w.code}]${w.raw_name ? ` \"${w.raw_name}\":` : \"\"} ${w.message}`);\n }\n }\n return lines.join(\"\\n\");\n}\n\nexport async function importCommand(\n input: string | undefined,\n opts: ImportOpts,\n): Promise<void> {\n const payload = resolveInput(input);\n if (!payload) {\n console.error(\"import: no input (provide a URL/base64/JSON argument, a file path, or pipe via stdin)\");\n process.exit(1);\n }\n\n let roster: Roster;\n try {\n roster = importListForge(payload);\n } catch (err) {\n console.error(`import: failed to decode/parse payload: ${(err as Error).message}`);\n process.exit(1);\n }\n\n // Guard: our own output must be schema-valid.\n const validate = createValidator().getSchema(ROSTER_SCHEMA_ID);\n if (!validate) {\n console.error(`import: roster schema not found (${ROSTER_SCHEMA_ID})`);\n process.exit(1);\n }\n if (!validate(roster)) {\n console.error(\"import: produced roster failed schema validation:\");\n console.error(JSON.stringify(validate.errors, null, 2));\n process.exit(1);\n }\n\n const json = JSON.stringify(roster, null, 2);\n if (opts.out) {\n writeFileSync(opts.out, json + \"\\n\", \"utf8\");\n console.error(`Wrote roster → ${opts.out}`);\n }\n\n if (opts.reporter === \"pretty\") {\n console.log(formatPretty(roster));\n } else if (!opts.out) {\n console.log(json);\n }\n}\n"]}
1
+ {"version":3,"file":"import.js","sourceRoot":"","sources":["../../src/commands/import.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAClE,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAGtD,MAAM,gBAAgB,GAAG,mDAAmD,CAAC;AAO7E,SAAS,SAAS;IAChB,IAAI,CAAC;QACH,OAAO,YAAY,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IACjC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED,kEAAkE;AAClE,SAAS,YAAY,CAAC,KAAyB;IAC7C,IAAI,CAAC,KAAK,IAAI,KAAK,KAAK,GAAG;QAAE,OAAO,SAAS,EAAE,CAAC,IAAI,EAAE,CAAC;IACvD,IAAI,UAAU,CAAC,KAAK,CAAC;QAAE,OAAO,YAAY,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;IACjE,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,YAAY,CAAC,MAAc;IAClC,MAAM,CAAC,GAAG,MAAM,CAAC,WAAW,CAAC;IAC7B,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,WAAW,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACrC,KAAK,CAAC,IAAI,CAAC,iBAAiB,MAAM,CAAC,UAAU,IAAI,cAAc,EAAE,CAAC,CAAC;IACnE,KAAK,CAAC,IAAI,CACR,kBACE,MAAM,CAAC,WAAW;SACf,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC,GAAG,CAAC,QAAQ,GAAG,CAAC,CAAC,OAAO,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;SAC3F,IAAI,CAAC,IAAI,CAAC,IAAI,mBACnB,EAAE,CACH,CAAC;IACF,KAAK,CAAC,IAAI,CAAC,kBAAkB,MAAM,CAAC,WAAW,IAAI,YAAY,EAAE,CAAC,CAAC;IACnE,KAAK,CAAC,IAAI,CACR,sBAAsB,MAAM,CAAC,MAAM,CAAC,cAAc,EAAE;QAClD,CAAC,MAAM,CAAC,MAAM,CAAC,cAAc,KAAK,IAAI,CAAC,CAAC,CAAC,cAAc,MAAM,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3F,CAAC,MAAM,CAAC,MAAM,CAAC,cAAc,KAAK,IAAI,CAAC,CAAC,CAAC,WAAW,MAAM,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAC3F,CAAC;IACF,KAAK,CAAC,IAAI,CAAC,YAAY,MAAM,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC;IAChD,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QAC7B,MAAM,IAAI,GAAG,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;QACxC,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,QAAQ,eAAe,CAAC;QACxE,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,IAAI,CAAC,CAAC,UAAU;YAAE,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACzC,IAAI,CAAC,CAAC,WAAW;YAAE,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;QACzF,IAAI,CAAC,CAAC,iBAAiB;YAAE,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,iBAAiB,CAAC,aAAa,CAAC,EAAE,GAAG,CAAC,CAAC;QACvF,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,KAAK,CAAC,IAAI,CAAC,OAAO,IAAI,IAAI,EAAE,KAAK,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,MAAM,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,CAAC,CAAC;IAC5G,CAAC;IACD,KAAK,CAAC,IAAI,CACR,eAAe,CAAC,CAAC,cAAc,YAAY,CAAC,CAAC,gBAAgB,YAAY;QACvE,eAAe,CAAC,CAAC,gBAAgB,YAAY,CAAC,CAAC,kBAAkB,UAAU,CAC9E,CAAC;IACF,IAAI,CAAC,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;QACtB,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,QAAQ,CAAC,MAAM,IAAI,CAAC,CAAC;QACjD,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;YAC3B,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,QAAQ,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC;QACvF,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,KAAyB,EACzB,IAAgB;IAEhB,MAAM,OAAO,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,uFAAuF,CAAC,CAAC;QACvG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,IAAI,MAAc,CAAC;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,2CAA4C,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;QACnF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,8CAA8C;IAC9C,MAAM,QAAQ,GAAG,eAAe,EAAE,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;IAC/D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,CAAC,KAAK,CAAC,oCAAoC,gBAAgB,GAAG,CAAC,CAAC;QACvE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QACtB,OAAO,CAAC,KAAK,CAAC,mDAAmD,CAAC,CAAC;QACnE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;QACxD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAC7C,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;QACb,aAAa,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;QAC7C,OAAO,CAAC,KAAK,CAAC,kBAAkB,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC9C,CAAC;IAED,IAAI,IAAI,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAC/B,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;IACpC,CAAC;SAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;QACrB,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACpB,CAAC;AACH,CAAC","sourcesContent":["/**\n * `import` command: turn a ListForge army-list export into a 40kdc roster.\n *\n * Input may be a ListForge URL, a bare base64 segment, an already-decoded JSON\n * string, or a path to a file containing any of those (or `-`/omitted for stdin).\n * The resolved roster is validated against `roster.schema.json` before output —\n * a guard that the importer only ever emits schema-valid rosters.\n *\n * The import is lenient: unresolved entries do not fail the command (exit 0).\n * Only a decode failure or schema-invalid output is fatal (exit 1).\n */\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { importListForge } from \"../import/index.js\";\nimport { createValidator } from \"../schema-loader.js\";\nimport type { Roster } from \"../import/index.js\";\n\nconst ROSTER_SCHEMA_ID = \"https://40kdc.dev/schemas/core/roster.schema.json\";\n\ninterface ImportOpts {\n reporter: string;\n out?: string;\n}\n\nfunction readStdin(): string {\n try {\n return readFileSync(0, \"utf8\");\n } catch {\n return \"\";\n }\n}\n\n/** Resolve the command's input source to a raw payload string. */\nfunction resolveInput(input: string | undefined): string {\n if (!input || input === \"-\") return readStdin().trim();\n if (existsSync(input)) return readFileSync(input, \"utf8\").trim();\n return input;\n}\n\nfunction formatPretty(roster: Roster): string {\n const d = roster.diagnostics;\n const lines: string[] = [];\n lines.push(`Roster: ${roster.name}`);\n lines.push(` Faction: ${roster.faction_id ?? \"(unresolved)\"}`);\n lines.push(\n ` Detachments: ${\n roster.detachments\n .map((d) => `${d.ref.id ?? d.ref.raw_name}${d.dp_cost != null ? ` (${d.dp_cost} DP)` : \"\"}`)\n .join(\", \") || \"(none/unresolved)\"\n }`,\n );\n lines.push(` Battle size: ${roster.battle_size ?? \"(unmapped)\"}`);\n lines.push(\n ` Points: computed ${roster.points.total_computed}` +\n (roster.points.total_reported !== null ? `, reported ${roster.points.total_reported}` : \"\") +\n (roster.points.declared_limit !== null ? `, limit ${roster.points.declared_limit}` : \"\"),\n );\n lines.push(` Units (${roster.units.length}):`);\n for (const u of roster.units) {\n const mark = u.ref.resolved ? \"✓\" : \"✗\";\n const id = u.ref.resolved ? u.ref.id : `${u.ref.raw_name} → unresolved`;\n const extras: string[] = [];\n if (u.is_warlord) extras.push(\"warlord\");\n if (u.enhancement) extras.push(`enh:${u.enhancement.resolved ? u.enhancement.id : \"?\"}`);\n if (u.leader_attachment) extras.push(`leads:${u.leader_attachment.bodyguard_ref.id}?`);\n const suffix = extras.length ? ` [${extras.join(\", \")}]` : \"\";\n lines.push(` ${mark} ${id} ×${u.model_count}${u.points !== null ? ` (${u.points}pts)` : \"\"}${suffix}`);\n }\n lines.push(\n ` Resolved: ${d.resolved_units} units / ${d.resolved_weapons} weapons; ` +\n `unresolved: ${d.unresolved_units} units / ${d.unresolved_weapons} weapons`,\n );\n if (d.warnings.length) {\n lines.push(` Warnings (${d.warnings.length}):`);\n for (const w of d.warnings) {\n lines.push(` - [${w.code}]${w.raw_name ? ` \"${w.raw_name}\":` : \"\"} ${w.message}`);\n }\n }\n return lines.join(\"\\n\");\n}\n\nexport async function importCommand(\n input: string | undefined,\n opts: ImportOpts,\n): Promise<void> {\n const payload = resolveInput(input);\n if (!payload) {\n console.error(\"import: no input (provide a URL/base64/JSON argument, a file path, or pipe via stdin)\");\n process.exit(1);\n }\n\n let roster: Roster;\n try {\n roster = importListForge(payload);\n } catch (err) {\n console.error(`import: failed to decode/parse payload: ${(err as Error).message}`);\n process.exit(1);\n }\n\n // Guard: our own output must be schema-valid.\n const validate = createValidator().getSchema(ROSTER_SCHEMA_ID);\n if (!validate) {\n console.error(`import: roster schema not found (${ROSTER_SCHEMA_ID})`);\n process.exit(1);\n }\n if (!validate(roster)) {\n console.error(\"import: produced roster failed schema validation:\");\n console.error(JSON.stringify(validate.errors, null, 2));\n process.exit(1);\n }\n\n const json = JSON.stringify(roster, null, 2);\n if (opts.out) {\n writeFileSync(opts.out, json + \"\\n\", \"utf8\");\n console.error(`Wrote roster → ${opts.out}`);\n }\n\n if (opts.reporter === \"pretty\") {\n console.log(formatPretty(roster));\n } else if (!opts.out) {\n console.log(json);\n }\n}\n"]}
@@ -0,0 +1,167 @@
1
+ import type { Dataset } from "./data/dataset.js";
2
+ import type { UnitView } from "./data/entities.js";
3
+ import type { TargetProfile, Unit, Weapon } from "./generated.js";
4
+ export type ComparePhase = "shooting" | "fight";
5
+ export type CompareMetric = "best-weapon" | "unit-total";
6
+ /** Which weapon type fires in a phase: shooting → ranged, fight → melee. */
7
+ export declare function weaponTypeForComparePhase(phase: ComparePhase): "ranged" | "melee";
8
+ /** One attacker weapon profile evaluated against one target. */
9
+ export interface WeaponCell {
10
+ weaponId: string;
11
+ weaponName: string;
12
+ profileIndex: number;
13
+ profileName: string;
14
+ range: number | null;
15
+ reaches: boolean;
16
+ withinHalfRange: boolean;
17
+ expectedKills: number;
18
+ }
19
+ /** One (attacker unit, target profile) pair across all the unit's weapons. */
20
+ export interface MatrixCell {
21
+ targetProfileId: string;
22
+ targetProfileName: string;
23
+ best: WeaponCell | null;
24
+ unitTotal: number;
25
+ weapons: WeaponCell[];
26
+ }
27
+ export interface MatrixRow {
28
+ unitId: string;
29
+ unitName: string;
30
+ cells: MatrixCell[];
31
+ /** Peak cell value across targets — the row's sort key. */
32
+ peak: number;
33
+ }
34
+ /** A target profile resolved to its live dataset unit. */
35
+ export interface ResolvedTarget {
36
+ profileId: string;
37
+ profileName: string;
38
+ unitRaw: Unit;
39
+ modelCount: number;
40
+ }
41
+ export declare function cellValue(cell: MatrixCell, metric: CompareMetric): number;
42
+ /**
43
+ * Resolve a target profile to its referenced unit, faction-scoped (shared ids
44
+ * like Rhino/Forgefiend appear under several factions). Returns null if the
45
+ * referenced unit is absent.
46
+ */
47
+ export declare function resolveTarget(ds: Dataset, profile: TargetProfile): ResolvedTarget | null;
48
+ /**
49
+ * Expected models killed for one (weapon profile, target) at a distance. The
50
+ * single unit of computation the matrix and the conformance corpus share.
51
+ */
52
+ export declare function expectedKills(ds: Dataset, weaponRaw: Weapon, profileIndex: number, target: ResolvedTarget, phase: ComparePhase, modelsFiring: number, withinHalfRange: boolean): number;
53
+ /**
54
+ * Expected post-FNP damage (wounds dealt) for one weapon profile against a
55
+ * target. The summable counterpart to {@link expectedKills}: damage adds
56
+ * linearly across weapons, so a unit's total output is built by summing this
57
+ * over its loadout and converting to kills once — never by summing per-weapon
58
+ * kills (each weapon's models-killed caps independently, which over-counts).
59
+ */
60
+ export declare function expectedDamage(ds: Dataset, weaponRaw: Weapon, profileIndex: number, target: ResolvedTarget, phase: ComparePhase, modelsFiring: number, withinHalfRange: boolean): number;
61
+ /**
62
+ * Evaluate every phase-appropriate weapon profile of one attacker unit against
63
+ * one target, returning per-weapon results plus the best.
64
+ */
65
+ export declare function unitVsTarget(ds: Dataset, attackerUnit: UnitView, target: ResolvedTarget, distance: number, phase: ComparePhase, modelsFiring?: number): MatrixCell;
66
+ /** Result of evaluating one (attacker weapon profile, target profile) cell. */
67
+ export interface CompareCell {
68
+ expectedKills: number;
69
+ reaches: boolean;
70
+ withinHalfRange: boolean;
71
+ modelCount: number;
72
+ }
73
+ /**
74
+ * Evaluate one (attacker weapon profile, target profile) cell — the single-cell
75
+ * entry point shared by the conformance runner and consumers. Throws on unknown
76
+ * profile/weapon ids so the runner can map them to the closed error enum.
77
+ */
78
+ export declare function compareCell(ds: Dataset, opts: {
79
+ factionId: string;
80
+ unitId: string;
81
+ weaponId: string;
82
+ profileIndex: number;
83
+ targetProfileId: string;
84
+ distance: number;
85
+ phase: ComparePhase;
86
+ modelsFiring?: number;
87
+ }): CompareCell;
88
+ /** The cross product: one row per attacker unit, one column per target. */
89
+ export declare function buildMatrix(ds: Dataset, attackerUnits: UnitView[], targets: ResolvedTarget[], distance: number, phase: ComparePhase, metric: CompareMetric, modelsFiring?: number): MatrixRow[];
90
+ /** One weapon in a loadout: `count` copies of `weaponId` profile `profileIndex`. */
91
+ export interface LoadoutLine {
92
+ weaponId: string;
93
+ profileIndex?: number;
94
+ count: number;
95
+ }
96
+ /** A named weapon configuration to evaluate. `points` enables per-point ranking
97
+ * and lets you compare unit *choices* (one Defiler vs two Forgefiends) on equal
98
+ * footing — a config can span multiple bodies (just list all their weapons). */
99
+ export interface LoadoutConfig {
100
+ label: string;
101
+ points?: number | null;
102
+ lines: LoadoutLine[];
103
+ }
104
+ export interface LoadoutTargetResult {
105
+ targetProfileId: string;
106
+ targetProfileName: string;
107
+ /** Total post-FNP wounds the whole config deals to this target. */
108
+ damage: number;
109
+ /** Models killed = min(modelCount, damage / W) — capped once, after summing. */
110
+ kills: number;
111
+ }
112
+ export interface LoadoutRanking {
113
+ config: LoadoutConfig;
114
+ results: LoadoutTargetResult[];
115
+ /** The metric the configs were ranked by (damage against the rank target, or
116
+ * summed across all targets when no rank target is given). */
117
+ score: number;
118
+ /** `score` per 100 points, or null when the config carries no points. */
119
+ scorePer100Points: number | null;
120
+ }
121
+ /**
122
+ * Total a single loadout against one target, summing post-FNP damage across all
123
+ * weapon lines (each fired by `count` models) and converting to kills *once*.
124
+ * A line whose weapon can't reach `distance` contributes nothing.
125
+ */
126
+ export declare function loadoutOutput(ds: Dataset, config: LoadoutConfig, target: ResolvedTarget, distance: number, phase: ComparePhase): LoadoutTargetResult;
127
+ /**
128
+ * Rank loadout configs against a set of targets. Each config is scored by its
129
+ * damage against `rankTargetId` (or summed across all targets when omitted);
130
+ * results are sorted high-to-low, with `scorePer100Points` for points-aware
131
+ * comparison across unit choices.
132
+ */
133
+ export declare function rankLoadouts(ds: Dataset, configs: LoadoutConfig[], targets: ResolvedTarget[], distance: number, phase: ComparePhase, rankTargetId?: string): LoadoutRanking[];
134
+ /**
135
+ * Evaluate one loadout against one target by ids — the single-call entry point
136
+ * for the conformance runner. Throws on unknown profile so the runner maps it
137
+ * to the closed error enum.
138
+ */
139
+ export declare function loadoutCell(ds: Dataset, opts: {
140
+ lines: LoadoutLine[];
141
+ targetProfileId: string;
142
+ distance: number;
143
+ phase: ComparePhase;
144
+ }): {
145
+ damage: number;
146
+ kills: number;
147
+ };
148
+ /** Result of deriving a unit's shooting-loadout space from its wargear. */
149
+ export interface EnumeratedLoadouts {
150
+ configs: LoadoutConfig[];
151
+ diagnostics: string[];
152
+ /** Always false: the dataset doesn't record per-model weapon counts, so every
153
+ * line defaults to count=1. Configs are reliable for *relative* slot ranking;
154
+ * absolute totals need counts supplied by hand. */
155
+ countsKnown: boolean;
156
+ }
157
+ /**
158
+ * Derive the shooting-loadout choice space for a unit from its wargear options:
159
+ * each mutually-exclusive ranged-weapon swap (grouped by option id) is an axis,
160
+ * and the cross product yields candidate configs (count=1 per weapon). Mirror
161
+ * of the Python `enumerate_loadouts`. Heuristic, not spec-pinned — it reflects
162
+ * the (imperfect) wargear data and conservatively drops refs it can't resolve.
163
+ */
164
+ export declare function enumerateLoadouts(ds: Dataset, factionId: string, unitId: string): EnumeratedLoadouts;
165
+ /** Render a matrix as a Discord-paste markdown table. */
166
+ export declare function matrixToMarkdown(rows: MatrixRow[], targets: ResolvedTarget[], metric: CompareMetric): string;
167
+ //# sourceMappingURL=compare.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compare.d.ts","sourceRoot":"","sources":["../src/compare.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Fleet comparison: expected kills of attacker units against target-profile
3
+ * archetypes. The cross product — a set of attacker units against a set of
4
+ * targets — yielding a matrix of expected models killed per (unit, target).
5
+ *
6
+ * Target profiles reference real dataset units (`faction_id` + `unit_id`), so
7
+ * each cell resolves the live unit and feeds it straight to {@link crunch}; the
8
+ * target's defensive abilities (FNP, ability invulns, save/toughness mods) are
9
+ * resolved via {@link Dataset.defensiveBuffsFor} and stacked onto the crunch.
10
+ * Rapid Fire / Melta come for free: each cell sets `withinHalfRange` from
11
+ * `distance <= range / 2`.
12
+ *
13
+ * Python mirror: `python/src/wh40kdc/compare.py`. The per-cell `expectedKills`
14
+ * is pinned against Python by the `compare` conformance area.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+ import { crunch } from "./cruncher/index.js";
19
+ /** Which weapon type fires in a phase: shooting → ranged, fight → melee. */
20
+ export function weaponTypeForComparePhase(phase) {
21
+ return phase === "fight" ? "melee" : "ranged";
22
+ }
23
+ export function cellValue(cell, metric) {
24
+ if (metric === "unit-total")
25
+ return cell.unitTotal;
26
+ return cell.best?.expectedKills ?? 0;
27
+ }
28
+ /**
29
+ * Resolve a target profile to its referenced unit, faction-scoped (shared ids
30
+ * like Rhino/Forgefiend appear under several factions). Returns null if the
31
+ * referenced unit is absent.
32
+ */
33
+ export function resolveTarget(ds, profile) {
34
+ const unit = ds.units.getInFaction(profile.unit_id, profile.faction_id);
35
+ if (!unit)
36
+ return null;
37
+ const override = profile.model_count_override ?? null;
38
+ const modelCount = override ?? unit.raw.model_count?.min ?? 1;
39
+ return { profileId: profile.id, profileName: profile.name, unitRaw: unit.raw, modelCount };
40
+ }
41
+ /**
42
+ * Expected models killed for one (weapon profile, target) at a distance. The
43
+ * single unit of computation the matrix and the conformance corpus share.
44
+ */
45
+ export function expectedKills(ds, weaponRaw, profileIndex, target, phase, modelsFiring, withinHalfRange) {
46
+ const ctx = { phase, withinHalfRange };
47
+ const buffs = ds.defensiveBuffsFor({ unitId: target.unitRaw.id, factionId: target.unitRaw.faction_id }, ctx);
48
+ const out = crunch({
49
+ attacker: { weapon: weaponRaw, profileIndex },
50
+ target: { unit: target.unitRaw, profileIndex: 0, modelCount: target.modelCount },
51
+ modelsFiring,
52
+ buffs,
53
+ context: ctx,
54
+ }, ds);
55
+ const stage = out.stages.find((s) => s.name === "models-killed");
56
+ return stage ? stage.expected : 0;
57
+ }
58
+ /**
59
+ * Expected post-FNP damage (wounds dealt) for one weapon profile against a
60
+ * target. The summable counterpart to {@link expectedKills}: damage adds
61
+ * linearly across weapons, so a unit's total output is built by summing this
62
+ * over its loadout and converting to kills once — never by summing per-weapon
63
+ * kills (each weapon's models-killed caps independently, which over-counts).
64
+ */
65
+ export function expectedDamage(ds, weaponRaw, profileIndex, target, phase, modelsFiring, withinHalfRange) {
66
+ const ctx = { phase, withinHalfRange };
67
+ const buffs = ds.defensiveBuffsFor({ unitId: target.unitRaw.id, factionId: target.unitRaw.faction_id }, ctx);
68
+ const out = crunch({
69
+ attacker: { weapon: weaponRaw, profileIndex },
70
+ target: { unit: target.unitRaw, profileIndex: 0, modelCount: target.modelCount },
71
+ modelsFiring,
72
+ buffs,
73
+ context: ctx,
74
+ }, ds);
75
+ const stage = out.stages.find((s) => s.name === "after-fnp");
76
+ return stage ? stage.expected : 0;
77
+ }
78
+ /**
79
+ * Evaluate every phase-appropriate weapon profile of one attacker unit against
80
+ * one target, returning per-weapon results plus the best.
81
+ */
82
+ export function unitVsTarget(ds, attackerUnit, target, distance, phase, modelsFiring = 1) {
83
+ const wantType = weaponTypeForComparePhase(phase);
84
+ const weapons = [];
85
+ for (const weapon of attackerUnit.weapons) {
86
+ const wraw = weapon.raw;
87
+ if (wraw.type !== wantType)
88
+ continue;
89
+ wraw.profiles.forEach((wprofile, idx) => {
90
+ const rng = wprofile.range;
91
+ const isRanged = typeof rng === "number";
92
+ const reaches = !isRanged || rng >= distance;
93
+ const withinHalf = isRanged && distance <= rng / 2;
94
+ const kills = reaches
95
+ ? expectedKills(ds, wraw, idx, target, phase, modelsFiring, withinHalf)
96
+ : 0;
97
+ weapons.push({
98
+ weaponId: wraw.id,
99
+ weaponName: weapon.name,
100
+ profileIndex: idx,
101
+ profileName: wprofile.name ?? weapon.name,
102
+ range: isRanged ? rng : null,
103
+ reaches,
104
+ withinHalfRange: withinHalf,
105
+ expectedKills: kills,
106
+ });
107
+ });
108
+ }
109
+ const reaching = weapons.filter((w) => w.reaches);
110
+ const best = reaching.reduce((acc, w) => (acc === null || w.expectedKills > acc.expectedKills ? w : acc), null);
111
+ const unitTotal = reaching.reduce((sum, w) => sum + w.expectedKills, 0);
112
+ return { targetProfileId: target.profileId, targetProfileName: target.profileName, best, unitTotal, weapons };
113
+ }
114
+ /**
115
+ * Evaluate one (attacker weapon profile, target profile) cell — the single-cell
116
+ * entry point shared by the conformance runner and consumers. Throws on unknown
117
+ * profile/weapon ids so the runner can map them to the closed error enum.
118
+ */
119
+ export function compareCell(ds, opts) {
120
+ const profile = ds.targetProfiles.get(opts.targetProfileId);
121
+ if (!profile)
122
+ throw new Error(`unknown target profile: ${opts.targetProfileId}`);
123
+ const target = resolveTarget(ds, profile);
124
+ if (!target) {
125
+ throw new Error(`target profile ${opts.targetProfileId} references missing unit ${profile.unit_id}`);
126
+ }
127
+ const weapon = ds.weapons.get(opts.weaponId);
128
+ if (!weapon)
129
+ throw new Error(`unknown weapon: ${opts.weaponId}`);
130
+ const wraw = weapon.raw;
131
+ const rng = wraw.profiles[opts.profileIndex]?.range;
132
+ const isRanged = typeof rng === "number";
133
+ const reaches = !isRanged || rng >= opts.distance;
134
+ const withinHalfRange = isRanged && opts.distance <= rng / 2;
135
+ const kills = reaches
136
+ ? expectedKills(ds, wraw, opts.profileIndex, target, opts.phase, opts.modelsFiring ?? 1, withinHalfRange)
137
+ : 0;
138
+ return { expectedKills: kills, reaches, withinHalfRange, modelCount: target.modelCount };
139
+ }
140
+ /** The cross product: one row per attacker unit, one column per target. */
141
+ export function buildMatrix(ds, attackerUnits, targets, distance, phase, metric, modelsFiring = 1) {
142
+ const rows = attackerUnits.map((unit) => {
143
+ const cells = targets.map((t) => unitVsTarget(ds, unit, t, distance, phase, modelsFiring));
144
+ const peak = cells.reduce((m, c) => Math.max(m, cellValue(c, metric)), 0);
145
+ return { unitId: unit.id, unitName: unit.name, cells, peak };
146
+ });
147
+ return rows.sort((a, b) => b.peak - a.peak);
148
+ }
149
+ /**
150
+ * Total a single loadout against one target, summing post-FNP damage across all
151
+ * weapon lines (each fired by `count` models) and converting to kills *once*.
152
+ * A line whose weapon can't reach `distance` contributes nothing.
153
+ */
154
+ export function loadoutOutput(ds, config, target, distance, phase) {
155
+ let damage = 0;
156
+ for (const line of config.lines) {
157
+ const weapon = ds.weapons.get(line.weaponId);
158
+ if (!weapon)
159
+ continue;
160
+ const profileIndex = line.profileIndex ?? 0;
161
+ const rng = weapon.raw.profiles[profileIndex]?.range;
162
+ const isRanged = typeof rng === "number";
163
+ if (isRanged && rng < distance)
164
+ continue; // out of range
165
+ const withinHalf = isRanged && distance <= rng / 2;
166
+ damage += expectedDamage(ds, weapon.raw, profileIndex, target, phase, line.count, withinHalf);
167
+ }
168
+ const w = target.unitRaw.profiles[0]?.W ?? 1;
169
+ const kills = w > 0 ? Math.min(target.modelCount, damage / w) : 0;
170
+ return {
171
+ targetProfileId: target.profileId,
172
+ targetProfileName: target.profileName,
173
+ damage,
174
+ kills,
175
+ };
176
+ }
177
+ /**
178
+ * Rank loadout configs against a set of targets. Each config is scored by its
179
+ * damage against `rankTargetId` (or summed across all targets when omitted);
180
+ * results are sorted high-to-low, with `scorePer100Points` for points-aware
181
+ * comparison across unit choices.
182
+ */
183
+ export function rankLoadouts(ds, configs, targets, distance, phase, rankTargetId) {
184
+ const ranked = configs.map((config) => {
185
+ const results = targets.map((t) => loadoutOutput(ds, config, t, distance, phase));
186
+ const score = rankTargetId
187
+ ? (results.find((r) => r.targetProfileId === rankTargetId)?.damage ?? 0)
188
+ : results.reduce((sum, r) => sum + r.damage, 0);
189
+ const scorePer100Points = config.points && config.points > 0 ? (score / config.points) * 100 : null;
190
+ return { config, results, score, scorePer100Points };
191
+ });
192
+ return ranked.sort((a, b) => b.score - a.score);
193
+ }
194
+ /**
195
+ * Evaluate one loadout against one target by ids — the single-call entry point
196
+ * for the conformance runner. Throws on unknown profile so the runner maps it
197
+ * to the closed error enum.
198
+ */
199
+ export function loadoutCell(ds, opts) {
200
+ const profile = ds.targetProfiles.get(opts.targetProfileId);
201
+ if (!profile)
202
+ throw new Error(`unknown target profile: ${opts.targetProfileId}`);
203
+ const target = resolveTarget(ds, profile);
204
+ if (!target)
205
+ throw new Error(`target profile ${opts.targetProfileId} references a missing unit`);
206
+ const res = loadoutOutput(ds, { label: "", lines: opts.lines }, target, opts.distance, opts.phase);
207
+ return { damage: res.damage, kills: res.kills };
208
+ }
209
+ function resolveWeaponId(ref, weaponIds) {
210
+ if (weaponIds.has(ref))
211
+ return ref;
212
+ if (ref.endsWith("s") && weaponIds.has(ref.slice(0, -1)))
213
+ return ref.slice(0, -1);
214
+ return null;
215
+ }
216
+ /**
217
+ * Derive the shooting-loadout choice space for a unit from its wargear options:
218
+ * each mutually-exclusive ranged-weapon swap (grouped by option id) is an axis,
219
+ * and the cross product yields candidate configs (count=1 per weapon). Mirror
220
+ * of the Python `enumerate_loadouts`. Heuristic, not spec-pinned — it reflects
221
+ * the (imperfect) wargear data and conservatively drops refs it can't resolve.
222
+ */
223
+ export function enumerateLoadouts(ds, factionId, unitId) {
224
+ const unit = ds.units.getInFaction(unitId, factionId);
225
+ if (!unit)
226
+ throw new Error(`unit ${unitId} not found in faction ${factionId}`);
227
+ const weaponIds = new Set(ds.weapons.all.map((w) => w.raw.id));
228
+ const isRanged = (wid) => ds.weapons.get(wid)?.raw.type === "ranged";
229
+ const baseRanged = [
230
+ ...new Set(unit.weapons.filter((w) => w.raw.type === "ranged").map((w) => w.raw.id)),
231
+ ].sort();
232
+ const diagnostics = [];
233
+ // Group choices by option id so duplicate rows of one physical slot union
234
+ // into a single exclusive axis (rather than letting two of its options stack).
235
+ const choicesByOption = new Map();
236
+ for (const opt of ds.wargearOptionsOf(unit.raw)) {
237
+ const oid = opt.id ?? JSON.stringify(opt);
238
+ const rawChoices = [];
239
+ const o = opt;
240
+ if (o.replaces)
241
+ rawChoices.push(o.replaces);
242
+ if (o.replacement)
243
+ rawChoices.push(o.replacement);
244
+ for (const ch of o.replacement_choice ?? [])
245
+ rawChoices.push(ch);
246
+ const bucket = choicesByOption.get(oid) ?? [];
247
+ for (const ch of rawChoices) {
248
+ const rids = [];
249
+ for (const ref of ch) {
250
+ const wid = resolveWeaponId(ref, weaponIds);
251
+ if (wid === null)
252
+ diagnostics.push(`option ${oid}: unresolved ref ${JSON.stringify(ref)}`);
253
+ else if (isRanged(wid))
254
+ rids.push(wid);
255
+ }
256
+ if (rids.length) {
257
+ const key = [...rids].sort().join("|");
258
+ if (!bucket.some((b) => [...b].sort().join("|") === key))
259
+ bucket.push(rids);
260
+ }
261
+ }
262
+ choicesByOption.set(oid, bucket);
263
+ }
264
+ const axes = [...choicesByOption.values()].filter((c) => c.length > 1);
265
+ const swapMembers = new Set(axes.flat(2));
266
+ const fixed = baseRanged.filter((w) => !swapMembers.has(w));
267
+ let combos = [fixed];
268
+ for (const axis of axes) {
269
+ combos = combos.flatMap((combo) => axis.map((choice) => [...combo, ...choice]));
270
+ }
271
+ const byLabel = new Map();
272
+ const points = unit.raw.points?.[0]?.cost ?? null;
273
+ for (const combo of combos) {
274
+ const weapons = [...new Set(combo)].sort();
275
+ if (!weapons.length)
276
+ continue;
277
+ const label = weapons.map((w) => ds.weapons.get(w)?.name ?? w).join(" + ");
278
+ if (!byLabel.has(label)) {
279
+ byLabel.set(label, {
280
+ label,
281
+ points,
282
+ lines: weapons.map((w) => ({ weaponId: w, count: 1 })),
283
+ });
284
+ }
285
+ }
286
+ return { configs: [...byLabel.values()], diagnostics: [...new Set(diagnostics)].sort(), countsKnown: false };
287
+ }
288
+ /** Render a matrix as a Discord-paste markdown table. */
289
+ export function matrixToMarkdown(rows, targets, metric) {
290
+ const headers = ["Unit", ...targets.map((t) => t.profileName)];
291
+ const lines = [`| ${headers.join(" | ")} |`, `|${headers.map(() => "---").join("|")}|`];
292
+ for (const row of rows) {
293
+ const cells = row.cells.map((c) => {
294
+ const v = cellValue(c, metric);
295
+ const text = v ? v.toFixed(2) : "—";
296
+ return v >= 1 ? `**${text}**` : text;
297
+ });
298
+ lines.push(`| ${[row.unitName, ...cells].join(" | ")} |`);
299
+ }
300
+ return lines.join("\n");
301
+ }
302
+ //# sourceMappingURL=compare.js.map