@alpaca-software/40kdc-data 0.5.5 → 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.
Files changed (64) hide show
  1. package/dist/codegen-data.js +1 -0
  2. package/dist/codegen-data.js.map +1 -1
  3. package/dist/compare.d.ts +167 -0
  4. package/dist/compare.d.ts.map +1 -0
  5. package/dist/compare.js +302 -0
  6. package/dist/compare.js.map +1 -0
  7. package/dist/cruncher/buffs.d.ts +13 -0
  8. package/dist/cruncher/buffs.d.ts.map +1 -1
  9. package/dist/cruncher/buffs.js +7 -0
  10. package/dist/cruncher/buffs.js.map +1 -1
  11. package/dist/cruncher/engine.d.ts.map +1 -1
  12. package/dist/cruncher/engine.js +14 -10
  13. package/dist/cruncher/engine.js.map +1 -1
  14. package/dist/data/bundle.generated.js +1 -1
  15. package/dist/data/bundle.generated.js.map +1 -1
  16. package/dist/data/dataset.d.ts +2 -1
  17. package/dist/data/dataset.d.ts.map +1 -1
  18. package/dist/data/dataset.js +2 -0
  19. package/dist/data/dataset.js.map +1 -1
  20. package/dist/data/entities.d.ts.map +1 -1
  21. package/dist/data/entities.js +17 -1
  22. package/dist/data/entities.js.map +1 -1
  23. package/dist/data/index.d.ts +2 -0
  24. package/dist/data/index.d.ts.map +1 -1
  25. package/dist/data/index.js +2 -0
  26. package/dist/data/index.js.map +1 -1
  27. package/dist/data/types.d.ts +3 -1
  28. package/dist/data/types.d.ts.map +1 -1
  29. package/dist/data/types.js +1 -0
  30. package/dist/data/types.js.map +1 -1
  31. package/dist/generated.d.ts +24 -0
  32. package/dist/generated.d.ts.map +1 -1
  33. package/dist/generated.js.map +1 -1
  34. package/dist/import/import-roster.d.ts.map +1 -1
  35. package/dist/import/import-roster.js +5 -0
  36. package/dist/import/import-roster.js.map +1 -1
  37. package/dist/import/index.d.ts +1 -0
  38. package/dist/import/index.d.ts.map +1 -1
  39. package/dist/import/index.js +1 -0
  40. package/dist/import/index.js.map +1 -1
  41. package/dist/import/roster-json.d.ts +30 -0
  42. package/dist/import/roster-json.d.ts.map +1 -0
  43. package/dist/import/roster-json.js +88 -0
  44. package/dist/import/roster-json.js.map +1 -0
  45. package/dist/import/types.d.ts +1 -1
  46. package/dist/import/types.d.ts.map +1 -1
  47. package/dist/import/types.js.map +1 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/migrate-keystone-twins.d.ts +23 -0
  53. package/dist/migrate-keystone-twins.d.ts.map +1 -0
  54. package/dist/migrate-keystone-twins.js +184 -0
  55. package/dist/migrate-keystone-twins.js.map +1 -0
  56. package/dist/runner.d.ts.map +1 -1
  57. package/dist/runner.js +61 -0
  58. package/dist/runner.js.map +1 -1
  59. package/dist/validate.d.ts.map +1 -1
  60. package/dist/validate.js +1 -0
  61. package/dist/validate.js.map +1 -1
  62. package/package.json +1 -1
  63. package/schemas/core/roster.schema.json +2 -1
  64. package/schemas/core/target-profile.schema.json +30 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Canonical roster-json adapter: re-import a 40kdc {@link Roster} export.
