@alpaca-software/40kdc-data 0.5.4 → 0.5.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen-data.js +1 -0
- package/dist/codegen-data.js.map +1 -1
- package/dist/compare.d.ts +167 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +302 -0
- package/dist/compare.js.map +1 -0
- package/dist/cruncher/buffs.d.ts +13 -0
- package/dist/cruncher/buffs.d.ts.map +1 -1
- package/dist/cruncher/buffs.js +7 -0
- package/dist/cruncher/buffs.js.map +1 -1
- package/dist/cruncher/engine.d.ts.map +1 -1
- package/dist/cruncher/engine.js +14 -10
- package/dist/cruncher/engine.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/dataset.d.ts +2 -1
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +2 -0
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/entities.d.ts.map +1 -1
- package/dist/data/entities.js +17 -1
- package/dist/data/entities.js.map +1 -1
- package/dist/data/index.d.ts +2 -0
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +2 -0
- package/dist/data/index.js.map +1 -1
- package/dist/data/types.d.ts +3 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/types.js +1 -0
- package/dist/data/types.js.map +1 -1
- package/dist/generated.d.ts +24 -0
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/import/import-roster.d.ts.map +1 -1
- package/dist/import/import-roster.js +5 -0
- package/dist/import/import-roster.js.map +1 -1
- package/dist/import/index.d.ts +1 -0
- package/dist/import/index.d.ts.map +1 -1
- package/dist/import/index.js +1 -0
- package/dist/import/index.js.map +1 -1
- package/dist/import/newrecruit-wtc.d.ts +5 -0
- package/dist/import/newrecruit-wtc.d.ts.map +1 -1
- package/dist/import/newrecruit-wtc.js +22 -1
- package/dist/import/newrecruit-wtc.js.map +1 -1
- package/dist/import/roster-json.d.ts +30 -0
- package/dist/import/roster-json.d.ts.map +1 -0
- package/dist/import/roster-json.js +88 -0
- package/dist/import/roster-json.js.map +1 -0
- package/dist/import/types.d.ts +1 -1
- package/dist/import/types.d.ts.map +1 -1
- package/dist/import/types.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/migrate-keystone-twins.d.ts +23 -0
- package/dist/migrate-keystone-twins.d.ts.map +1 -0
- package/dist/migrate-keystone-twins.js +184 -0
- package/dist/migrate-keystone-twins.js.map +1 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +61 -0
- package/dist/runner.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +1 -0
- package/dist/validate.js.map +1 -1
- package/package.json +1 -1
- package/schemas/core/roster.schema.json +2 -1
- package/schemas/core/target-profile.schema.json +30 -0
package/dist/codegen-data.js
CHANGED
|
@@ -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",
|
package/dist/codegen-data.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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,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,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 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,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 \"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"]}
|
|
@@ -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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"}
|
package/dist/compare.js
ADDED
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compare.js","sourceRoot":"","sources":["../src/compare.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,MAAM,EAAsB,MAAM,qBAAqB,CAAC;AAQjE,4EAA4E;AAC5E,MAAM,UAAU,yBAAyB,CAAC,KAAmB;IAC3D,OAAO,KAAK,KAAK,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC;AAChD,CAAC;AAuCD,MAAM,UAAU,SAAS,CAAC,IAAgB,EAAE,MAAqB;IAC/D,IAAI,MAAM,KAAK,YAAY;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC;IACnD,OAAO,IAAI,CAAC,IAAI,EAAE,aAAa,IAAI,CAAC,CAAC;AACvC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,EAAW,EAAE,OAAsB;IAC/D,MAAM,IAAI,GAAG,EAAE,CAAC,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IACxE,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,MAAM,QAAQ,GAAG,OAAO,CAAC,oBAAoB,IAAI,IAAI,CAAC;IACtD,MAAM,UAAU,GAAG,QAAQ,IAAI,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,CAAC;IAC9D,OAAO,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,EAAE,WAAW,EAAE,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,GAAG,EAAE,UAAU,EAAE,CAAC;AAC7F,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAC3B,EAAW,EACX,SAAiB,EACjB,YAAoB,EACpB,MAAsB,EACtB,KAAmB,EACnB,YAAoB,EACpB,eAAwB;IAExB,MAAM,GAAG,GAAkB,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC;IACtD,MAAM,KAAK,GAAG,EAAE,CAAC,iBAAiB,CAChC,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,CAAC,OAAO,CAAC,UAAU,EAAE,EACnE,GAAG,CACJ,CAAC;IACF,MAAM,GAAG,GAAG,MAAM,CAChB;QACE,QAAQ,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE;QAC7C,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE,YAAY,EAAE,CAAC,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE;QAChF,YAAY;QACZ,KAAK;QACL,OAAO,EAAE,GAAG;KACb,EACD,EAAE,CACH,CAAC;IACF,MAAM,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,eAAe,CAAC,CAAC;IACjE,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AACpC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC5B,EAAW,EACX,SAAiB,EACjB,YAAoB,EACpB,MAAsB,EACtB,KAAmB,EACnB,YAAoB,EACpB,eAAwB;IAExB,MAAM,GAAG,GAAkB,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC;IACtD,MAAM,KAAK,GAAG,EAAE,CAAC,iBAAiB,CAChC,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,CAAC,OAAO,CAAC,UAAU,EAAE,EACnE,GAAG,CACJ,CAAC;IACF,MAAM,GAAG,GAAG,MAAM,CAChB;QACE,QAAQ,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE;QAC7C,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE,YAAY,EAAE,CAAC,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE;QAChF,YAAY;QACZ,KAAK;QACL,OAAO,EAAE,GAAG;KACb,EACD,EAAE,CACH,CAAC;IACF,MAAM,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,WAAW,CAAC,CAAC;IAC7D,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;AACpC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAC1B,EAAW,EACX,YAAsB,EACtB,MAAsB,EACtB,QAAgB,EAChB,KAAmB,EACnB,YAAY,GAAG,CAAC;IAEhB,MAAM,QAAQ,GAAG,yBAAyB,CAAC,KAAK,CAAC,CAAC;IAClD,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,KAAK,MAAM,MAAM,IAAI,YAAY,CAAC,OAAO,EAAE,CAAC;QAC1C,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC;QACxB,IAAI,IAAI,CAAC,IAAI,KAAK,QAAQ;YAAE,SAAS;QACrC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,GAAG,EAAE,EAAE;YACtC,MAAM,GAAG,GAAG,QAAQ,CAAC,KAAK,CAAC;YAC3B,MAAM,QAAQ,GAAG,OAAO,GAAG,KAAK,QAAQ,CAAC;YACzC,MAAM,OAAO,GAAG,CAAC,QAAQ,IAAK,GAAc,IAAI,QAAQ,CAAC;YACzD,MAAM,UAAU,GAAG,QAAQ,IAAI,QAAQ,IAAK,GAAc,GAAG,CAAC,CAAC;YAC/D,MAAM,KAAK,GAAG,OAAO;gBACnB,CAAC,CAAC,aAAa,CAAC,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,EAAE,UAAU,CAAC;gBACvE,CAAC,CAAC,CAAC,CAAC;YACN,OAAO,CAAC,IAAI,CAAC;gBACX,QAAQ,EAAE,IAAI,CAAC,EAAE;gBACjB,UAAU,EAAE,MAAM,CAAC,IAAI;gBACvB,YAAY,EAAE,GAAG;gBACjB,WAAW,EAAE,QAAQ,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI;gBACzC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAE,GAAc,CAAC,CAAC,CAAC,IAAI;gBACxC,OAAO;gBACP,eAAe,EAAE,UAAU;gBAC3B,aAAa,EAAE,KAAK;aACrB,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC;IACD,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IAClD,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAC1B,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,KAAK,IAAI,IAAI,CAAC,CAAC,aAAa,GAAG,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAC3E,IAAI,CACL,CAAC;IACF,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC;IACxE,OAAO,EAAE,eAAe,EAAE,MAAM,CAAC,SAAS,EAAE,iBAAiB,EAAE,MAAM,CAAC,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC;AAChH,CAAC;AAUD;;;;GAIG;AACH,MAAM,UAAU,WAAW,CACzB,EAAW,EACX,IASC;IAED,MAAM,OAAO,GAAG,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;IAC5D,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,IAAI,CAAC,eAAe,EAAE,CAAC,CAAC;IACjF,MAAM,MAAM,GAAG,aAAa,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;IAC1C,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CACb,kBAAkB,IAAI,CAAC,eAAe,4BAA4B,OAAO,CAAC,OAAO,EAAE,CACpF,CAAC;IACJ,CAAC;IACD,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC7C,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,mBAAmB,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IACjE,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC;IACxB,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,YAAY,CAAC,EAAE,KAAK,CAAC;IACpD,MAAM,QAAQ,GAAG,OAAO,GAAG,KAAK,QAAQ,CAAC;IACzC,MAAM,OAAO,GAAG,CAAC,QAAQ,IAAK,GAAc,IAAI,IAAI,CAAC,QAAQ,CAAC;IAC9D,MAAM,eAAe,GAAG,QAAQ,IAAI,IAAI,CAAC,QAAQ,IAAK,GAAc,GAAG,CAAC,CAAC;IACzE,MAAM,KAAK,GAAG,OAAO;QACnB,CAAC,CAAC,aAAa,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,YAAY,IAAI,CAAC,EAAE,eAAe,CAAC;QACzG,CAAC,CAAC,CAAC,CAAC;IACN,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,CAAC,UAAU,EAAE,CAAC;AAC3F,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,WAAW,CACzB,EAAW,EACX,aAAyB,EACzB,OAAyB,EACzB,QAAgB,EAChB,KAAmB,EACnB,MAAqB,EACrB,YAAY,GAAG,CAAC;IAEhB,MAAM,IAAI,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;QACtC,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC;QAC3F,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAC1E,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAC/D,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC;AAC9C,CAAC;AAyCD;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAC3B,EAAW,EACX,MAAqB,EACrB,MAAsB,EACtB,QAAgB,EAChB,KAAmB;IAEnB,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QAChC,MAAM,MAAM,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC7C,IAAI,CAAC,MAAM;YAAE,SAAS;QACtB,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,IAAI,CAAC,CAAC;QAC5C,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,KAAK,CAAC;QACrD,MAAM,QAAQ,GAAG,OAAO,GAAG,KAAK,QAAQ,CAAC;QACzC,IAAI,QAAQ,IAAK,GAAc,GAAG,QAAQ;YAAE,SAAS,CAAC,eAAe;QACrE,MAAM,UAAU,GAAG,QAAQ,IAAI,QAAQ,IAAK,GAAc,GAAG,CAAC,CAAC;QAC/D,MAAM,IAAI,cAAc,CAAC,EAAE,EAAE,MAAM,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;IAChG,CAAC;IACD,MAAM,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;IAC7C,MAAM,KAAK,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAClE,OAAO;QACL,eAAe,EAAE,MAAM,CAAC,SAAS;QACjC,iBAAiB,EAAE,MAAM,CAAC,WAAW;QACrC,MAAM;QACN,KAAK;KACN,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAC1B,EAAW,EACX,OAAwB,EACxB,OAAyB,EACzB,QAAgB,EAChB,KAAmB,EACnB,YAAqB;IAErB,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE;QACpC,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC;QAClF,MAAM,KAAK,GAAG,YAAY;YACxB,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,eAAe,KAAK,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC,CAAC;YACxE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QAClD,MAAM,iBAAiB,GACrB,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;QAC5E,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC;IACvD,CAAC,CAAC,CAAC;IACH,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;AAClD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CACzB,EAAW,EACX,IAKC;IAED,MAAM,OAAO,GAAG,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;IAC5D,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,IAAI,CAAC,eAAe,EAAE,CAAC,CAAC;IACjF,MAAM,MAAM,GAAG,aAAa,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;IAC1C,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,KAAK,CAAC,kBAAkB,IAAI,CAAC,eAAe,4BAA4B,CAAC,CAAC;IACjG,MAAM,GAAG,GAAG,aAAa,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IACnG,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC;AAClD,CAAC;AAYD,SAAS,eAAe,CAAC,GAAW,EAAE,SAAsB;IAC1D,IAAI,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,GAAG,CAAC;IACnC,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAClF,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAC/B,EAAW,EACX,SAAiB,EACjB,MAAc;IAEd,MAAM,IAAI,GAAG,EAAE,CAAC,KAAK,CAAC,YAAY,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACtD,IAAI,CAAC,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,MAAM,yBAAyB,SAAS,EAAE,CAAC,CAAC;IAC/E,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAC/D,MAAM,QAAQ,GAAG,CAAC,GAAW,EAAW,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC;IACtF,MAAM,UAAU,GAAG;QACjB,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;KACrF,CAAC,IAAI,EAAE,CAAC;IACT,MAAM,WAAW,GAAa,EAAE,CAAC;IAEjC,0EAA0E;IAC1E,+EAA+E;IAC/E,MAAM,eAAe,GAAG,IAAI,GAAG,EAAsB,CAAC;IACtD,KAAK,MAAM,GAAG,IAAI,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QAChD,MAAM,GAAG,GAAG,GAAG,CAAC,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAC1C,MAAM,UAAU,GAAe,EAAE,CAAC;QAClC,MAAM,CAAC,GAAG,GAAuF,CAAC;QAClG,IAAI,CAAC,CAAC,QAAQ;YAAE,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC5C,IAAI,CAAC,CAAC,WAAW;YAAE,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;QAClD,KAAK,MAAM,EAAE,IAAI,CAAC,CAAC,kBAAkB,IAAI,EAAE;YAAE,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACjE,MAAM,MAAM,GAAG,eAAe,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9C,KAAK,MAAM,EAAE,IAAI,UAAU,EAAE,CAAC;YAC5B,MAAM,IAAI,GAAa,EAAE,CAAC;YAC1B,KAAK,MAAM,GAAG,IAAI,EAAE,EAAE,CAAC;gBACrB,MAAM,GAAG,GAAG,eAAe,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;gBAC5C,IAAI,GAAG,KAAK,IAAI;oBAAE,WAAW,CAAC,IAAI,CAAC,UAAU,GAAG,oBAAoB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;qBACtF,IAAI,QAAQ,CAAC,GAAG,CAAC;oBAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACzC,CAAC;YACD,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;gBAChB,MAAM,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;gBACvC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC;oBAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAC9E,CAAC;QACH,CAAC;QACD,eAAe,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACnC,CAAC;IAED,MAAM,IAAI,GAAG,CAAC,GAAG,eAAe,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACvE,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1C,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAE5D,IAAI,MAAM,GAAe,CAAC,KAAK,CAAC,CAAC;IACjC,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;QACxB,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,GAAG,KAAK,EAAE,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IAClF,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,GAAG,EAAyB,CAAC;IACjD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,IAAI,CAAC;IAClD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC3C,IAAI,CAAC,OAAO,CAAC,MAAM;YAAE,SAAS;QAC9B,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC3E,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACxB,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE;gBACjB,KAAK;gBACL,MAAM;gBACN,KAAK,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAC;aACvD,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,GAAG,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;AAC/G,CAAC;AAED,yDAAyD;AACzD,MAAM,UAAU,gBAAgB,CAC9B,IAAiB,EACjB,OAAyB,EACzB,MAAqB;IAErB,MAAM,OAAO,GAAG,CAAC,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC;IAC/D,MAAM,KAAK,GAAG,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACxF,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;YAChC,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;YAC/B,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;YACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;QACvC,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC","sourcesContent":["/**\n * Fleet comparison: expected kills of attacker units against target-profile\n * archetypes. The cross product — a set of attacker units against a set of\n * targets — yielding a matrix of expected models killed per (unit, target).\n *\n * Target profiles reference real dataset units (`faction_id` + `unit_id`), so\n * each cell resolves the live unit and feeds it straight to {@link crunch}; the\n * target's defensive abilities (FNP, ability invulns, save/toughness mods) are\n * resolved via {@link Dataset.defensiveBuffsFor} and stacked onto the crunch.\n * Rapid Fire / Melta come for free: each cell sets `withinHalfRange` from\n * `distance <= range / 2`.\n *\n * Python mirror: `python/src/wh40kdc/compare.py`. The per-cell `expectedKills`\n * is pinned against Python by the `compare` conformance area.\n *\n * @packageDocumentation\n */\nimport { crunch, type EngineContext } from \"./cruncher/index.js\";\nimport type { Dataset } from \"./data/dataset.js\";\nimport type { UnitView } from \"./data/entities.js\";\nimport type { TargetProfile, Unit, Weapon } from \"./generated.js\";\n\nexport type ComparePhase = \"shooting\" | \"fight\";\nexport type CompareMetric = \"best-weapon\" | \"unit-total\";\n\n/** Which weapon type fires in a phase: shooting → ranged, fight → melee. */\nexport function weaponTypeForComparePhase(phase: ComparePhase): \"ranged\" | \"melee\" {\n return phase === \"fight\" ? \"melee\" : \"ranged\";\n}\n\n/** One attacker weapon profile evaluated against one target. */\nexport interface WeaponCell {\n weaponId: string;\n weaponName: string;\n profileIndex: number;\n profileName: string;\n range: number | null;\n reaches: boolean;\n withinHalfRange: boolean;\n expectedKills: number;\n}\n\n/** One (attacker unit, target profile) pair across all the unit's weapons. */\nexport interface MatrixCell {\n targetProfileId: string;\n targetProfileName: string;\n best: WeaponCell | null;\n unitTotal: number;\n weapons: WeaponCell[];\n}\n\nexport interface MatrixRow {\n unitId: string;\n unitName: string;\n cells: MatrixCell[];\n /** Peak cell value across targets — the row's sort key. */\n peak: number;\n}\n\n/** A target profile resolved to its live dataset unit. */\nexport interface ResolvedTarget {\n profileId: string;\n profileName: string;\n unitRaw: Unit;\n modelCount: number;\n}\n\nexport function cellValue(cell: MatrixCell, metric: CompareMetric): number {\n if (metric === \"unit-total\") return cell.unitTotal;\n return cell.best?.expectedKills ?? 0;\n}\n\n/**\n * Resolve a target profile to its referenced unit, faction-scoped (shared ids\n * like Rhino/Forgefiend appear under several factions). Returns null if the\n * referenced unit is absent.\n */\nexport function resolveTarget(ds: Dataset, profile: TargetProfile): ResolvedTarget | null {\n const unit = ds.units.getInFaction(profile.unit_id, profile.faction_id);\n if (!unit) return null;\n const override = profile.model_count_override ?? null;\n const modelCount = override ?? unit.raw.model_count?.min ?? 1;\n return { profileId: profile.id, profileName: profile.name, unitRaw: unit.raw, modelCount };\n}\n\n/**\n * Expected models killed for one (weapon profile, target) at a distance. The\n * single unit of computation the matrix and the conformance corpus share.\n */\nexport function expectedKills(\n ds: Dataset,\n weaponRaw: Weapon,\n profileIndex: number,\n target: ResolvedTarget,\n phase: ComparePhase,\n modelsFiring: number,\n withinHalfRange: boolean,\n): number {\n const ctx: EngineContext = { phase, withinHalfRange };\n const buffs = ds.defensiveBuffsFor(\n { unitId: target.unitRaw.id, factionId: target.unitRaw.faction_id },\n ctx,\n );\n const out = crunch(\n {\n attacker: { weapon: weaponRaw, profileIndex },\n target: { unit: target.unitRaw, profileIndex: 0, modelCount: target.modelCount },\n modelsFiring,\n buffs,\n context: ctx,\n },\n ds,\n );\n const stage = out.stages.find((s) => s.name === \"models-killed\");\n return stage ? stage.expected : 0;\n}\n\n/**\n * Expected post-FNP damage (wounds dealt) for one weapon profile against a\n * target. The summable counterpart to {@link expectedKills}: damage adds\n * linearly across weapons, so a unit's total output is built by summing this\n * over its loadout and converting to kills once — never by summing per-weapon\n * kills (each weapon's models-killed caps independently, which over-counts).\n */\nexport function expectedDamage(\n ds: Dataset,\n weaponRaw: Weapon,\n profileIndex: number,\n target: ResolvedTarget,\n phase: ComparePhase,\n modelsFiring: number,\n withinHalfRange: boolean,\n): number {\n const ctx: EngineContext = { phase, withinHalfRange };\n const buffs = ds.defensiveBuffsFor(\n { unitId: target.unitRaw.id, factionId: target.unitRaw.faction_id },\n ctx,\n );\n const out = crunch(\n {\n attacker: { weapon: weaponRaw, profileIndex },\n target: { unit: target.unitRaw, profileIndex: 0, modelCount: target.modelCount },\n modelsFiring,\n buffs,\n context: ctx,\n },\n ds,\n );\n const stage = out.stages.find((s) => s.name === \"after-fnp\");\n return stage ? stage.expected : 0;\n}\n\n/**\n * Evaluate every phase-appropriate weapon profile of one attacker unit against\n * one target, returning per-weapon results plus the best.\n */\nexport function unitVsTarget(\n ds: Dataset,\n attackerUnit: UnitView,\n target: ResolvedTarget,\n distance: number,\n phase: ComparePhase,\n modelsFiring = 1,\n): MatrixCell {\n const wantType = weaponTypeForComparePhase(phase);\n const weapons: WeaponCell[] = [];\n for (const weapon of attackerUnit.weapons) {\n const wraw = weapon.raw;\n if (wraw.type !== wantType) continue;\n wraw.profiles.forEach((wprofile, idx) => {\n const rng = wprofile.range;\n const isRanged = typeof rng === \"number\";\n const reaches = !isRanged || (rng as number) >= distance;\n const withinHalf = isRanged && distance <= (rng as number) / 2;\n const kills = reaches\n ? expectedKills(ds, wraw, idx, target, phase, modelsFiring, withinHalf)\n : 0;\n weapons.push({\n weaponId: wraw.id,\n weaponName: weapon.name,\n profileIndex: idx,\n profileName: wprofile.name ?? weapon.name,\n range: isRanged ? (rng as number) : null,\n reaches,\n withinHalfRange: withinHalf,\n expectedKills: kills,\n });\n });\n }\n const reaching = weapons.filter((w) => w.reaches);\n const best = reaching.reduce<WeaponCell | null>(\n (acc, w) => (acc === null || w.expectedKills > acc.expectedKills ? w : acc),\n null,\n );\n const unitTotal = reaching.reduce((sum, w) => sum + w.expectedKills, 0);\n return { targetProfileId: target.profileId, targetProfileName: target.profileName, best, unitTotal, weapons };\n}\n\n/** Result of evaluating one (attacker weapon profile, target profile) cell. */\nexport interface CompareCell {\n expectedKills: number;\n reaches: boolean;\n withinHalfRange: boolean;\n modelCount: number;\n}\n\n/**\n * Evaluate one (attacker weapon profile, target profile) cell — the single-cell\n * entry point shared by the conformance runner and consumers. Throws on unknown\n * profile/weapon ids so the runner can map them to the closed error enum.\n */\nexport function compareCell(\n ds: Dataset,\n opts: {\n factionId: string;\n unitId: string;\n weaponId: string;\n profileIndex: number;\n targetProfileId: string;\n distance: number;\n phase: ComparePhase;\n modelsFiring?: number;\n },\n): CompareCell {\n const profile = ds.targetProfiles.get(opts.targetProfileId);\n if (!profile) throw new Error(`unknown target profile: ${opts.targetProfileId}`);\n const target = resolveTarget(ds, profile);\n if (!target) {\n throw new Error(\n `target profile ${opts.targetProfileId} references missing unit ${profile.unit_id}`,\n );\n }\n const weapon = ds.weapons.get(opts.weaponId);\n if (!weapon) throw new Error(`unknown weapon: ${opts.weaponId}`);\n const wraw = weapon.raw;\n const rng = wraw.profiles[opts.profileIndex]?.range;\n const isRanged = typeof rng === \"number\";\n const reaches = !isRanged || (rng as number) >= opts.distance;\n const withinHalfRange = isRanged && opts.distance <= (rng as number) / 2;\n const kills = reaches\n ? expectedKills(ds, wraw, opts.profileIndex, target, opts.phase, opts.modelsFiring ?? 1, withinHalfRange)\n : 0;\n return { expectedKills: kills, reaches, withinHalfRange, modelCount: target.modelCount };\n}\n\n/** The cross product: one row per attacker unit, one column per target. */\nexport function buildMatrix(\n ds: Dataset,\n attackerUnits: UnitView[],\n targets: ResolvedTarget[],\n distance: number,\n phase: ComparePhase,\n metric: CompareMetric,\n modelsFiring = 1,\n): MatrixRow[] {\n const rows = attackerUnits.map((unit) => {\n const cells = targets.map((t) => unitVsTarget(ds, unit, t, distance, phase, modelsFiring));\n const peak = cells.reduce((m, c) => Math.max(m, cellValue(c, metric)), 0);\n return { unitId: unit.id, unitName: unit.name, cells, peak };\n });\n return rows.sort((a, b) => b.peak - a.peak);\n}\n\n// ---------------------------------------------------------------------------\n// Loadout ranking — \"what's the best shooting loadout for this unit/slot?\"\n// ---------------------------------------------------------------------------\n\n/** One weapon in a loadout: `count` copies of `weaponId` profile `profileIndex`. */\nexport interface LoadoutLine {\n weaponId: string;\n profileIndex?: number;\n count: number;\n}\n\n/** A named weapon configuration to evaluate. `points` enables per-point ranking\n * and lets you compare unit *choices* (one Defiler vs two Forgefiends) on equal\n * footing — a config can span multiple bodies (just list all their weapons). */\nexport interface LoadoutConfig {\n label: string;\n points?: number | null;\n lines: LoadoutLine[];\n}\n\nexport interface LoadoutTargetResult {\n targetProfileId: string;\n targetProfileName: string;\n /** Total post-FNP wounds the whole config deals to this target. */\n damage: number;\n /** Models killed = min(modelCount, damage / W) — capped once, after summing. */\n kills: number;\n}\n\nexport interface LoadoutRanking {\n config: LoadoutConfig;\n results: LoadoutTargetResult[];\n /** The metric the configs were ranked by (damage against the rank target, or\n * summed across all targets when no rank target is given). */\n score: number;\n /** `score` per 100 points, or null when the config carries no points. */\n scorePer100Points: number | null;\n}\n\n/**\n * Total a single loadout against one target, summing post-FNP damage across all\n * weapon lines (each fired by `count` models) and converting to kills *once*.\n * A line whose weapon can't reach `distance` contributes nothing.\n */\nexport function loadoutOutput(\n ds: Dataset,\n config: LoadoutConfig,\n target: ResolvedTarget,\n distance: number,\n phase: ComparePhase,\n): LoadoutTargetResult {\n let damage = 0;\n for (const line of config.lines) {\n const weapon = ds.weapons.get(line.weaponId);\n if (!weapon) continue;\n const profileIndex = line.profileIndex ?? 0;\n const rng = weapon.raw.profiles[profileIndex]?.range;\n const isRanged = typeof rng === \"number\";\n if (isRanged && (rng as number) < distance) continue; // out of range\n const withinHalf = isRanged && distance <= (rng as number) / 2;\n damage += expectedDamage(ds, weapon.raw, profileIndex, target, phase, line.count, withinHalf);\n }\n const w = target.unitRaw.profiles[0]?.W ?? 1;\n const kills = w > 0 ? Math.min(target.modelCount, damage / w) : 0;\n return {\n targetProfileId: target.profileId,\n targetProfileName: target.profileName,\n damage,\n kills,\n };\n}\n\n/**\n * Rank loadout configs against a set of targets. Each config is scored by its\n * damage against `rankTargetId` (or summed across all targets when omitted);\n * results are sorted high-to-low, with `scorePer100Points` for points-aware\n * comparison across unit choices.\n */\nexport function rankLoadouts(\n ds: Dataset,\n configs: LoadoutConfig[],\n targets: ResolvedTarget[],\n distance: number,\n phase: ComparePhase,\n rankTargetId?: string,\n): LoadoutRanking[] {\n const ranked = configs.map((config) => {\n const results = targets.map((t) => loadoutOutput(ds, config, t, distance, phase));\n const score = rankTargetId\n ? (results.find((r) => r.targetProfileId === rankTargetId)?.damage ?? 0)\n : results.reduce((sum, r) => sum + r.damage, 0);\n const scorePer100Points =\n config.points && config.points > 0 ? (score / config.points) * 100 : null;\n return { config, results, score, scorePer100Points };\n });\n return ranked.sort((a, b) => b.score - a.score);\n}\n\n/**\n * Evaluate one loadout against one target by ids — the single-call entry point\n * for the conformance runner. Throws on unknown profile so the runner maps it\n * to the closed error enum.\n */\nexport function loadoutCell(\n ds: Dataset,\n opts: {\n lines: LoadoutLine[];\n targetProfileId: string;\n distance: number;\n phase: ComparePhase;\n },\n): { damage: number; kills: number } {\n const profile = ds.targetProfiles.get(opts.targetProfileId);\n if (!profile) throw new Error(`unknown target profile: ${opts.targetProfileId}`);\n const target = resolveTarget(ds, profile);\n if (!target) throw new Error(`target profile ${opts.targetProfileId} references a missing unit`);\n const res = loadoutOutput(ds, { label: \"\", lines: opts.lines }, target, opts.distance, opts.phase);\n return { damage: res.damage, kills: res.kills };\n}\n\n/** Result of deriving a unit's shooting-loadout space from its wargear. */\nexport interface EnumeratedLoadouts {\n configs: LoadoutConfig[];\n diagnostics: string[];\n /** Always false: the dataset doesn't record per-model weapon counts, so every\n * line defaults to count=1. Configs are reliable for *relative* slot ranking;\n * absolute totals need counts supplied by hand. */\n countsKnown: boolean;\n}\n\nfunction resolveWeaponId(ref: string, weaponIds: Set<string>): string | null {\n if (weaponIds.has(ref)) return ref;\n if (ref.endsWith(\"s\") && weaponIds.has(ref.slice(0, -1))) return ref.slice(0, -1);\n return null;\n}\n\n/**\n * Derive the shooting-loadout choice space for a unit from its wargear options:\n * each mutually-exclusive ranged-weapon swap (grouped by option id) is an axis,\n * and the cross product yields candidate configs (count=1 per weapon). Mirror\n * of the Python `enumerate_loadouts`. Heuristic, not spec-pinned — it reflects\n * the (imperfect) wargear data and conservatively drops refs it can't resolve.\n */\nexport function enumerateLoadouts(\n ds: Dataset,\n factionId: string,\n unitId: string,\n): EnumeratedLoadouts {\n const unit = ds.units.getInFaction(unitId, factionId);\n if (!unit) throw new Error(`unit ${unitId} not found in faction ${factionId}`);\n const weaponIds = new Set(ds.weapons.all.map((w) => w.raw.id));\n const isRanged = (wid: string): boolean => ds.weapons.get(wid)?.raw.type === \"ranged\";\n const baseRanged = [\n ...new Set(unit.weapons.filter((w) => w.raw.type === \"ranged\").map((w) => w.raw.id)),\n ].sort();\n const diagnostics: string[] = [];\n\n // Group choices by option id so duplicate rows of one physical slot union\n // into a single exclusive axis (rather than letting two of its options stack).\n const choicesByOption = new Map<string, string[][]>();\n for (const opt of ds.wargearOptionsOf(unit.raw)) {\n const oid = opt.id ?? JSON.stringify(opt);\n const rawChoices: string[][] = [];\n const o = opt as { replaces?: string[]; replacement?: string[]; replacement_choice?: string[][] };\n if (o.replaces) rawChoices.push(o.replaces);\n if (o.replacement) rawChoices.push(o.replacement);\n for (const ch of o.replacement_choice ?? []) rawChoices.push(ch);\n const bucket = choicesByOption.get(oid) ?? [];\n for (const ch of rawChoices) {\n const rids: string[] = [];\n for (const ref of ch) {\n const wid = resolveWeaponId(ref, weaponIds);\n if (wid === null) diagnostics.push(`option ${oid}: unresolved ref ${JSON.stringify(ref)}`);\n else if (isRanged(wid)) rids.push(wid);\n }\n if (rids.length) {\n const key = [...rids].sort().join(\"|\");\n if (!bucket.some((b) => [...b].sort().join(\"|\") === key)) bucket.push(rids);\n }\n }\n choicesByOption.set(oid, bucket);\n }\n\n const axes = [...choicesByOption.values()].filter((c) => c.length > 1);\n const swapMembers = new Set(axes.flat(2));\n const fixed = baseRanged.filter((w) => !swapMembers.has(w));\n\n let combos: string[][] = [fixed];\n for (const axis of axes) {\n combos = combos.flatMap((combo) => axis.map((choice) => [...combo, ...choice]));\n }\n\n const byLabel = new Map<string, LoadoutConfig>();\n const points = unit.raw.points?.[0]?.cost ?? null;\n for (const combo of combos) {\n const weapons = [...new Set(combo)].sort();\n if (!weapons.length) continue;\n const label = weapons.map((w) => ds.weapons.get(w)?.name ?? w).join(\" + \");\n if (!byLabel.has(label)) {\n byLabel.set(label, {\n label,\n points,\n lines: weapons.map((w) => ({ weaponId: w, count: 1 })),\n });\n }\n }\n return { configs: [...byLabel.values()], diagnostics: [...new Set(diagnostics)].sort(), countsKnown: false };\n}\n\n/** Render a matrix as a Discord-paste markdown table. */\nexport function matrixToMarkdown(\n rows: MatrixRow[],\n targets: ResolvedTarget[],\n metric: CompareMetric,\n): string {\n const headers = [\"Unit\", ...targets.map((t) => t.profileName)];\n const lines = [`| ${headers.join(\" | \")} |`, `|${headers.map(() => \"---\").join(\"|\")}|`];\n for (const row of rows) {\n const cells = row.cells.map((c) => {\n const v = cellValue(c, metric);\n const text = v ? v.toFixed(2) : \"—\";\n return v >= 1 ? `**${text}**` : text;\n });\n lines.push(`| ${[row.unitName, ...cells].join(\" | \")} |`);\n }\n return lines.join(\"\\n\");\n}\n"]}
|
package/dist/cruncher/buffs.d.ts
CHANGED
|
@@ -125,6 +125,13 @@ export type BuffApplicability = {
|
|
|
125
125
|
requiresTargetKeyword?: string;
|
|
126
126
|
/** Attacker must carry this keyword (case-insensitive). */
|
|
127
127
|
requiresAttackerKeyword?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Range-gated abilities (DSL `scope.range_inches`, e.g. a "within 18\"" reroll):
|
|
130
|
+
* the buff applies only when the target is within this many inches. Gate is
|
|
131
|
+
* permissive when the caller leaves `EngineContext.distanceInches` undefined,
|
|
132
|
+
* so callers that don't track distance keep their current behavior.
|
|
133
|
+
*/
|
|
134
|
+
maxRangeInches?: number;
|
|
128
135
|
};
|
|
129
136
|
/** A single buff: where it came from, when it applies, what it contributes. */
|
|
130
137
|
export type Buff = {
|
|
@@ -151,6 +158,12 @@ export type EngineContext = {
|
|
|
151
158
|
attackerCharged?: boolean;
|
|
152
159
|
/** Within half the weapon's range — Melta / Rapid Fire fire. */
|
|
153
160
|
withinHalfRange?: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Distance to the target in inches. Drives `applicableWhen.maxRangeInches`
|
|
163
|
+
* for range-gated abilities. Undefined when the caller doesn't track distance
|
|
164
|
+
* — range gates then evaluate permissively (the buff applies).
|
|
165
|
+
*/
|
|
166
|
+
distanceInches?: number;
|
|
154
167
|
/** Attacker benefits from cover (mostly informational; cover applies to defenders). */
|
|
155
168
|
attackerInCover?: boolean;
|
|
156
169
|
/** Target is in cover — the resolver flips on `cover`, the engine applies +1 to save. */
|