3
+ *
4
+ * The exporter's `roster-json` format (see `export/roster-json.ts`) is the
5
+ * lossless pivot — exactly `roster.schema.json` shape. This adapter closes the
6
+ * loop so a 40kdc-native export round-trips through the normal
7
+ * `tryImportRoster` pipeline: validate the canonical envelope, lower it to the
8
+ * format-agnostic {@link ParsedRoster}, and let `resolve` re-derive ids
9
+ * against the *current* dataset (so a stored export keeps resolving across
10
+ * dataset releases, and stale ids self-heal through name resolution).
11
+ *
12
+ * Lowering notes:
13
+ * - Unit/wargear/enhancement rows lower to their `ref.raw_name` — the same
14
+ * raw-display-name path every other adapter takes.
15
+ * - `faction_id`/`detachment_id` have no raw name in the canonical shape, so
16
+ * the id slug is passed through as the raw name; collection lookup does an
17
+ * exact-id match before any name lookup, so resolution is exact.
18
+ * - `is_character` isn't stored on the canonical shape (it's an inference
19
+ * input, not an output). It lowers as `leader_attachment != null`, which
20
+ * reproduces the original (deterministic) attachment inference on
21
+ * re-import. Attachments are always provisional either way.
22
+ *
23
+ * **IP safety**: the canonical document carries only permitted facts (names,
24
+ * counts, points, ids); no prose fields exist to read.
25
+ *
26
+ * TS mirror of `crates/wh40kdc/src/import/roster_json.rs`.
27
+ */
28
+ function isRecord(v) {
29
+ return typeof v === "object" && v !== null && !Array.isArray(v);
30
+ }
31
+ /** Battle-size labels `resolve` maps back to the enum. */
32
+ const BATTLE_SIZE_LABELS = {
33
+ incursion: "Incursion",
34
+ "strike-force": "Strike Force",
35
+ };
36
+ export const rosterJsonAdapter = {
37
+ id: "roster-json",
38
+ /**
39
+ * The canonical shape is unmistakable: a `source.format` discriminator plus
40
+ * the `game_version` + `diagnostics` envelope no external builder emits.
41
+ * All three are required by `roster.schema.json`.
42
+ */
43
+ matches(decoded) {
44
+ if (!isRecord(decoded))
45
+ return false;
46
+ const source = decoded["source"];
47
+ const gameVersion = decoded["game_version"];
48
+ return (isRecord(source) &&
49
+ typeof source["format"] === "string" &&
50
+ isRecord(gameVersion) &&
51
+ typeof gameVersion["edition"] === "string" &&
52
+ isRecord(decoded["diagnostics"]) &&
53
+ Array.isArray(decoded["units"]));
54
+ },
55
+ parse(decoded) {
56
+ // matches() vetted the envelope; the unit rows are schema-shaped.
57
+ const roster = decoded;
58
+ const units = roster.units.map((u) => ({
59
+ raw_name: u.ref.raw_name,
60
+ // Not stored canonically; attached units were characters, and re-running
61
+ // the (deterministic) inference over them reproduces the exported
62
+ // attachments. See module docs.
63
+ is_character: u.leader_attachment != null,
64
+ model_count: u.model_count,
65
+ points: u.points,
66
+ is_warlord: u.is_warlord,
67
+ enhancement_raw_name: u.enhancement?.raw_name ?? null,
68
+ enhancement_points: u.enhancement_points,
69
+ wargear: u.wargear.map((w) => ({ raw_name: w.ref.raw_name, count: w.count })),
70
+ }));
71
+ return {
72
+ name: roster.name,
73
+ generated_by: roster.source.generated_by,
74
+ // Id slugs pass through as raw names — collection lookup id-matches
75
+ // exactly before any name lookup.
76
+ faction_raw_name: roster.faction_id,
77
+ detachment_raw_name: roster.detachment_id,
78
+ battle_size_raw: roster.battle_size != null ? (BATTLE_SIZE_LABELS[roster.battle_size] ?? null) : null,
79
+ declared_limit: roster.points.declared_limit,
80
+ total_reported: roster.points.total_reported,
81
+ total_computed: roster.points.total_computed,
82
+ units,
83
+ // The canonical shape carries a single primary faction.
84
+ multi_force: false,
85
+ };
86
+ },
87
+ };
88
+ //# sourceMappingURL=roster-json.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"roster-json.js","sourceRoot":"","sources":["../../src/import/roster-json.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,SAAS,QAAQ,CAAC,CAAU;IAC1B,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAClE,CAAC;AAED,0DAA0D;AAC1D,MAAM,kBAAkB,GAA2B;IACjD,SAAS,EAAE,WAAW;IACtB,cAAc,EAAE,cAAc;CAC/B,CAAC;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAkB;IAC9C,EAAE,EAAE,aAAa;IAEjB;;;;OAIG;IACH,OAAO,CAAC,OAAgB;QACtB,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC;YAAE,OAAO,KAAK,CAAC;QACrC,MAAM,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;QACjC,MAAM,WAAW,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;QAC5C,OAAO,CACL,QAAQ,CAAC,MAAM,CAAC;YAChB,OAAO,MAAM,CAAC,QAAQ,CAAC,KAAK,QAAQ;YACpC,QAAQ,CAAC,WAAW,CAAC;YACrB,OAAO,WAAW,CAAC,SAAS,CAAC,KAAK,QAAQ;YAC1C,QAAQ,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;YAChC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAChC,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,OAAgB;QACpB,kEAAkE;QAClE,MAAM,MAAM,GAAG,OAA4B,CAAC;QAE5C,MAAM,KAAK,GAAiB,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACnD,QAAQ,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ;YACxB,yEAAyE;YACzE,kEAAkE;YAClE,gCAAgC;YAChC,YAAY,EAAE,CAAC,CAAC,iBAAiB,IAAI,IAAI;YACzC,WAAW,EAAE,CAAC,CAAC,WAAW;YAC1B,MAAM,EAAE,CAAC,CAAC,MAAM;YAChB,UAAU,EAAE,CAAC,CAAC,UAAU;YACxB,oBAAoB,EAAE,CAAC,CAAC,WAAW,EAAE,QAAQ,IAAI,IAAI;YACrD,kBAAkB,EAAE,CAAC,CAAC,kBAAkB;YACxC,OAAO,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;SAC9E,CAAC,CAAC,CAAC;QAEJ,OAAO;YACL,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,YAAY;YACxC,oEAAoE;YACpE,kCAAkC;YAClC,gBAAgB,EAAE,MAAM,CAAC,UAAU;YACnC,mBAAmB,EAAE,MAAM,CAAC,aAAa;YACzC,eAAe,EACb,MAAM,CAAC,WAAW,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;YACtF,cAAc,EAAE,MAAM,CAAC,MAAM,CAAC,cAAc;YAC5C,cAAc,EAAE,MAAM,CAAC,MAAM,CAAC,cAAc;YAC5C,cAAc,EAAE,MAAM,CAAC,MAAM,CAAC,cAAc;YAC5C,KAAK;YACL,wDAAwD;YACxD,WAAW,EAAE,KAAK;SACnB,CAAC;IACJ,CAAC;CACF,CAAC","sourcesContent":["/**\n * Canonical roster-json adapter: re-import a 40kdc {@link Roster} export.\n *\n * The exporter's `roster-json` format (see `export/roster-json.ts`) is the\n * lossless pivot — exactly `roster.schema.json` shape. This adapter closes the\n * loop so a 40kdc-native export round-trips through the normal\n * `tryImportRoster` pipeline: validate the canonical envelope, lower it to the\n * format-agnostic {@link ParsedRoster}, and let `resolve` re-derive ids\n * against the *current* dataset (so a stored export keeps resolving across\n * dataset releases, and stale ids self-heal through name resolution).\n *\n * Lowering notes:\n * - Unit/wargear/enhancement rows lower to their `ref.raw_name` — the same\n * raw-display-name path every other adapter takes.\n * - `faction_id`/`detachment_id` have no raw name in the canonical shape, so\n * the id slug is passed through as the raw name; collection lookup does an\n * exact-id match before any name lookup, so resolution is exact.\n * - `is_character` isn't stored on the canonical shape (it's an inference\n * input, not an output). It lowers as `leader_attachment != null`, which\n * reproduces the original (deterministic) attachment inference on\n * re-import. Attachments are always provisional either way.\n *\n * **IP safety**: the canonical document carries only permitted facts (names,\n * counts, points, ids); no prose fields exist to read.\n *\n * TS mirror of `crates/wh40kdc/src/import/roster_json.rs`.\n */\n\nimport type { FormatAdapter } from \"./adapter.js\";\nimport type { ParsedRoster, ParsedUnit, Roster } from \"./types.js\";\n\nfunction isRecord(v: unknown): v is Record<string, unknown> {\n return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\n/** Battle-size labels `resolve` maps back to the enum. */\nconst BATTLE_SIZE_LABELS: Record<string, string> = {\n incursion: \"Incursion\",\n \"strike-force\": \"Strike Force\",\n};\n\nexport const rosterJsonAdapter: FormatAdapter = {\n id: \"roster-json\",\n\n /**\n * The canonical shape is unmistakable: a `source.format` discriminator plus\n * the `game_version` + `diagnostics` envelope no external builder emits.\n * All three are required by `roster.schema.json`.\n */\n matches(decoded: unknown): boolean {\n if (!isRecord(decoded)) return false;\n const source = decoded[\"source\"];\n const gameVersion = decoded[\"game_version\"];\n return (\n isRecord(source) &&\n typeof source[\"format\"] === \"string\" &&\n isRecord(gameVersion) &&\n typeof gameVersion[\"edition\"] === \"string\" &&\n isRecord(decoded[\"diagnostics\"]) &&\n Array.isArray(decoded[\"units\"])\n );\n },\n\n parse(decoded: unknown): ParsedRoster {\n // matches() vetted the envelope; the unit rows are schema-shaped.\n const roster = decoded as unknown as Roster;\n\n const units: ParsedUnit[] = roster.units.map((u) => ({\n raw_name: u.ref.raw_name,\n // Not stored canonically; attached units were characters, and re-running\n // the (deterministic) inference over them reproduces the exported\n // attachments. See module docs.\n is_character: u.leader_attachment != null,\n model_count: u.model_count,\n points: u.points,\n is_warlord: u.is_warlord,\n enhancement_raw_name: u.enhancement?.raw_name ?? null,\n enhancement_points: u.enhancement_points,\n wargear: u.wargear.map((w) => ({ raw_name: w.ref.raw_name, count: w.count })),\n }));\n\n return {\n name: roster.name,\n generated_by: roster.source.generated_by,\n // Id slugs pass through as raw names — collection lookup id-matches\n // exactly before any name lookup.\n faction_raw_name: roster.faction_id,\n detachment_raw_name: roster.detachment_id,\n battle_size_raw:\n roster.battle_size != null ? (BATTLE_SIZE_LABELS[roster.battle_size] ?? null) : null,\n declared_limit: roster.points.declared_limit,\n total_reported: roster.points.total_reported,\n total_computed: roster.points.total_computed,\n units,\n // The canonical shape carries a single primary faction.\n multi_force: false,\n };\n },\n};\n"]}
@@ -64,7 +64,7 @@ export interface RosterUnit {
64
64
  }
65
65
  /** Identifier for the adapter that produced this roster. New format adapters
66
66
  * extend this union; `roster.schema.json` keeps the canonical enum. */
67
- export type RosterFormat = "listforge" | "listforge-text" | "newrecruit-json" | "newrecruit-wtc-compact" | "newrecruit-wtc-full" | "newrecruit-simple" | "rosterizer" | "gw";
67
+ export type RosterFormat = "listforge" | "listforge-text" | "newrecruit-json" | "newrecruit-wtc-compact" | "newrecruit-wtc-full" | "newrecruit-simple" | "rosterizer" | "gw" | "roster-json";
68
68
  /** Provenance of the imported list. */
69
69
  export interface RosterSource {
70
70
  format: RosterFormat;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/import/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/import/types.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/import/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG","sourcesContent":["/**\n * Types for the army-list importer.\n *\n * Two layers live here:\n * - The **output** types ({@link Roster} and friends) mirror\n * `schemas/core/roster.schema.json` field-for-field. They are hand-authored\n * rather than generated so importer work isn't gated on the Rust→typify codegen\n * round-trip; the AJV validator (against the real schema) is the source of truth\n * for conformance.\n * - The **intermediate** type ({@link ParsedRoster}) is format-agnostic: a parser\n * adapter lowers a source payload to this shape (raw names + counts only, no\n * resolved ids), and {@link resolve} turns it into a {@link Roster}.\n *\n * Nothing here ever carries reproduced rules or ability text — only permitted\n * facts (names, counts, points, keywords, entity ids).\n *\n * @packageDocumentation\n */\n\n/** A 40kdc battle size (mirrors the shared `battle-size` def). */\nexport type BattleSize = \"incursion\" | \"strike-force\";\n\n/** Diagnostic warning codes emitted during an import. */\nexport type WarningCode =\n | \"faction-unresolved\"\n | \"unit-unresolved\"\n | \"weapon-unresolved\"\n | \"enhancement-unresolved\"\n | \"detachment-unresolved\"\n | \"battle-size-unmapped\"\n | \"points-mismatch\"\n | \"leader-attachment-inferred\"\n | \"multi-force\"\n | \"unknown-field\";\n\n// ---------------------------------------------------------------------------\n// Output types (mirror roster.schema.json)\n// ---------------------------------------------------------------------------\n\n/** A near-match suggestion offered when resolution fails. */\nexport interface Candidate {\n id: string;\n name: string;\n}\n\n/**\n * A reference to a 40kdc entity that may or may not have resolved. Retains the\n * source's raw name so the import is lossless even on a miss.\n */\nexport interface ResolvedRef {\n /** Resolved entity id, or null when no match was found. */\n id: string | null;\n /** The display name exactly as it appeared in the source payload. */\n raw_name: string;\n /** True iff {@link id} is non-null. */\n resolved: boolean;\n /** Up to 5 best-guess alternatives when resolution failed. */\n candidates: Candidate[];\n}\n\n/** A weapon/wargear selection on a unit. */\nexport interface RosterWargear {\n ref: ResolvedRef;\n count: number;\n}\n\n/** An inferred, always-provisional leader→bodyguard attachment. */\nexport interface RosterLeaderAttachment {\n bodyguard_ref: ResolvedRef;\n provisional: boolean;\n}\n\n/** One unit entry in a roster. */\nexport interface RosterUnit {\n ref: ResolvedRef;\n model_count: number;\n /** Base unit cost (without the enhancement). */\n points: number | null;\n is_warlord: boolean;\n enhancement: ResolvedRef | null;\n /** Points cost of the enhancement when the source reported one; null otherwise. */\n enhancement_points: number | null;\n wargear: RosterWargear[];\n leader_attachment: RosterLeaderAttachment | null;\n}\n\n/** Identifier for the adapter that produced this roster. New format adapters\n * extend this union; `roster.schema.json` keeps the canonical enum. */\nexport type RosterFormat =\n | \"listforge\"\n | \"listforge-text\"\n | \"newrecruit-json\"\n | \"newrecruit-wtc-compact\"\n | \"newrecruit-wtc-full\"\n | \"newrecruit-simple\"\n | \"rosterizer\"\n | \"gw\";\n\n/** Provenance of the imported list. */\nexport interface RosterSource {\n format: RosterFormat;\n generated_by: string | null;\n}\n\n/** Point totals; reported and computed are kept distinct, never reconciled. */\nexport interface RosterPoints {\n declared_limit: number | null;\n total_reported: number | null;\n total_computed: number;\n}\n\n/** A single diagnostic warning. */\nexport interface Warning {\n code: WarningCode;\n message: string;\n raw_name: string | null;\n}\n\n/** A summary of what resolved and what did not during the import. */\nexport interface Diagnostics {\n resolved_units: number;\n unresolved_units: number;\n resolved_weapons: number;\n unresolved_weapons: number;\n warnings: Warning[];\n}\n\n/** Reference to the game edition + dataslate (mirrors game-version-ref). */\nexport interface GameVersionRef {\n edition: string;\n dataslate: string;\n}\n\n/** A fully-resolved army list. Validates against `roster.schema.json`. */\nexport interface Roster {\n name: string;\n source: RosterSource;\n faction_id: string | null;\n detachment_id: string | null;\n battle_size: BattleSize | null;\n points: RosterPoints;\n units: RosterUnit[];\n game_version: GameVersionRef;\n diagnostics: Diagnostics;\n}\n\n// ---------------------------------------------------------------------------\n// Intermediate types (format-agnostic; produced by a parser adapter)\n// ---------------------------------------------------------------------------\n\n/** A weapon/wargear selection before id resolution. */\nexport interface ParsedWargear {\n raw_name: string;\n count: number;\n}\n\n/** A unit selection before id resolution. */\nexport interface ParsedUnit {\n raw_name: string;\n /** True when the source classifies this as a character/leader-capable model. */\n is_character: boolean;\n model_count: number;\n /** Base unit cost (without the enhancement). */\n points: number | null;\n is_warlord: boolean;\n enhancement_raw_name: string | null;\n /** Points cost of the enhancement when the source reported one; null otherwise. */\n enhancement_points: number | null;\n wargear: ParsedWargear[];\n}\n\n/**\n * The format-agnostic intermediate. A {@link FormatAdapter} produces this from a\n * decoded source payload; {@link resolve} consumes it. Contains only raw display\n * names and counts — never reproduced rules text.\n */\nexport interface ParsedRoster {\n name: string;\n generated_by: string | null;\n /** Raw faction name from the source (e.g. \"Grey Knights\"). */\n faction_raw_name: string | null;\n /** Raw detachment name (e.g. \"Banishers\"). */\n detachment_raw_name: string | null;\n /** Raw battle-size label (e.g. \"2. Strike Force (2000 Point limit)\"). */\n battle_size_raw: string | null;\n /** Points limit parsed from the battle-size label, if any. */\n declared_limit: number | null;\n /** Total points reported by the source cost block. */\n total_reported: number | null;\n /** Points summed from every cost line in the source tree. */\n total_computed: number;\n units: ParsedUnit[];\n /** True when the source contained more than one distinct faction. */\n multi_force: boolean;\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/import/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG","sourcesContent":["/**\n * Types for the army-list importer.\n *\n * Two layers live here:\n * - The **output** types ({@link Roster} and friends) mirror\n * `schemas/core/roster.schema.json` field-for-field. They are hand-authored\n * rather than generated so importer work isn't gated on the Rust→typify codegen\n * round-trip; the AJV validator (against the real schema) is the source of truth\n * for conformance.\n * - The **intermediate** type ({@link ParsedRoster}) is format-agnostic: a parser\n * adapter lowers a source payload to this shape (raw names + counts only, no\n * resolved ids), and {@link resolve} turns it into a {@link Roster}.\n *\n * Nothing here ever carries reproduced rules or ability text — only permitted\n * facts (names, counts, points, keywords, entity ids).\n *\n * @packageDocumentation\n */\n\n/** A 40kdc battle size (mirrors the shared `battle-size` def). */\nexport type BattleSize = \"incursion\" | \"strike-force\";\n\n/** Diagnostic warning codes emitted during an import. */\nexport type WarningCode =\n | \"faction-unresolved\"\n | \"unit-unresolved\"\n | \"weapon-unresolved\"\n | \"enhancement-unresolved\"\n | \"detachment-unresolved\"\n | \"battle-size-unmapped\"\n | \"points-mismatch\"\n | \"leader-attachment-inferred\"\n | \"multi-force\"\n | \"unknown-field\";\n\n// ---------------------------------------------------------------------------\n// Output types (mirror roster.schema.json)\n// ---------------------------------------------------------------------------\n\n/** A near-match suggestion offered when resolution fails. */\nexport interface Candidate {\n id: string;\n name: string;\n}\n\n/**\n * A reference to a 40kdc entity that may or may not have resolved. Retains the\n * source's raw name so the import is lossless even on a miss.\n */\nexport interface ResolvedRef {\n /** Resolved entity id, or null when no match was found. */\n id: string | null;\n /** The display name exactly as it appeared in the source payload. */\n raw_name: string;\n /** True iff {@link id} is non-null. */\n resolved: boolean;\n /** Up to 5 best-guess alternatives when resolution failed. */\n candidates: Candidate[];\n}\n\n/** A weapon/wargear selection on a unit. */\nexport interface RosterWargear {\n ref: ResolvedRef;\n count: number;\n}\n\n/** An inferred, always-provisional leader→bodyguard attachment. */\nexport interface RosterLeaderAttachment {\n bodyguard_ref: ResolvedRef;\n provisional: boolean;\n}\n\n/** One unit entry in a roster. */\nexport interface RosterUnit {\n ref: ResolvedRef;\n model_count: number;\n /** Base unit cost (without the enhancement). */\n points: number | null;\n is_warlord: boolean;\n enhancement: ResolvedRef | null;\n /** Points cost of the enhancement when the source reported one; null otherwise. */\n enhancement_points: number | null;\n wargear: RosterWargear[];\n leader_attachment: RosterLeaderAttachment | null;\n}\n\n/** Identifier for the adapter that produced this roster. New format adapters\n * extend this union; `roster.schema.json` keeps the canonical enum. */\nexport type RosterFormat =\n | \"listforge\"\n | \"listforge-text\"\n | \"newrecruit-json\"\n | \"newrecruit-wtc-compact\"\n | \"newrecruit-wtc-full\"\n | \"newrecruit-simple\"\n | \"rosterizer\"\n | \"gw\"\n | \"roster-json\";\n\n/** Provenance of the imported list. */\nexport interface RosterSource {\n format: RosterFormat;\n generated_by: string | null;\n}\n\n/** Point totals; reported and computed are kept distinct, never reconciled. */\nexport interface RosterPoints {\n declared_limit: number | null;\n total_reported: number | null;\n total_computed: number;\n}\n\n/** A single diagnostic warning. */\nexport interface Warning {\n code: WarningCode;\n message: string;\n raw_name: string | null;\n}\n\n/** A summary of what resolved and what did not during the import. */\nexport interface Diagnostics {\n resolved_units: number;\n unresolved_units: number;\n resolved_weapons: number;\n unresolved_weapons: number;\n warnings: Warning[];\n}\n\n/** Reference to the game edition + dataslate (mirrors game-version-ref). */\nexport interface GameVersionRef {\n edition: string;\n dataslate: string;\n}\n\n/** A fully-resolved army list. Validates against `roster.schema.json`. */\nexport interface Roster {\n name: string;\n source: RosterSource;\n faction_id: string | null;\n detachment_id: string | null;\n battle_size: BattleSize | null;\n points: RosterPoints;\n units: RosterUnit[];\n game_version: GameVersionRef;\n diagnostics: Diagnostics;\n}\n\n// ---------------------------------------------------------------------------\n// Intermediate types (format-agnostic; produced by a parser adapter)\n// ---------------------------------------------------------------------------\n\n/** A weapon/wargear selection before id resolution. */\nexport interface ParsedWargear {\n raw_name: string;\n count: number;\n}\n\n/** A unit selection before id resolution. */\nexport interface ParsedUnit {\n raw_name: string;\n /** True when the source classifies this as a character/leader-capable model. */\n is_character: boolean;\n model_count: number;\n /** Base unit cost (without the enhancement). */\n points: number | null;\n is_warlord: boolean;\n enhancement_raw_name: string | null;\n /** Points cost of the enhancement when the source reported one; null otherwise. */\n enhancement_points: number | null;\n wargear: ParsedWargear[];\n}\n\n/**\n * The format-agnostic intermediate. A {@link FormatAdapter} produces this from a\n * decoded source payload; {@link resolve} consumes it. Contains only raw display\n * names and counts — never reproduced rules text.\n */\nexport interface ParsedRoster {\n name: string;\n generated_by: string | null;\n /** Raw faction name from the source (e.g. \"Grey Knights\"). */\n faction_raw_name: string | null;\n /** Raw detachment name (e.g. \"Banishers\"). */\n detachment_raw_name: string | null;\n /** Raw battle-size label (e.g. \"2. Strike Force (2000 Point limit)\"). */\n battle_size_raw: string | null;\n /** Points limit parsed from the battle-size label, if any. */\n declared_limit: number | null;\n /** Total points reported by the source cost block. */\n total_reported: number | null;\n /** Points summed from every cost line in the source tree. */\n total_computed: number;\n units: ParsedUnit[];\n /** True when the source contained more than one distinct faction. */\n multi_force: boolean;\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { resolveLayout, polygonCentroid, footprintVertices, orientedOffsets, Ter
7
7
  export type { ResolvedPiece, ResolvedVec2, BoardEdge, FeatureRef, Keystone, KeystoneMeasurement, DimensionLine, SolveInput, TriangulationLine, TriangulateInput, AttachLine, AttachPiece, AttachInput, } from "./terrain/index.js";
8
8
  export * from "./scoring/index.js";
9
9
  export * from "./cruncher/index.js";
10
+ export * from "./compare.js";
10
11
  export { importListForge, importNewRecruit, importRoster, tryImportRoster, decodeListForge, } from "./import/index.js";
11
12
  export { exportRoster, newRecruitJsonSerializer, newRecruitSimpleSerializer, newRecruitWtcCompactSerializer, newRecruitWtcFullSerializer, rosterJsonSerializer, rosterizerSerializer, } from "./export/index.js";
12
13
  export type { ExportFormat, RosterSerializer } from "./export/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,iBAAiB,CAAC;AAGhC,cAAc,gBAAgB,CAAC;AAI/B,cAAc,sBAAsB,CAAC;AAKrC,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIrD,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAKnD,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,EACnB,aAAa,EACb,yBAAyB,EACzB,qBAAqB,EACrB,iBAAiB,EACjB,oBAAoB,EACpB,YAAY,EACZ,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,aAAa,EACb,YAAY,EACZ,SAAS,EACT,UAAU,EACV,QAAQ,EACR,mBAAmB,EACnB,aAAa,EACb,UAAU,EACV,iBAAiB,EACjB,gBAAgB,EAChB,UAAU,EACV,WAAW,EACX,WAAW,GACZ,MAAM,oBAAoB,CAAC;AAK5B,cAAc,oBAAoB,CAAC;AAKnC,cAAc,qBAAqB,CAAC;AAUpC,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,YAAY,EACZ,wBAAwB,EACxB,0BAA0B,EAC1B,8BAA8B,EAC9B,2BAA2B,EAC3B,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACxE,YAAY,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,YAAY,EACV,aAAa,EACb,YAAY,EACZ,mBAAmB,EACnB,YAAY,EACZ,MAAM,EACN,UAAU,EACV,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,sBAAsB,EACtB,WAAW,EACX,SAAS,EACT,WAAW,EACX,OAAO,EACP,WAAW,EACX,YAAY,EACZ,UAAU,EACV,aAAa,GACd,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,iBAAiB,CAAC;AAGhC,cAAc,gBAAgB,CAAC;AAI/B,cAAc,sBAAsB,CAAC;AAKrC,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIrD,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAKnD,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,EACnB,aAAa,EACb,yBAAyB,EACzB,qBAAqB,EACrB,iBAAiB,EACjB,oBAAoB,EACpB,YAAY,EACZ,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,aAAa,EACb,YAAY,EACZ,SAAS,EACT,UAAU,EACV,QAAQ,EACR,mBAAmB,EACnB,aAAa,EACb,UAAU,EACV,iBAAiB,EACjB,gBAAgB,EAChB,UAAU,EACV,WAAW,EACX,WAAW,GACZ,MAAM,oBAAoB,CAAC;AAK5B,cAAc,oBAAoB,CAAC;AAKnC,cAAc,qBAAqB,CAAC;AAKpC,cAAc,cAAc,CAAC;AAU7B,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,YAAY,EACZ,wBAAwB,EACxB,0BAA0B,EAC1B,8BAA8B,EAC9B,2BAA2B,EAC3B,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACxE,YAAY,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,YAAY,EACV,aAAa,EACb,YAAY,EACZ,mBAAmB,EACnB,YAAY,EACZ,MAAM,EACN,UAAU,EACV,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,sBAAsB,EACtB,WAAW,EACX,SAAS,EACT,WAAW,EACX,OAAO,EACP,WAAW,EACX,YAAY,EACZ,UAAU,EACV,aAAa,GACd,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -17,6 +17,10 @@ export * from "./scoring/index.js";
17
17
  // Mirrored by the Rust `wh40kdc::cruncher` module and pinned by the
18
18
  // `conformance/cruncher` corpus. Pure functions — universal (Node + browser).
19
19
  export * from "./cruncher/index.js";
20
+ // Fleet comparison (attacker set × target-profile set → expected-kills matrix),
21
+ // built on the cruncher. Mirrors `python/src/wh40kdc/compare.py`; the per-cell
22
+ // math is pinned against Python by the `conformance/compare` corpus.
23
+ export * from "./compare.js";
20
24
  // Schema access + AJV validation lives behind the `./validate` subpath export
21
25
  // (`@alpaca-software/40kdc-data/validate`), NOT the root barrel: it reads
22
26
  // schema files from disk at module load (node:fs/node:url), which breaks
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,uDAAuD;AACvD,cAAc,iBAAiB,CAAC;AAEhC,mDAAmD;AACnD,cAAc,gBAAgB,CAAC;AAE/B,0EAA0E;AAC1E,6EAA6E;AAC7E,cAAc,sBAAsB,CAAC;AAWrC,+EAA+E;AAC/E,2EAA2E;AAC3E,8EAA8E;AAC9E,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,EACnB,aAAa,EACb,yBAAyB,EACzB,qBAAqB,EACrB,iBAAiB,EACjB,oBAAoB,EACpB,YAAY,EACZ,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAiB5B,6EAA6E;AAC7E,+EAA+E;AAC/E,yDAAyD;AACzD,cAAc,oBAAoB,CAAC;AAEnC,4EAA4E;AAC5E,oEAAoE;AACpE,8EAA8E;AAC9E,cAAc,qBAAqB,CAAC;AAEpC,8EAA8E;AAC9E,0EAA0E;AAC1E,yEAAyE;AACzE,qEAAqE;AAErE,4EAA4E;AAC5E,+EAA+E;AAC/E,uCAAuC;AACvC,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,mBAAmB,CAAC;AAE3B,+EAA+E;AAC/E,OAAO,EACL,YAAY,EACZ,wBAAwB,EACxB,0BAA0B,EAC1B,8BAA8B,EAC9B,2BAA2B,EAC3B,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC","sourcesContent":["// The linked, typed dataset — the primary entry point.\nexport * from \"./data/index.js\";\n\n// Generated types for every entity in the dataset.\nexport * from \"./generated.js\";\n\n// Plain-English translation of structured data (scoring-card awards + the\n// shared Ability-DSL condition humanizer). Cross-impl pinned by conformance.\nexport * from \"./translate/index.js\";\n\n// `ScoringTrigger` is emitted by both ./generated.js (schema-derived) and\n// ./translate (hand-authored). They are structurally identical; disambiguate the\n// two wildcard re-exports in favour of the generated, schema-canonical type.\nexport type { ScoringTrigger } from \"./generated.js\";\n\n// `AbilityScope` likewise: ./translate/effect.ts hand-authors a looser view\n// for the describer; prefer the generated, schema-canonical type.\nexport type { AbilityScope } from \"./generated.js\";\n\n// Terrain geometry resolver (template-anchored layout → board-space vertices).\n// Curated (not wildcard) so its internal type aliases don't clash with the\n// generated Footprint/TerrainTemplate/TerrainLayout types. Cross-impl pinned.\nexport {\n resolveLayout,\n polygonCentroid,\n footprintVertices,\n orientedOffsets,\n TerrainResolveError,\n solveCentroid,\n solveCentroidTriangulated,\n solveCentroidAttached,\n TerrainSolveError,\n keystoneMeasurements,\n BOARD_INCHES,\n TerrainKeystoneError,\n} from \"./terrain/index.js\";\nexport type {\n ResolvedPiece,\n ResolvedVec2,\n BoardEdge,\n FeatureRef,\n Keystone,\n KeystoneMeasurement,\n DimensionLine,\n SolveInput,\n TriangulationLine,\n TriangulateInput,\n AttachLine,\n AttachPiece,\n AttachInput,\n} from \"./terrain/index.js\";\n\n// Card-driven secondary-mission scoring: compute VP from asserted awards and\n// track per-round, per-player scoring. Mirrored by the Rust `wh40kdc::scoring`\n// module and pinned by the `conformance/scoring` corpus.\nexport * from \"./scoring/index.js\";\n\n// Damage-projection engine (expected-value math over weapon/unit profiles).\n// Mirrored by the Rust `wh40kdc::cruncher` module and pinned by the\n// `conformance/cruncher` corpus. Pure functions — universal (Node + browser).\nexport * from \"./cruncher/index.js\";\n\n// Schema access + AJV validation lives behind the `./validate` subpath export\n// (`@alpaca-software/40kdc-data/validate`), NOT the root barrel: it reads\n// schema files from disk at module load (node:fs/node:url), which breaks\n// browser bundles. The root barrel stays universal (Node + browser).\n\n// Army-list importer (ListForge → resolved 40kdc roster). Types are curated\n// rather than re-exported wholesale to avoid name clashes with generated types\n// (e.g. BattleSize, LeaderAttachment).\nexport {\n importListForge,\n importNewRecruit,\n importRoster,\n tryImportRoster,\n decodeListForge,\n} from \"./import/index.js\";\n\n// Army-list exporter (Roster → text or JSON for any of the supported formats).\nexport {\n exportRoster,\n newRecruitJsonSerializer,\n newRecruitSimpleSerializer,\n newRecruitWtcCompactSerializer,\n newRecruitWtcFullSerializer,\n rosterJsonSerializer,\n rosterizerSerializer,\n} from \"./export/index.js\";\nexport type { ExportFormat, RosterSerializer } from \"./export/index.js\";\nexport type { FormatAdapter } from \"./import/index.js\";\nexport type {\n ImportOptions,\n ImportResult,\n ImportFailureReason,\n AdapterTrial,\n Roster,\n RosterUnit,\n RosterWargear,\n RosterSource,\n RosterFormat,\n RosterPoints,\n RosterLeaderAttachment,\n ResolvedRef,\n Candidate,\n Diagnostics,\n Warning,\n WarningCode,\n ParsedRoster,\n ParsedUnit,\n ParsedWargear,\n} from \"./import/index.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,uDAAuD;AACvD,cAAc,iBAAiB,CAAC;AAEhC,mDAAmD;AACnD,cAAc,gBAAgB,CAAC;AAE/B,0EAA0E;AAC1E,6EAA6E;AAC7E,cAAc,sBAAsB,CAAC;AAWrC,+EAA+E;AAC/E,2EAA2E;AAC3E,8EAA8E;AAC9E,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,EACnB,aAAa,EACb,yBAAyB,EACzB,qBAAqB,EACrB,iBAAiB,EACjB,oBAAoB,EACpB,YAAY,EACZ,oBAAoB,GACrB,MAAM,oBAAoB,CAAC;AAiB5B,6EAA6E;AAC7E,+EAA+E;AAC/E,yDAAyD;AACzD,cAAc,oBAAoB,CAAC;AAEnC,4EAA4E;AAC5E,oEAAoE;AACpE,8EAA8E;AAC9E,cAAc,qBAAqB,CAAC;AAEpC,gFAAgF;AAChF,+EAA+E;AAC/E,qEAAqE;AACrE,cAAc,cAAc,CAAC;AAE7B,8EAA8E;AAC9E,0EAA0E;AAC1E,yEAAyE;AACzE,qEAAqE;AAErE,4EAA4E;AAC5E,+EAA+E;AAC/E,uCAAuC;AACvC,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,mBAAmB,CAAC;AAE3B,+EAA+E;AAC/E,OAAO,EACL,YAAY,EACZ,wBAAwB,EACxB,0BAA0B,EAC1B,8BAA8B,EAC9B,2BAA2B,EAC3B,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC","sourcesContent":["// The linked, typed dataset — the primary entry point.\nexport * from \"./data/index.js\";\n\n// Generated types for every entity in the dataset.\nexport * from \"./generated.js\";\n\n// Plain-English translation of structured data (scoring-card awards + the\n// shared Ability-DSL condition humanizer). Cross-impl pinned by conformance.\nexport * from \"./translate/index.js\";\n\n// `ScoringTrigger` is emitted by both ./generated.js (schema-derived) and\n// ./translate (hand-authored). They are structurally identical; disambiguate the\n// two wildcard re-exports in favour of the generated, schema-canonical type.\nexport type { ScoringTrigger } from \"./generated.js\";\n\n// `AbilityScope` likewise: ./translate/effect.ts hand-authors a looser view\n// for the describer; prefer the generated, schema-canonical type.\nexport type { AbilityScope } from \"./generated.js\";\n\n// Terrain geometry resolver (template-anchored layout → board-space vertices).\n// Curated (not wildcard) so its internal type aliases don't clash with the\n// generated Footprint/TerrainTemplate/TerrainLayout types. Cross-impl pinned.\nexport {\n resolveLayout,\n polygonCentroid,\n footprintVertices,\n orientedOffsets,\n TerrainResolveError,\n solveCentroid,\n solveCentroidTriangulated,\n solveCentroidAttached,\n TerrainSolveError,\n keystoneMeasurements,\n BOARD_INCHES,\n TerrainKeystoneError,\n} from \"./terrain/index.js\";\nexport type {\n ResolvedPiece,\n ResolvedVec2,\n BoardEdge,\n FeatureRef,\n Keystone,\n KeystoneMeasurement,\n DimensionLine,\n SolveInput,\n TriangulationLine,\n TriangulateInput,\n AttachLine,\n AttachPiece,\n AttachInput,\n} from \"./terrain/index.js\";\n\n// Card-driven secondary-mission scoring: compute VP from asserted awards and\n// track per-round, per-player scoring. Mirrored by the Rust `wh40kdc::scoring`\n// module and pinned by the `conformance/scoring` corpus.\nexport * from \"./scoring/index.js\";\n\n// Damage-projection engine (expected-value math over weapon/unit profiles).\n// Mirrored by the Rust `wh40kdc::cruncher` module and pinned by the\n// `conformance/cruncher` corpus. Pure functions — universal (Node + browser).\nexport * from \"./cruncher/index.js\";\n\n// Fleet comparison (attacker set × target-profile set → expected-kills matrix),\n// built on the cruncher. Mirrors `python/src/wh40kdc/compare.py`; the per-cell\n// math is pinned against Python by the `conformance/compare` corpus.\nexport * from \"./compare.js\";\n\n// Schema access + AJV validation lives behind the `./validate` subpath export\n// (`@alpaca-software/40kdc-data/validate`), NOT the root barrel: it reads\n// schema files from disk at module load (node:fs/node:url), which breaks\n// browser bundles. The root barrel stays universal (Node + browser).\n\n// Army-list importer (ListForge → resolved 40kdc roster). Types are curated\n// rather than re-exported wholesale to avoid name clashes with generated types\n// (e.g. BattleSize, LeaderAttachment).\nexport {\n importListForge,\n importNewRecruit,\n importRoster,\n tryImportRoster,\n decodeListForge,\n} from \"./import/index.js\";\n\n// Army-list exporter (Roster → text or JSON for any of the supported formats).\nexport {\n exportRoster,\n newRecruitJsonSerializer,\n newRecruitSimpleSerializer,\n newRecruitWtcCompactSerializer,\n newRecruitWtcFullSerializer,\n rosterJsonSerializer,\n rosterizerSerializer,\n} from \"./export/index.js\";\nexport type { ExportFormat, RosterSerializer } from \"./export/index.js\";\nexport type { FormatAdapter } from \"./import/index.js\";\nexport type {\n ImportOptions,\n ImportResult,\n ImportFailureReason,\n AdapterTrial,\n Roster,\n RosterUnit,\n RosterWargear,\n RosterSource,\n RosterFormat,\n RosterPoints,\n RosterLeaderAttachment,\n ResolvedRef,\n Candidate,\n Diagnostics,\n Warning,\n WarningCode,\n ParsedRoster,\n ParsedUnit,\n ParsedWargear,\n} from \"./import/index.js\";\n"]}
@@ -0,0 +1,23 @@
1
+ import { type Keystone, type TerrainLayout, type TerrainTemplate } from "./terrain/resolve.js";
2
+ export interface PairingAddition {
3
+ layoutId: string;
4
+ /** The piece RECEIVING the mirrored keystone. */
5
+ pieceId: string;
6
+ /** The piece whose keystone was mirrored. */
7
+ fromPieceId: string;
8
+ keystone: Keystone;
9
+ }
10
+ export interface PairingReport {
11
+ /** Mirrors that are missing (check mode) / were added (write mode). */
12
+ additions: PairingAddition[];
13
+ /** Keystone-bearing pieces we could not pair or mirror, with the reason. */
14
+ warnings: string[];
15
+ }
16
+ /**
17
+ * Scan every layout for keystones whose symmetry twin lacks the mirror.
18
+ * `apply` mutates the layout objects in place (appending the mirrors);
19
+ * check mode leaves them untouched. Either way the report lists every
20
+ * addition and every piece that could not be handled.
21
+ */
22
+ export declare function pairKeystones(layouts: TerrainLayout[], templates: TerrainTemplate[], apply: boolean): PairingReport;
23
+ //# sourceMappingURL=migrate-keystone-twins.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate-keystone-twins.d.ts","sourceRoot":"","sources":["../src/migrate-keystone-twins.ts"],"names":[],"mappings":"AA0BA,OAAO,EAIL,KAAK,QAAQ,EAEb,KAAK,aAAa,EAClB,KAAK,eAAe,EAErB,MAAM,sBAAsB,CAAC;AAqE9B,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,QAAQ,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,aAAa,EAAE,EACxB,SAAS,EAAE,eAAe,EAAE,EAC5B,KAAK,EAAE,OAAO,GACb,aAAa,CAqEf"}
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Keystone twin pairing for `data/core/terrain-layouts.json`: every authored
3
+ * measurement keystone should have its point-reflected counterpart on the
4
+ * piece's 180°-symmetry twin, so a layout card prints dimension lines for
5
+ * BOTH players' halves (the layout editor now maintains this invariant on
6
+ * add/remove; this script back-fills the existing data and re-audits it).
7
+ *
8
+ * Twins are not stored — they are recovered the way the editor's
9
+ * `autoPairTwins` does: same template, unparented, centroid within 0.75″ of
10
+ * the point reflection through the board centre. The mirrored keystone flips
11
+ * its board edge (left↔right, top↔bottom); a face ref flips its side, and a
12
+ * vertex ref is resolved GEOMETRICALLY — reflect the source vertex through
13
+ * the centre and take the twin's nearest vertex. Index arithmetic is not
14
+ * safe: many layouts store the twin at the same angle (not θ+180), so vertex
15
+ * i on one side need not be vertex i on the other. A reflection that lands
16
+ * more than 0.25″ from every twin vertex is skipped with a warning rather
17
+ * than guessed.
18
+ *
19
+ * Usage (from `tools/`):
20
+ * npx tsx src/migrate-keystone-twins.ts # write missing mirrors
21
+ * npx tsx src/migrate-keystone-twins.ts --check # report only; exit 1 if any are missing
22
+ */
23
+ import { readFileSync, writeFileSync } from "node:fs";
24
+ import { join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { orientedOffsets, } from "./terrain/resolve.js";
27
+ const BOARD = { width: 60, height: 44 };
28
+ /** Twin pairing tolerance on the point-reflected centroid (matches the editor). */
29
+ const POS_TOL = 0.75;
30
+ /** A piece this close to the board centre is self-symmetric and has no twin. */
31
+ const CENTRE_TOL = 0.3;
32
+ /** How close (inches) the reflected vertex must land to a twin vertex. */
33
+ const VERT_EPS = 0.25;
34
+ const REPO_ROOT = join(new URL("../..", import.meta.url).pathname);
35
+ const LAYOUTS_PATH = join(REPO_ROOT, "data", "core", "terrain-layouts.json");
36
+ const TEMPLATES_PATH = join(REPO_ROOT, "data", "core", "terrain-templates.json");
37
+ // ---- geometry --------------------------------------------------------------
38
+ const flipEdge = (e) => e === "left" ? "right" : e === "right" ? "left" : e === "top" ? "bottom" : "top";
39
+ const flipSide = (s) => s === "min-x" ? "max-x" : s === "max-x" ? "min-x" : s === "min-y" ? "max-y" : "min-y";
40
+ const sameRef = (a, b) => a.kind === "vertex" && b.kind === "vertex"
41
+ ? a.index === b.index
42
+ : a.kind === "face" && b.kind === "face"
43
+ ? a.side === b.side
44
+ : false;
45
+ const hasKeystone = (p, k) => (p.keystones ?? []).some((e) => e.edge === k.edge && sameRef(e.ref, k.ref));
46
+ /** Board-space vertices of an UNPARENTED piece (centroid + oriented offsets). */
47
+ function boardVertices(p, templates) {
48
+ const fp = p.footprint ?? (p.template ? templates.get(p.template)?.footprint : undefined);
49
+ if (!fp)
50
+ return null;
51
+ const offsets = orientedOffsets(fp, p.rotation_degrees ?? 0, p.mirror ?? "none");
52
+ return offsets.map((o) => ({ x: p.position.x + o.x, y: p.position.y + o.y }));
53
+ }
54
+ /**
55
+ * The keystone `k` mirrored from `primary` onto `twin`, or null (with a
56
+ * reason) when the vertex reflection has no close-enough twin vertex.
57
+ */
58
+ function mirrorKeystone(k, primaryVerts, twinVerts) {
59
+ const edge = flipEdge(k.edge);
60
+ if (k.ref.kind === "face")
61
+ return { keystone: { edge, ref: { kind: "face", side: flipSide(k.ref.side) } } };
62
+ const anchor = primaryVerts[k.ref.index];
63
+ if (!anchor)
64
+ return { error: `vertex index ${k.ref.index} out of range` };
65
+ const reflected = { x: BOARD.width - anchor.x, y: BOARD.height - anchor.y };
66
+ let bestIndex = -1;
67
+ let best = Infinity;
68
+ twinVerts.forEach((v, i) => {
69
+ const d = Math.hypot(v.x - reflected.x, v.y - reflected.y);
70
+ if (d < best) {
71
+ best = d;
72
+ bestIndex = i;
73
+ }
74
+ });
75
+ if (bestIndex < 0 || best > VERT_EPS) {
76
+ return { error: `reflected vertex lands ${best.toFixed(2)}″ from the nearest twin vertex (> ${VERT_EPS}″)` };
77
+ }
78
+ return { keystone: { edge, ref: { kind: "vertex", index: bestIndex } } };
79
+ }
80
+ /**
81
+ * Scan every layout for keystones whose symmetry twin lacks the mirror.
82
+ * `apply` mutates the layout objects in place (appending the mirrors);
83
+ * check mode leaves them untouched. Either way the report lists every
84
+ * addition and every piece that could not be handled.
85
+ */
86
+ export function pairKeystones(layouts, templates, apply) {
87
+ const byId = new Map(templates.map((t) => [t.id, t]));
88
+ const additions = [];
89
+ const warnings = [];
90
+ for (const layout of layouts) {
91
+ const pieces = layout.pieces ?? [];
92
+ // Twin pairing pass (unparented, off-centre, same template, reflected centroid).
93
+ const twin = new Map();
94
+ for (const p of pieces) {
95
+ if (twin.has(p) || p.parent_area_id)
96
+ continue;
97
+ const onCentre = Math.abs(p.position.x - BOARD.width / 2) < CENTRE_TOL &&
98
+ Math.abs(p.position.y - BOARD.height / 2) < CENTRE_TOL;
99
+ if (onCentre)
100
+ continue;
101
+ const want = { x: BOARD.width - p.position.x, y: BOARD.height - p.position.y };
102
+ const match = pieces.find((q) => q !== p &&
103
+ !twin.has(q) &&
104
+ !q.parent_area_id &&
105
+ q.template === p.template &&
106
+ Math.hypot(q.position.x - want.x, q.position.y - want.y) <= POS_TOL);
107
+ if (match) {
108
+ twin.set(p, match);
109
+ twin.set(match, p);
110
+ }
111
+ }
112
+ for (const p of pieces) {
113
+ if (!p.keystones?.length)
114
+ continue;
115
+ const label = `${layout.id}/${p.id ?? p.name ?? "?"}`;
116
+ if (p.parent_area_id) {
117
+ warnings.push(`${label}: keystones on a parented feature — pair by hand`);
118
+ continue;
119
+ }
120
+ const t = twin.get(p);
121
+ if (!t) {
122
+ const onCentre = Math.abs(p.position.x - BOARD.width / 2) < CENTRE_TOL &&
123
+ Math.abs(p.position.y - BOARD.height / 2) < CENTRE_TOL;
124
+ if (!onCentre)
125
+ warnings.push(`${label}: no symmetry twin found — pair by hand`);
126
+ continue; // centre pieces are their own mirror; nothing to do
127
+ }
128
+ const pv = boardVertices(p, byId);
129
+ const tv = boardVertices(t, byId);
130
+ if (!pv || !tv) {
131
+ warnings.push(`${label}: missing footprint on the pair — pair by hand`);
132
+ continue;
133
+ }
134
+ for (const k of p.keystones) {
135
+ const m = mirrorKeystone(k, pv, tv);
136
+ if ("error" in m) {
137
+ warnings.push(`${label} [${k.edge}/${JSON.stringify(k.ref)}]: ${m.error}`);
138
+ continue;
139
+ }
140
+ if (hasKeystone(t, m.keystone))
141
+ continue;
142
+ additions.push({
143
+ layoutId: layout.id,
144
+ pieceId: t.id ?? "?",
145
+ fromPieceId: p.id ?? "?",
146
+ keystone: m.keystone,
147
+ });
148
+ if (apply)
149
+ t.keystones = [...(t.keystones ?? []), m.keystone];
150
+ }
151
+ }
152
+ }
153
+ return { additions, warnings };
154
+ }
155
+ // ---- CLI -------------------------------------------------------------------
156
+ function main() {
157
+ const check = process.argv.includes("--check");
158
+ const layouts = JSON.parse(readFileSync(LAYOUTS_PATH, "utf8"));
159
+ const templates = JSON.parse(readFileSync(TEMPLATES_PATH, "utf8"));
160
+ const report = pairKeystones(layouts, templates, !check);
161
+ for (const w of report.warnings)
162
+ console.warn(`warn: ${w}`);
163
+ const byLayout = new Map();
164
+ for (const a of report.additions)
165
+ byLayout.set(a.layoutId, (byLayout.get(a.layoutId) ?? 0) + 1);
166
+ for (const [id, n] of byLayout)
167
+ console.log(`${check ? "missing" : "added"}: ${id} — ${n} keystone(s)`);
168
+ console.log(`${report.additions.length} mirrored keystone(s) ${check ? "missing" : "added"} across ${byLayout.size} layout(s); ${report.warnings.length} warning(s)`);
169
+ if (check) {
170
+ if (report.additions.length > 0)
171
+ process.exit(1);
172
+ return;
173
+ }
174
+ if (report.additions.length > 0) {
175
+ writeFileSync(LAYOUTS_PATH, JSON.stringify(layouts, null, 2) + "\n");
176
+ console.log(`wrote ${LAYOUTS_PATH}`);
177
+ }
178
+ }
179
+ // Direct-invocation entry point (`npx tsx src/migrate-keystone-twins.ts [--check]`).
180
+ const isMain = process.argv[1] &&
181
+ resolve(process.argv[1]).replace(/\.\w+$/, "") === fileURLToPath(import.meta.url).replace(/\.\w+$/, "");
182
+ if (isMain)
183
+ main();
184
+ //# sourceMappingURL=migrate-keystone-twins.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrate-keystone-twins.js","sourceRoot":"","sources":["../src/migrate-keystone-twins.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Keystone twin pairing for `data/core/terrain-layouts.json`: every authored\n * measurement keystone should have its point-reflected counterpart on the\n * piece's 180°-symmetry twin, so a layout card prints dimension lines for\n * BOTH players' halves (the layout editor now maintains this invariant on\n * add/remove; this script back-fills the existing data and re-audits it).\n *\n * Twins are not stored — they are recovered the way the editor's\n * `autoPairTwins` does: same template, unparented, centroid within 0.75″ of\n * the point reflection through the board centre. The mirrored keystone flips\n * its board edge (left↔right, top↔bottom); a face ref flips its side, and a\n * vertex ref is resolved GEOMETRICALLY — reflect the source vertex through\n * the centre and take the twin's nearest vertex. Index arithmetic is not\n * safe: many layouts store the twin at the same angle (not θ+180), so vertex\n * i on one side need not be vertex i on the other. A reflection that lands\n * more than 0.25″ from every twin vertex is skipped with a warning rather\n * than guessed.\n *\n * Usage (from `tools/`):\n * npx tsx src/migrate-keystone-twins.ts # write missing mirrors\n * npx tsx src/migrate-keystone-twins.ts --check # report only; exit 1 if any are missing\n */\nimport { readFileSync, writeFileSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport {\n orientedOffsets,\n type BoardEdge,\n type Footprint,\n type Keystone,\n type LayoutPiece,\n type TerrainLayout,\n type TerrainTemplate,\n type Vec2,\n} from \"./terrain/resolve.js\";\n\nconst BOARD = { width: 60, height: 44 } as const;\n/** Twin pairing tolerance on the point-reflected centroid (matches the editor). */\nconst POS_TOL = 0.75;\n/** A piece this close to the board centre is self-symmetric and has no twin. */\nconst CENTRE_TOL = 0.3;\n/** How close (inches) the reflected vertex must land to a twin vertex. */\nconst VERT_EPS = 0.25;\n\nconst REPO_ROOT = join(new URL(\"../..\", import.meta.url).pathname);\nconst LAYOUTS_PATH = join(REPO_ROOT, \"data\", \"core\", \"terrain-layouts.json\");\nconst TEMPLATES_PATH = join(REPO_ROOT, \"data\", \"core\", \"terrain-templates.json\");\n\n// ---- geometry --------------------------------------------------------------\n\nconst flipEdge = (e: BoardEdge): BoardEdge =>\n e === \"left\" ? \"right\" : e === \"right\" ? \"left\" : e === \"top\" ? \"bottom\" : \"top\";\nconst flipSide = (s: \"min-x\" | \"max-x\" | \"min-y\" | \"max-y\"): \"min-x\" | \"max-x\" | \"min-y\" | \"max-y\" =>\n s === \"min-x\" ? \"max-x\" : s === \"max-x\" ? \"min-x\" : s === \"min-y\" ? \"max-y\" : \"min-y\";\n\nconst sameRef = (a: Keystone[\"ref\"], b: Keystone[\"ref\"]): boolean =>\n a.kind === \"vertex\" && b.kind === \"vertex\"\n ? a.index === b.index\n : a.kind === \"face\" && b.kind === \"face\"\n ? a.side === b.side\n : false;\nconst hasKeystone = (p: LayoutPiece, k: Keystone): boolean =>\n (p.keystones ?? []).some((e) => e.edge === k.edge && sameRef(e.ref, k.ref));\n\n/** Board-space vertices of an UNPARENTED piece (centroid + oriented offsets). */\nfunction boardVertices(p: LayoutPiece, templates: Map<string, TerrainTemplate>): Vec2[] | null {\n const fp: Footprint | undefined = p.footprint ?? (p.template ? templates.get(p.template)?.footprint : undefined);\n if (!fp) return null;\n const offsets = orientedOffsets(fp, p.rotation_degrees ?? 0, p.mirror ?? \"none\");\n return offsets.map((o) => ({ x: p.position.x + o.x, y: p.position.y + o.y }));\n}\n\n/**\n * The keystone `k` mirrored from `primary` onto `twin`, or null (with a\n * reason) when the vertex reflection has no close-enough twin vertex.\n */\nfunction mirrorKeystone(\n k: Keystone,\n primaryVerts: Vec2[],\n twinVerts: Vec2[],\n): { keystone: Keystone } | { error: string } {\n const edge = flipEdge(k.edge);\n if (k.ref.kind === \"face\") return { keystone: { edge, ref: { kind: \"face\", side: flipSide(k.ref.side) } } };\n const anchor = primaryVerts[k.ref.index];\n if (!anchor) return { error: `vertex index ${k.ref.index} out of range` };\n const reflected = { x: BOARD.width - anchor.x, y: BOARD.height - anchor.y };\n let bestIndex = -1;\n let best = Infinity;\n twinVerts.forEach((v, i) => {\n const d = Math.hypot(v.x - reflected.x, v.y - reflected.y);\n if (d < best) {\n best = d;\n bestIndex = i;\n }\n });\n if (bestIndex < 0 || best > VERT_EPS) {\n return { error: `reflected vertex lands ${best.toFixed(2)}″ from the nearest twin vertex (> ${VERT_EPS}″)` };\n }\n return { keystone: { edge, ref: { kind: \"vertex\", index: bestIndex } } };\n}\n\n// ---- pairing + audit -------------------------------------------------------\n\nexport interface PairingAddition {\n layoutId: string;\n /** The piece RECEIVING the mirrored keystone. */\n pieceId: string;\n /** The piece whose keystone was mirrored. */\n fromPieceId: string;\n keystone: Keystone;\n}\n\nexport interface PairingReport {\n /** Mirrors that are missing (check mode) / were added (write mode). */\n additions: PairingAddition[];\n /** Keystone-bearing pieces we could not pair or mirror, with the reason. */\n warnings: string[];\n}\n\n/**\n * Scan every layout for keystones whose symmetry twin lacks the mirror.\n * `apply` mutates the layout objects in place (appending the mirrors);\n * check mode leaves them untouched. Either way the report lists every\n * addition and every piece that could not be handled.\n */\nexport function pairKeystones(\n layouts: TerrainLayout[],\n templates: TerrainTemplate[],\n apply: boolean,\n): PairingReport {\n const byId = new Map(templates.map((t) => [t.id, t]));\n const additions: PairingAddition[] = [];\n const warnings: string[] = [];\n\n for (const layout of layouts) {\n const pieces = layout.pieces ?? [];\n // Twin pairing pass (unparented, off-centre, same template, reflected centroid).\n const twin = new Map<LayoutPiece, LayoutPiece>();\n for (const p of pieces) {\n if (twin.has(p) || p.parent_area_id) continue;\n const onCentre =\n Math.abs(p.position.x - BOARD.width / 2) < CENTRE_TOL &&\n Math.abs(p.position.y - BOARD.height / 2) < CENTRE_TOL;\n if (onCentre) continue;\n const want = { x: BOARD.width - p.position.x, y: BOARD.height - p.position.y };\n const match = pieces.find(\n (q) =>\n q !== p &&\n !twin.has(q) &&\n !q.parent_area_id &&\n q.template === p.template &&\n Math.hypot(q.position.x - want.x, q.position.y - want.y) <= POS_TOL,\n );\n if (match) {\n twin.set(p, match);\n twin.set(match, p);\n }\n }\n\n for (const p of pieces) {\n if (!p.keystones?.length) continue;\n const label = `${layout.id}/${p.id ?? p.name ?? \"?\"}`;\n if (p.parent_area_id) {\n warnings.push(`${label}: keystones on a parented feature — pair by hand`);\n continue;\n }\n const t = twin.get(p);\n if (!t) {\n const onCentre =\n Math.abs(p.position.x - BOARD.width / 2) < CENTRE_TOL &&\n Math.abs(p.position.y - BOARD.height / 2) < CENTRE_TOL;\n if (!onCentre) warnings.push(`${label}: no symmetry twin found — pair by hand`);\n continue; // centre pieces are their own mirror; nothing to do\n }\n const pv = boardVertices(p, byId);\n const tv = boardVertices(t, byId);\n if (!pv || !tv) {\n warnings.push(`${label}: missing footprint on the pair — pair by hand`);\n continue;\n }\n for (const k of p.keystones) {\n const m = mirrorKeystone(k, pv, tv);\n if (\"error\" in m) {\n warnings.push(`${label} [${k.edge}/${JSON.stringify(k.ref)}]: ${m.error}`);\n continue;\n }\n if (hasKeystone(t, m.keystone)) continue;\n additions.push({\n layoutId: layout.id,\n pieceId: t.id ?? \"?\",\n fromPieceId: p.id ?? \"?\",\n keystone: m.keystone,\n });\n if (apply) t.keystones = [...(t.keystones ?? []), m.keystone];\n }\n }\n }\n return { additions, warnings };\n}\n\n// ---- CLI -------------------------------------------------------------------\n\nfunction main(): void {\n const check = process.argv.includes(\"--check\");\n const layouts = JSON.parse(readFileSync(LAYOUTS_PATH, \"utf8\")) as TerrainLayout[];\n const templates = JSON.parse(readFileSync(TEMPLATES_PATH, \"utf8\")) as TerrainTemplate[];\n\n const report = pairKeystones(layouts, templates, !check);\n\n for (const w of report.warnings) console.warn(`warn: ${w}`);\n const byLayout = new Map<string, number>();\n for (const a of report.additions) byLayout.set(a.layoutId, (byLayout.get(a.layoutId) ?? 0) + 1);\n for (const [id, n] of byLayout) console.log(`${check ? \"missing\" : \"added\"}: ${id} — ${n} keystone(s)`);\n console.log(\n `${report.additions.length} mirrored keystone(s) ${check ? \"missing\" : \"added\"} across ${byLayout.size} layout(s); ${report.warnings.length} warning(s)`,\n );\n\n if (check) {\n if (report.additions.length > 0) process.exit(1);\n return;\n }\n if (report.additions.length > 0) {\n writeFileSync(LAYOUTS_PATH, JSON.stringify(layouts, null, 2) + \"\\n\");\n console.log(`wrote ${LAYOUTS_PATH}`);\n }\n}\n\n// Direct-invocation entry point (`npx tsx src/migrate-keystone-twins.ts [--check]`).\nconst isMain =\n process.argv[1] &&\n resolve(process.argv[1]).replace(/\\.\\w+$/, \"\") === fileURLToPath(import.meta.url).replace(/\\.\\w+$/, \"\");\nif (isMain) main();\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":";AAsBA,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAoC5C,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AA8C3B,MAAM,MAAM,cAAc,GACtB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAC5B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,UAAU,EAAE,SAAS,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAElE,QAAA,MAAM,WAAW,oJASP,CAAC;AACX,KAAK,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAgB9C,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,GAAG,CAAC;CACjB;AAED,wBAAgB,iBAAiB,IAAI,WAAW,CAE/C;AAmID;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,CAAC,EACG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GACrG,IAAI,GACJ,SAAS,GACZ,MAAM,GAAG,IAAI,CAQf;AA8fD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,cAAc,CA4ChG;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc9E"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":";AAsBA,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAqC5C,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AA8C3B,MAAM,MAAM,cAAc,GACtB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAC5B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,UAAU,EAAE,SAAS,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAElE,QAAA,MAAM,WAAW,oJASP,CAAC;AACX,KAAK,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAgB9C,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,GAAG,CAAC;CACjB;AAED,wBAAgB,iBAAiB,IAAI,WAAW,CAE/C;AAmID;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,CAAC,EACG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GACrG,IAAI,GACJ,SAAS,GACZ,MAAM,GAAG,IAAI,CAQf;AA0kBD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,cAAc,CAgDhG;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc9E"}
package/dist/runner.js CHANGED
@@ -26,6 +26,7 @@ import { exportRoster } from "./export/index.js";
26
26
  import { importRoster, tryImportRoster } from "./import/import-roster.js";
27
27
  import { createValidator } from "./schema-loader.js";
28
28
  import { attributeStages, crunch } from "./cruncher/index.js";
29
+ import { compareCell, loadoutCell } from "./compare.js";
29
30
  import { describeScoringCard, describeAbility } from "./translate/index.js";
30
31
  import { awardsOf, scoreTurn, scoreCap, scoreSecondaryEvent, scorePrimaryEvent, emptyPlayerGame, addToHand, scoreSecondary, removeScore, setPrimary, playerPrimary, playerSecondary, playerTotal, wtcResult, } from "./scoring/index.js";
31
32
  import { resolveLayout, TerrainResolveError, keystoneMeasurements, TerrainKeystoneError, BOARD_INCHES, } from "./terrain/index.js";
@@ -482,6 +483,62 @@ function handleAttribution(state, args) {
482
483
  return err("CRUNCH_ERROR", { detail: e.message });
483
484
  }
484
485
  }
486
+ function handleCompare(state, args) {
487
+ if (typeof args !== "object" || args === null) {
488
+ return err("INVALID_INPUT", { detail: "compare args must be an object" });
489
+ }
490
+ const a = args;
491
+ const at = a.attacker ?? {};
492
+ if (typeof at.factionId !== "string" ||
493
+ typeof at.unitId !== "string" ||
494
+ typeof at.weaponId !== "string" ||
495
+ typeof at.profileIndex !== "number" ||
496
+ typeof a.targetProfileId !== "string" ||
497
+ typeof a.distance !== "number" ||
498
+ (a.phase !== "shooting" && a.phase !== "fight")) {
499
+ return err("INVALID_INPUT", { detail: "compare: malformed attacker/target/distance/phase" });
500
+ }
501
+ try {
502
+ const cell = compareCell(getDataset(state), {
503
+ factionId: at.factionId,
504
+ unitId: at.unitId,
505
+ weaponId: at.weaponId,
506
+ profileIndex: at.profileIndex,
507
+ targetProfileId: a.targetProfileId,
508
+ distance: a.distance,
509
+ phase: a.phase,
510
+ modelsFiring: typeof a.modelsFiring === "number" ? a.modelsFiring : 1,
511
+ });
512
+ return ok(cell);
513
+ }
514
+ catch (e) {
515
+ return err("UNKNOWN_ENTITY", { detail: e.message });
516
+ }
517
+ }
518
+ function handleLoadout(state, args) {
519
+ if (typeof args !== "object" || args === null) {
520
+ return err("INVALID_INPUT", { detail: "loadout args must be an object" });
521
+ }
522
+ const a = args;
523
+ if (!Array.isArray(a.lines) ||
524
+ typeof a.targetProfileId !== "string" ||
525
+ typeof a.distance !== "number" ||
526
+ (a.phase !== "shooting" && a.phase !== "fight")) {
527
+ return err("INVALID_INPUT", { detail: "loadout: malformed lines/target/distance/phase" });
528
+ }
529
+ try {
530
+ const cell = loadoutCell(getDataset(state), {
531
+ lines: a.lines,
532
+ targetProfileId: a.targetProfileId,
533
+ distance: a.distance,
534
+ phase: a.phase,
535
+ });
536
+ return ok(cell);
537
+ }
538
+ catch (e) {
539
+ return err("UNKNOWN_ENTITY", { detail: e.message });
540
+ }
541
+ }
485
542
  function handleTranslateScoring(state, args) {
486
543
  if (typeof args !== "object" || args === null) {
487
544
  return err("INVALID_INPUT", { detail: "translate_scoring args must be an object" });
@@ -747,6 +804,10 @@ export function dispatch(state, req) {
747
804
  return handleValidate(state, req.args);
748
805
  case "crunch":
749
806
  return handleCrunch(state, req.args);
807
+ case "compare":
808
+ return handleCompare(state, req.args);
809
+ case "loadout":
810
+ return handleLoadout(state, req.args);
750
811
  case "attribution":
751
812
  return handleAttribution(state, req.args);
752
813
  case "translate_scoring":