@alpaca-software/40kdc-data 0.3.2 → 0.4.5

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 (102) hide show
  1. package/README.md +12 -6
  2. package/dist/bundle-schemas.d.ts.map +1 -1
  3. package/dist/bundle-schemas.js +17 -0
  4. package/dist/bundle-schemas.js.map +1 -1
  5. package/dist/cli.js +5 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/codegen-data.js +1 -0
  8. package/dist/codegen-data.js.map +1 -1
  9. package/dist/commands/populate-base-sizes.d.ts +2 -0
  10. package/dist/commands/populate-base-sizes.d.ts.map +1 -0
  11. package/dist/commands/populate-base-sizes.js +158 -0
  12. package/dist/commands/populate-base-sizes.js.map +1 -0
  13. package/dist/convert-faction.d.ts +3 -1
  14. package/dist/convert-faction.d.ts.map +1 -1
  15. package/dist/convert-faction.js +49 -16
  16. package/dist/convert-faction.js.map +1 -1
  17. package/dist/converters/base-size-bridge.d.ts +122 -0
  18. package/dist/converters/base-size-bridge.d.ts.map +1 -0
  19. package/dist/converters/base-size-bridge.js +198 -0
  20. package/dist/converters/base-size-bridge.js.map +1 -0
  21. package/dist/converters/base-size-guide-extract.d.ts +11 -0
  22. package/dist/converters/base-size-guide-extract.d.ts.map +1 -0
  23. package/dist/converters/base-size-guide-extract.js +59 -0
  24. package/dist/converters/base-size-guide-extract.js.map +1 -0
  25. package/dist/converters/option-bridge.d.ts +36 -0
  26. package/dist/converters/option-bridge.d.ts.map +1 -0
  27. package/dist/converters/option-bridge.js +72 -0
  28. package/dist/converters/option-bridge.js.map +1 -0
  29. package/dist/converters/option-parser.d.ts +56 -0
  30. package/dist/converters/option-parser.d.ts.map +1 -0
  31. package/dist/converters/option-parser.js +209 -0
  32. package/dist/converters/option-parser.js.map +1 -0
  33. package/dist/converters/wargear-options.d.ts +55 -0
  34. package/dist/converters/wargear-options.d.ts.map +1 -0
  35. package/dist/converters/wargear-options.js +187 -0
  36. package/dist/converters/wargear-options.js.map +1 -0
  37. package/dist/data/bundle.generated.js +1 -1
  38. package/dist/data/bundle.generated.js.map +1 -1
  39. package/dist/data/dataset.d.ts +9 -1
  40. package/dist/data/dataset.d.ts.map +1 -1
  41. package/dist/data/dataset.js +14 -0
  42. package/dist/data/dataset.js.map +1 -1
  43. package/dist/data/entities.d.ts +3 -1
  44. package/dist/data/entities.d.ts.map +1 -1
  45. package/dist/data/entities.js +4 -0
  46. package/dist/data/entities.js.map +1 -1
  47. package/dist/data/index.d.ts +4 -0
  48. package/dist/data/index.d.ts.map +1 -1
  49. package/dist/data/index.js +4 -0
  50. package/dist/data/index.js.map +1 -1
  51. package/dist/data/loadout.d.ts +60 -0
  52. package/dist/data/loadout.d.ts.map +1 -0
  53. package/dist/data/loadout.js +135 -0
  54. package/dist/data/loadout.js.map +1 -0
  55. package/dist/data/types.d.ts +3 -1
  56. package/dist/data/types.d.ts.map +1 -1
  57. package/dist/data/types.js +1 -0
  58. package/dist/data/types.js.map +1 -1
  59. package/dist/export/rosterizer.js +1 -1
  60. package/dist/export/rosterizer.js.map +1 -1
  61. package/dist/gen-conformance.js +171 -0
  62. package/dist/gen-conformance.js.map +1 -1
  63. package/dist/generated.d.ts +135 -55
  64. package/dist/generated.d.ts.map +1 -1
  65. package/dist/generated.js.map +1 -1
  66. package/dist/import/rosterizer.d.ts +1 -1
  67. package/dist/import/rosterizer.js.map +1 -1
  68. package/dist/index.d.ts +3 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +3 -3
  71. package/dist/index.js.map +1 -1
  72. package/dist/runner.d.ts +16 -0
  73. package/dist/runner.d.ts.map +1 -1
  74. package/dist/runner.js +216 -0
  75. package/dist/runner.js.map +1 -1
  76. package/dist/scoring/index.d.ts +28 -6
  77. package/dist/scoring/index.d.ts.map +1 -1
  78. package/dist/scoring/index.js +31 -7
  79. package/dist/scoring/index.js.map +1 -1
  80. package/dist/terrain/index.d.ts +2 -2
  81. package/dist/terrain/index.d.ts.map +1 -1
  82. package/dist/terrain/index.js +1 -1
  83. package/dist/terrain/index.js.map +1 -1
  84. package/dist/terrain/solve.d.ts +41 -0
  85. package/dist/terrain/solve.d.ts.map +1 -1
  86. package/dist/terrain/solve.js +100 -0
  87. package/dist/terrain/solve.js.map +1 -1
  88. package/dist/translate/condition.d.ts.map +1 -1
  89. package/dist/translate/condition.js +4 -0
  90. package/dist/translate/condition.js.map +1 -1
  91. package/dist/validate.d.ts.map +1 -1
  92. package/dist/validate.js +13 -5
  93. package/dist/validate.js.map +1 -1
  94. package/package.json +5 -5
  95. package/schemas/$defs/common.schema.json +14 -0
  96. package/schemas/core/secondary-card.schema.json +10 -0
  97. package/schemas/core/terrain-layout.schema.json +18 -0
  98. package/schemas/core/unit-composition.schema.json +5 -1
  99. package/schemas/core/unit.schema.json +2 -10
  100. package/schemas/core/wargear-option.schema.json +32 -6
  101. package/schemas/core/wargear.schema.json +24 -0
  102. package/schemas/enrichment/ability-dsl/condition.schema.json +3 -2
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"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"}
@@ -21,10 +21,10 @@
21
21
  * `PlayerGame` is a plain JSON-serializable object so a UI can persist a whole
22
22
  * match (two of them) to localStorage and rehydrate without a revival step.
23
23
  *
24
- * CONFORMANCE FOLLOW-UP: this engine is TypeScript-only for now. A Rust port
25
- * plus a `conformance/scoring` corpus area (and a `SPEC_VERSION` bump) are a
26
- * separate change; the public shapes below are the surface that port mirrors,
27
- * so keep them stable.
24
+ * CONFORMANCE: mirrored by the Rust `wh40kdc::scoring` module and pinned by the
25
+ * `conformance/scoring` corpus (this is the reference/oracle implementation).
26
+ * The public shapes below are the surface that port mirrors — changing them is
27
+ * a cross-impl, `SPEC_VERSION`-bumping change.
28
28
  */
29
29
  /** The Tactical approach caps a single secondary's score at this many VP. */
30
30
  export const TACTICAL_CARD_CAP = 5;
@@ -111,6 +111,18 @@ export function scoreCap(card, approach) {
111
111
  export function scoreSecondaryEvent(asserted, card, approach) {
112
112
  return Math.min(scoreTurn(asserted), scoreCap(card, approach));
113
113
  }
114
+ /**
115
+ * The primary VP a single battle round's scoring grants: the asserted awards'
116
+ * total, clamped to the per-round cap (`mission.vp_per_round_cap`, 15 by 11e
117
+ * default). Unlike a secondary, a primary is scored once *per round* against the
118
+ * same card, so the ceiling is the round cap rather than a per-card cap — there
119
+ * is no tactical 5-VP rule on primary. The per-game primary cap
120
+ * (`vp_per_game_cap`) is applied separately by {@link setPrimary}, which sees
121
+ * the other rounds' totals.
122
+ */
123
+ export function scorePrimaryEvent(asserted, roundCap) {
124
+ return Math.min(scoreTurn(asserted), roundCap);
125
+ }
114
126
  function roundIndex(round) {
115
127
  return Math.max(0, Math.min(ROUNDS - 1, Math.trunc(round) - 1));
116
128
  }
@@ -150,10 +162,22 @@ export function removeScore(pg, index) {
150
162
  : [...pg.handIds, entry.cardId];
151
163
  return { ...pg, rounds, log, handIds };
152
164
  }
153
- /** Set primary VP for a battle round (1-based) to a clamped value. Pure. */
154
- export function setPrimary(pg, round, vp) {
165
+ /**
166
+ * Set primary VP for a battle round (1-based) to a clamped value. Pure.
167
+ *
168
+ * `caps` bounds the stored value by both the per-round ceiling (`roundCap`,
169
+ * `mission.vp_per_round_cap`) and the remaining per-game primary room
170
+ * (`gameCap`, `mission.vp_per_game_cap`) — the latter computed against the
171
+ * *other* rounds' primary, so no sequence of round scores can push the primary
172
+ * game total past `gameCap`. With no caps both default to `Infinity`, leaving
173
+ * only the floor-at-zero clamp (the historical behavior).
174
+ */
175
+ export function setPrimary(pg, round, vp, caps = {}) {
155
176
  const i = roundIndex(round);
156
- const rounds = pg.rounds.map((c, idx) => idx === i ? { ...c, primary: Math.max(0, vp) } : c);
177
+ const others = pg.rounds.reduce((s, c, idx) => (idx === i ? s : s + c.primary), 0);
178
+ const room = Math.max(0, Math.min(caps.roundCap ?? Infinity, (caps.gameCap ?? Infinity) - others));
179
+ const clamped = Math.max(0, Math.min(vp, room));
180
+ const rounds = pg.rounds.map((c, idx) => (idx === i ? { ...c, primary: clamped } : c));
157
181
  return { ...pg, rounds };
158
182
  }
159
183
  /** Put a drawn card in hand (no duplicates). Pure. */
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Card-driven secondary-mission scoring, 10th-edition tactical model.\n *\n * Drawn secondaries are *held* in hand across rounds and **scored once**: the\n * player asserts which of a card's awards they achieved, the engine computes the\n * VP (clamped to the card's cap), records it against the current battle round,\n * and the card is then discarded. There is no multi-turn per-card accrual — a\n * card pays out exactly once.\n *\n * Why \"asserted\" rather than evaluated: there is no board-state model here, so\n * an award's `when` condition is a human-readable label (see\n * `translate/scoring.ts`'s `describeScoringCard`, which this module never\n * modifies), not something the engine checks. The player ticks the awards they\n * made; the engine does the arithmetic, the OR-tier resolution, the cumulative\n * sums, and the cap.\n *\n * Deck-level rules the card schema deliberately omits live here as constants —\n * chiefly the 5 VP-per-card ceiling of the Tactical approach. The Fixed approach\n * instead uses each award's printed `vp_max`.\n *\n * `PlayerGame` is a plain JSON-serializable object so a UI can persist a whole\n * match (two of them) to localStorage and rehydrate without a revival step.\n *\n * CONFORMANCE FOLLOW-UP: this engine is TypeScript-only for now. A Rust port\n * plus a `conformance/scoring` corpus area (and a `SPEC_VERSION` bump) are a\n * separate change; the public shapes below are the surface that port mirrors,\n * so keep them stable.\n */\n\nimport type { SecondaryCard } from \"../generated.js\";\nimport type { ScoringAward, ScoringMode } from \"../translate/scoring.js\";\n\n/** The Tactical approach caps a single secondary's score at this many VP. */\nexport const TACTICAL_CARD_CAP = 5;\n/** Battle rounds in a game. */\nexport const ROUNDS = 5;\n/** Per-player VP ceiling (WTC sheet: grand total out of 100). */\nexport const GAME_VP_CAP = 100;\n\n/** An award the player ticks when scoring, with a count for per-instance awards. */\nexport interface AssertedAward {\n award: ScoringAward;\n /** Instances achieved (for `vp_per` awards); defaults to 1. */\n count?: number;\n}\n\n/** VP recorded against a single battle round. */\nexport interface RoundCell {\n primary: number;\n secondary: number;\n}\n\n/** A scored secondary, kept so the record can be shown and undone. */\nexport interface ScoreEntry {\n cardId: string;\n /** Battle round (1-based) the card was scored in. */\n round: number;\n vp: number;\n}\n\n/** One player's whole-game scoring state. Plain data — safe to JSON round-trip. */\nexport interface PlayerGame {\n /** Scoring approach: filters `mode` awards and sets the per-score cap. */\n approach: ScoringMode;\n /** Drawn-but-unscored secondaries, by card id. Scoring removes a card from here. */\n handIds: string[];\n /** Per-round VP, index 0 = round 1. Always length {@link ROUNDS}. */\n rounds: RoundCell[];\n /** Log of scored secondaries, in scoring order — the editable record. */\n log: ScoreEntry[];\n}\n\n/** A fresh player game for the given approach (defaults to tactical). */\nexport function emptyPlayerGame(approach: ScoringMode = \"tactical\"): PlayerGame {\n return {\n approach,\n handIds: [],\n rounds: Array.from({ length: ROUNDS }, () => ({ primary: 0, secondary: 0 })),\n log: [],\n };\n}\n\n/** Read a card's `awards`, typed (the generated `SecondaryCard` leaves them opaque). */\nexport function awardsOf(card: SecondaryCard): ScoringAward[] {\n return (card.awards ?? []) as unknown as ScoringAward[];\n}\n\n/**\n * The awards a player scores under `approach`. An award with no `mode` is flat\n * (it scores the same either way); an award tagged `fixed`/`tactical` scores\n * only under the matching approach.\n */\nexport function awardsForApproach(card: SecondaryCard, approach: ScoringMode): ScoringAward[] {\n return awardsOf(card).filter((a) => a.mode == null || a.mode === approach);\n}\n\n/**\n * VP for a single asserted award. A flat `vp` ignores `count`; a `vp_per` award\n * scores `vp_per × count`, with `count` clamped to `per_max` when present.\n */\nexport function scoreAward(award: ScoringAward, count = 1): number {\n if (award.vp != null) return award.vp;\n if (award.vp_per != null) {\n const capped = award.per_max != null ? Math.min(count, award.per_max) : count;\n return award.vp_per * Math.max(0, capped);\n }\n return 0;\n}\n\n/**\n * VP from everything asserted in one scoring, before the card cap. Awards\n * sharing an `exclusive_group` resolve as \"only the highest scores\" (the card's\n * literal OR between tier rows); everything else, including `cumulative` \"+\"\n * rows, sums.\n */\nexport function scoreTurn(asserted: AssertedAward[]): number {\n const groupBest = new Map<string, number>();\n let total = 0;\n for (const { award, count } of asserted) {\n const v = scoreAward(award, count ?? 1);\n if (award.exclusive_group != null) {\n const prev = groupBest.get(award.exclusive_group) ?? 0;\n if (v > prev) groupBest.set(award.exclusive_group, v);\n } else {\n total += v;\n }\n }\n for (const v of groupBest.values()) total += v;\n return total;\n}\n\n/**\n * A card's per-score VP ceiling under `approach`. Tactical is the universal\n * {@link TACTICAL_CARD_CAP}. Fixed uses the largest `vp_max` printed on the\n * card's scorable awards, or `Infinity` when none is printed (uncapped).\n */\nexport function scoreCap(card: SecondaryCard, approach: ScoringMode): number {\n if (approach === \"tactical\") return TACTICAL_CARD_CAP;\n const caps = awardsForApproach(card, \"fixed\")\n .map((a) => a.vp_max)\n .filter((x): x is number => x != null);\n return caps.length > 0 ? Math.max(...caps) : Infinity;\n}\n\n/**\n * The VP a single scoring of `card` grants under `approach`: the asserted awards'\n * total, clamped to the card's cap. This is the amount banked when the card is\n * scored (and then discarded).\n */\nexport function scoreSecondaryEvent(\n asserted: AssertedAward[],\n card: SecondaryCard,\n approach: ScoringMode,\n): number {\n return Math.min(scoreTurn(asserted), scoreCap(card, approach));\n}\n\nfunction roundIndex(round: number): number {\n return Math.max(0, Math.min(ROUNDS - 1, Math.trunc(round) - 1));\n}\n\n/** Add secondary VP to a battle round (1-based). Pure — returns new state. */\nexport function recordSecondary(pg: PlayerGame, round: number, vp: number): PlayerGame {\n const i = roundIndex(round);\n const rounds = pg.rounds.map((c, idx) =>\n idx === i ? { ...c, secondary: c.secondary + Math.max(0, vp) } : c,\n );\n return { ...pg, rounds };\n}\n\n/**\n * Score a held secondary: add its VP to the round, append it to the log, and\n * discard it from hand. Pure. The caller computes `vp` via\n * {@link scoreSecondaryEvent}.\n */\nexport function scoreSecondary(\n pg: PlayerGame,\n round: number,\n cardId: string,\n vp: number,\n): PlayerGame {\n const banked = Math.max(0, vp);\n const recorded = recordSecondary(pg, round, banked);\n return {\n ...removeFromHand(recorded, cardId),\n log: [...pg.log, { cardId, round, vp: banked }],\n };\n}\n\n/**\n * Undo a logged scoring by index: subtract its VP from its round, drop the log\n * entry, and return the card to hand so it can be re-scored. Pure; a no-op for\n * an out-of-range index.\n */\nexport function removeScore(pg: PlayerGame, index: number): PlayerGame {\n const entry = pg.log[index];\n if (!entry) return pg;\n const i = roundIndex(entry.round);\n const rounds = pg.rounds.map((c, idx) =>\n idx === i ? { ...c, secondary: Math.max(0, c.secondary - entry.vp) } : c,\n );\n const log = pg.log.filter((_, idx) => idx !== index);\n const handIds = pg.handIds.includes(entry.cardId)\n ? pg.handIds\n : [...pg.handIds, entry.cardId];\n return { ...pg, rounds, log, handIds };\n}\n\n/** Set primary VP for a battle round (1-based) to a clamped value. Pure. */\nexport function setPrimary(pg: PlayerGame, round: number, vp: number): PlayerGame {\n const i = roundIndex(round);\n const rounds = pg.rounds.map((c, idx) =>\n idx === i ? { ...c, primary: Math.max(0, vp) } : c,\n );\n return { ...pg, rounds };\n}\n\n/** Put a drawn card in hand (no duplicates). Pure. */\nexport function addToHand(pg: PlayerGame, cardId: string): PlayerGame {\n if (pg.handIds.includes(cardId)) return pg;\n return { ...pg, handIds: [...pg.handIds, cardId] };\n}\n\n/** Remove a card from hand (e.g. on score or discard). Pure. */\nexport function removeFromHand(pg: PlayerGame, cardId: string): PlayerGame {\n return { ...pg, handIds: pg.handIds.filter((id) => id !== cardId) };\n}\n\n/** Total primary VP across the game. */\nexport function playerPrimary(pg: PlayerGame): number {\n return pg.rounds.reduce((sum, c) => sum + c.primary, 0);\n}\n\n/** Total secondary VP across the game. */\nexport function playerSecondary(pg: PlayerGame): number {\n return pg.rounds.reduce((sum, c) => sum + c.secondary, 0);\n}\n\n/** Grand total VP, capped at {@link GAME_VP_CAP}. */\nexport function playerTotal(pg: PlayerGame): number {\n return Math.min(GAME_VP_CAP, playerPrimary(pg) + playerSecondary(pg));\n}\n\n/**\n * The WTC 20-point result from two grand totals. The winner's margin maps onto\n * 11 bands (0-5 → 10-10 draw, 6-10 → 11-9, ... 51+ → 20-0); the loser gets the\n * complement. `a`/`b` correspond to the argument order.\n */\nexport function wtcResult(totalA: number, totalB: number): { a: number; b: number } {\n const diff = Math.abs(totalA - totalB);\n const band = diff <= 5 ? 0 : Math.min(10, Math.ceil((diff - 5) / 5));\n const winner = 10 + band;\n const loser = 10 - band;\n if (totalA === totalB) return { a: 10, b: 10 };\n return totalA > totalB ? { a: winner, b: loser } : { a: loser, b: winner };\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/scoring/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Card-driven secondary-mission scoring, 10th-edition tactical model.\n *\n * Drawn secondaries are *held* in hand across rounds and **scored once**: the\n * player asserts which of a card's awards they achieved, the engine computes the\n * VP (clamped to the card's cap), records it against the current battle round,\n * and the card is then discarded. There is no multi-turn per-card accrual — a\n * card pays out exactly once.\n *\n * Why \"asserted\" rather than evaluated: there is no board-state model here, so\n * an award's `when` condition is a human-readable label (see\n * `translate/scoring.ts`'s `describeScoringCard`, which this module never\n * modifies), not something the engine checks. The player ticks the awards they\n * made; the engine does the arithmetic, the OR-tier resolution, the cumulative\n * sums, and the cap.\n *\n * Deck-level rules the card schema deliberately omits live here as constants —\n * chiefly the 5 VP-per-card ceiling of the Tactical approach. The Fixed approach\n * instead uses each award's printed `vp_max`.\n *\n * `PlayerGame` is a plain JSON-serializable object so a UI can persist a whole\n * match (two of them) to localStorage and rehydrate without a revival step.\n *\n * CONFORMANCE: mirrored by the Rust `wh40kdc::scoring` module and pinned by the\n * `conformance/scoring` corpus (this is the reference/oracle implementation).\n * The public shapes below are the surface that port mirrors — changing them is\n * a cross-impl, `SPEC_VERSION`-bumping change.\n */\n\nimport type { SecondaryCard } from \"../generated.js\";\nimport type { ScoringAward, ScoringMode } from \"../translate/scoring.js\";\n\n/** The Tactical approach caps a single secondary's score at this many VP. */\nexport const TACTICAL_CARD_CAP = 5;\n/** Battle rounds in a game. */\nexport const ROUNDS = 5;\n/** Per-player VP ceiling (WTC sheet: grand total out of 100). */\nexport const GAME_VP_CAP = 100;\n\n/** An award the player ticks when scoring, with a count for per-instance awards. */\nexport interface AssertedAward {\n award: ScoringAward;\n /** Instances achieved (for `vp_per` awards); defaults to 1. */\n count?: number;\n}\n\n/** VP recorded against a single battle round. */\nexport interface RoundCell {\n primary: number;\n secondary: number;\n}\n\n/** A scored secondary, kept so the record can be shown and undone. */\nexport interface ScoreEntry {\n cardId: string;\n /** Battle round (1-based) the card was scored in. */\n round: number;\n vp: number;\n}\n\n/** One player's whole-game scoring state. Plain data — safe to JSON round-trip. */\nexport interface PlayerGame {\n /** Scoring approach: filters `mode` awards and sets the per-score cap. */\n approach: ScoringMode;\n /** Drawn-but-unscored secondaries, by card id. Scoring removes a card from here. */\n handIds: string[];\n /** Per-round VP, index 0 = round 1. Always length {@link ROUNDS}. */\n rounds: RoundCell[];\n /** Log of scored secondaries, in scoring order — the editable record. */\n log: ScoreEntry[];\n}\n\n/** A fresh player game for the given approach (defaults to tactical). */\nexport function emptyPlayerGame(approach: ScoringMode = \"tactical\"): PlayerGame {\n return {\n approach,\n handIds: [],\n rounds: Array.from({ length: ROUNDS }, () => ({ primary: 0, secondary: 0 })),\n log: [],\n };\n}\n\n/** Read a card's `awards`, typed (the generated `SecondaryCard` leaves them opaque). */\nexport function awardsOf(card: SecondaryCard): ScoringAward[] {\n return (card.awards ?? []) as unknown as ScoringAward[];\n}\n\n/**\n * The awards a player scores under `approach`. An award with no `mode` is flat\n * (it scores the same either way); an award tagged `fixed`/`tactical` scores\n * only under the matching approach.\n */\nexport function awardsForApproach(card: SecondaryCard, approach: ScoringMode): ScoringAward[] {\n return awardsOf(card).filter((a) => a.mode == null || a.mode === approach);\n}\n\n/**\n * VP for a single asserted award. A flat `vp` ignores `count`; a `vp_per` award\n * scores `vp_per × count`, with `count` clamped to `per_max` when present.\n */\nexport function scoreAward(award: ScoringAward, count = 1): number {\n if (award.vp != null) return award.vp;\n if (award.vp_per != null) {\n const capped = award.per_max != null ? Math.min(count, award.per_max) : count;\n return award.vp_per * Math.max(0, capped);\n }\n return 0;\n}\n\n/**\n * VP from everything asserted in one scoring, before the card cap. Awards\n * sharing an `exclusive_group` resolve as \"only the highest scores\" (the card's\n * literal OR between tier rows); everything else, including `cumulative` \"+\"\n * rows, sums.\n */\nexport function scoreTurn(asserted: AssertedAward[]): number {\n const groupBest = new Map<string, number>();\n let total = 0;\n for (const { award, count } of asserted) {\n const v = scoreAward(award, count ?? 1);\n if (award.exclusive_group != null) {\n const prev = groupBest.get(award.exclusive_group) ?? 0;\n if (v > prev) groupBest.set(award.exclusive_group, v);\n } else {\n total += v;\n }\n }\n for (const v of groupBest.values()) total += v;\n return total;\n}\n\n/**\n * A card's per-score VP ceiling under `approach`. Tactical is the universal\n * {@link TACTICAL_CARD_CAP}. Fixed uses the largest `vp_max` printed on the\n * card's scorable awards, or `Infinity` when none is printed (uncapped).\n */\nexport function scoreCap(card: SecondaryCard, approach: ScoringMode): number {\n if (approach === \"tactical\") return TACTICAL_CARD_CAP;\n const caps = awardsForApproach(card, \"fixed\")\n .map((a) => a.vp_max)\n .filter((x): x is number => x != null);\n return caps.length > 0 ? Math.max(...caps) : Infinity;\n}\n\n/**\n * The VP a single scoring of `card` grants under `approach`: the asserted awards'\n * total, clamped to the card's cap. This is the amount banked when the card is\n * scored (and then discarded).\n */\nexport function scoreSecondaryEvent(\n asserted: AssertedAward[],\n card: SecondaryCard,\n approach: ScoringMode,\n): number {\n return Math.min(scoreTurn(asserted), scoreCap(card, approach));\n}\n\n/**\n * The primary VP a single battle round's scoring grants: the asserted awards'\n * total, clamped to the per-round cap (`mission.vp_per_round_cap`, 15 by 11e\n * default). Unlike a secondary, a primary is scored once *per round* against the\n * same card, so the ceiling is the round cap rather than a per-card cap — there\n * is no tactical 5-VP rule on primary. The per-game primary cap\n * (`vp_per_game_cap`) is applied separately by {@link setPrimary}, which sees\n * the other rounds' totals.\n */\nexport function scorePrimaryEvent(asserted: AssertedAward[], roundCap: number): number {\n return Math.min(scoreTurn(asserted), roundCap);\n}\n\nfunction roundIndex(round: number): number {\n return Math.max(0, Math.min(ROUNDS - 1, Math.trunc(round) - 1));\n}\n\n/** Add secondary VP to a battle round (1-based). Pure — returns new state. */\nexport function recordSecondary(pg: PlayerGame, round: number, vp: number): PlayerGame {\n const i = roundIndex(round);\n const rounds = pg.rounds.map((c, idx) =>\n idx === i ? { ...c, secondary: c.secondary + Math.max(0, vp) } : c,\n );\n return { ...pg, rounds };\n}\n\n/**\n * Score a held secondary: add its VP to the round, append it to the log, and\n * discard it from hand. Pure. The caller computes `vp` via\n * {@link scoreSecondaryEvent}.\n */\nexport function scoreSecondary(\n pg: PlayerGame,\n round: number,\n cardId: string,\n vp: number,\n): PlayerGame {\n const banked = Math.max(0, vp);\n const recorded = recordSecondary(pg, round, banked);\n return {\n ...removeFromHand(recorded, cardId),\n log: [...pg.log, { cardId, round, vp: banked }],\n };\n}\n\n/**\n * Undo a logged scoring by index: subtract its VP from its round, drop the log\n * entry, and return the card to hand so it can be re-scored. Pure; a no-op for\n * an out-of-range index.\n */\nexport function removeScore(pg: PlayerGame, index: number): PlayerGame {\n const entry = pg.log[index];\n if (!entry) return pg;\n const i = roundIndex(entry.round);\n const rounds = pg.rounds.map((c, idx) =>\n idx === i ? { ...c, secondary: Math.max(0, c.secondary - entry.vp) } : c,\n );\n const log = pg.log.filter((_, idx) => idx !== index);\n const handIds = pg.handIds.includes(entry.cardId)\n ? pg.handIds\n : [...pg.handIds, entry.cardId];\n return { ...pg, rounds, log, handIds };\n}\n\n/**\n * Set primary VP for a battle round (1-based) to a clamped value. Pure.\n *\n * `caps` bounds the stored value by both the per-round ceiling (`roundCap`,\n * `mission.vp_per_round_cap`) and the remaining per-game primary room\n * (`gameCap`, `mission.vp_per_game_cap`) — the latter computed against the\n * *other* rounds' primary, so no sequence of round scores can push the primary\n * game total past `gameCap`. With no caps both default to `Infinity`, leaving\n * only the floor-at-zero clamp (the historical behavior).\n */\nexport function setPrimary(\n pg: PlayerGame,\n round: number,\n vp: number,\n caps: { roundCap?: number; gameCap?: number } = {},\n): PlayerGame {\n const i = roundIndex(round);\n const others = pg.rounds.reduce((s, c, idx) => (idx === i ? s : s + c.primary), 0);\n const room = Math.max(0, Math.min(caps.roundCap ?? Infinity, (caps.gameCap ?? Infinity) - others));\n const clamped = Math.max(0, Math.min(vp, room));\n const rounds = pg.rounds.map((c, idx) => (idx === i ? { ...c, primary: clamped } : c));\n return { ...pg, rounds };\n}\n\n/** Put a drawn card in hand (no duplicates). Pure. */\nexport function addToHand(pg: PlayerGame, cardId: string): PlayerGame {\n if (pg.handIds.includes(cardId)) return pg;\n return { ...pg, handIds: [...pg.handIds, cardId] };\n}\n\n/** Remove a card from hand (e.g. on score or discard). Pure. */\nexport function removeFromHand(pg: PlayerGame, cardId: string): PlayerGame {\n return { ...pg, handIds: pg.handIds.filter((id) => id !== cardId) };\n}\n\n/** Total primary VP across the game. */\nexport function playerPrimary(pg: PlayerGame): number {\n return pg.rounds.reduce((sum, c) => sum + c.primary, 0);\n}\n\n/** Total secondary VP across the game. */\nexport function playerSecondary(pg: PlayerGame): number {\n return pg.rounds.reduce((sum, c) => sum + c.secondary, 0);\n}\n\n/** Grand total VP, capped at {@link GAME_VP_CAP}. */\nexport function playerTotal(pg: PlayerGame): number {\n return Math.min(GAME_VP_CAP, playerPrimary(pg) + playerSecondary(pg));\n}\n\n/**\n * The WTC 20-point result from two grand totals. The winner's margin maps onto\n * 11 bands (0-5 → 10-10 draw, 6-10 → 11-9, ... 51+ → 20-0); the loser gets the\n * complement. `a`/`b` correspond to the argument order.\n */\nexport function wtcResult(totalA: number, totalB: number): { a: number; b: number } {\n const diff = Math.abs(totalA - totalB);\n const band = diff <= 5 ? 0 : Math.min(10, Math.ceil((diff - 5) / 5));\n const winner = 10 + band;\n const loser = 10 - band;\n if (totalA === totalB) return { a: 10, b: 10 };\n return totalA > totalB ? { a: winner, b: loser } : { a: loser, b: winner };\n}\n"]}
@@ -6,6 +6,6 @@
6
6
  */
7
7
  export { resolveLayout, polygonCentroid, footprintVertices, orientedOffsets, TerrainResolveError, } from "./resolve.js";
8
8
  export type { ResolvedPiece, Vec2 as ResolvedVec2 } from "./resolve.js";
9
- export { solveCentroid, TerrainSolveError } from "./solve.js";
10
- export type { BoardEdge, FeatureRef, DimensionLine, SolveInput } from "./solve.js";
9
+ export { solveCentroid, solveCentroidTriangulated, TerrainSolveError } from "./solve.js";
10
+ export type { BoardEdge, FeatureRef, DimensionLine, SolveInput, TriangulationLine, TriangulateInput, } from "./solve.js";
11
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/terrain/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,GACpB,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,aAAa,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,cAAc,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC9D,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/terrain/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,GACpB,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,aAAa,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,cAAc,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,yBAAyB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACzF,YAAY,EACV,SAAS,EACT,UAAU,EACV,aAAa,EACb,UAAU,EACV,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,YAAY,CAAC"}
@@ -5,5 +5,5 @@
5
5
  * @packageDocumentation
6
6
  */
7
7
  export { resolveLayout, polygonCentroid, footprintVertices, orientedOffsets, TerrainResolveError, } from "./resolve.js";
8
- export { solveCentroid, TerrainSolveError } from "./solve.js";
8
+ export { solveCentroid, solveCentroidTriangulated, TerrainSolveError } from "./solve.js";
9
9
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/terrain/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["/**\n * Terrain geometry: resolve template-anchored layouts to absolute board-space\n * vertices. See {@link resolveLayout} for the transform contract.\n *\n * @packageDocumentation\n */\nexport {\n resolveLayout,\n polygonCentroid,\n footprintVertices,\n orientedOffsets,\n TerrainResolveError,\n} from \"./resolve.js\";\nexport type { ResolvedPiece, Vec2 as ResolvedVec2 } from \"./resolve.js\";\nexport { solveCentroid, TerrainSolveError } from \"./solve.js\";\nexport type { BoardEdge, FeatureRef, DimensionLine, SolveInput } from \"./solve.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/terrain/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,aAAa,EAAE,yBAAyB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["/**\n * Terrain geometry: resolve template-anchored layouts to absolute board-space\n * vertices. See {@link resolveLayout} for the transform contract.\n *\n * @packageDocumentation\n */\nexport {\n resolveLayout,\n polygonCentroid,\n footprintVertices,\n orientedOffsets,\n TerrainResolveError,\n} from \"./resolve.js\";\nexport type { ResolvedPiece, Vec2 as ResolvedVec2 } from \"./resolve.js\";\nexport { solveCentroid, solveCentroidTriangulated, TerrainSolveError } from \"./solve.js\";\nexport type {\n BoardEdge,\n FeatureRef,\n DimensionLine,\n SolveInput,\n TriangulationLine,\n TriangulateInput,\n} from \"./solve.js\";\n"]}
@@ -53,4 +53,45 @@ export declare class TerrainSolveError extends Error {
53
53
  * pin the two unknowns directly.
54
54
  */
55
55
  export declare function solveCentroid(input: SolveInput): Vec2;
56
+ /**
57
+ * One triangulation measurement: `distance` inches from board `edge` to a
58
+ * specific footprint vertex (corner). Faces are intentionally excluded — an
59
+ * arbitrarily-rotated piece has no axis-aligned face to measure to.
60
+ */
61
+ export interface TriangulationLine {
62
+ edge: BoardEdge;
63
+ distance: number;
64
+ vertex: number;
65
+ }
66
+ export interface TriangulateInput {
67
+ footprint: Footprint;
68
+ mirror: Mirror;
69
+ board: {
70
+ width: number;
71
+ height: number;
72
+ };
73
+ /**
74
+ * Three corner measurements. At least two must share an axis (left/right or
75
+ * top/bottom) to fix the angle, and at least one must pin the other axis.
76
+ */
77
+ lines: [TriangulationLine, TriangulationLine, TriangulationLine];
78
+ /** Current rotation in degrees, used to choose between the two angle roots. */
79
+ rotationHint?: number;
80
+ }
81
+ /**
82
+ * Back-solve a piece's centroid AND rotation from three card measurements to
83
+ * specific footprint corners — the inverse needed for pieces at non-90° angles,
84
+ * where the card pins three corner-to-edge distances rather than one per axis.
85
+ *
86
+ * Closed form: with the (unknown) rotation θ, each corner `v` resolves to
87
+ * `centroid + R(θ)·v`. Subtracting two same-axis measurements cancels the
88
+ * centroid and leaves `A·cosθ + B·sinθ = C`, solved as `θ = atan2(B,A) ±
89
+ * acos(C/√(A²+B²))`; the root nearest `rotationHint` is chosen. One measurement
90
+ * on each axis then pins the centroid.
91
+ */
92
+ export declare function solveCentroidTriangulated(input: TriangulateInput): {
93
+ x: number;
94
+ y: number;
95
+ rotation: number;
96
+ };
56
97
  //# sourceMappingURL=solve.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"solve.d.ts","sourceRoot":"","sources":["../../src/terrain/solve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,KAAK,IAAI,EAAE,MAAM,cAAc,CAAC;AAEvF,0FAA0F;AAC1F,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,QAAQ,CAAC;AAE5D;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAA;CAAE,CAAC;AAElE,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,SAAS,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,iFAAiF;IACjF,KAAK,EAAE,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;CACvC;AAED,qBAAa,iBAAkB,SAAQ,KAAK;CAAG;AAkD/C;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAYrD"}
1
+ {"version":3,"file":"solve.d.ts","sourceRoot":"","sources":["../../src/terrain/solve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,KAAK,IAAI,EAAE,MAAM,cAAc,CAAC;AAEvF,0FAA0F;AAC1F,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,QAAQ,CAAC;AAE5D;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAA;CAAE,CAAC;AAElE,2EAA2E;AAC3E,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,SAAS,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,2DAA2D;IAC3D,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,iFAAiF;IACjF,KAAK,EAAE,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;CACvC;AAED,qBAAa,iBAAkB,SAAQ,KAAK;CAAG;AAkD/C;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAYrD;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,SAAS,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,SAAS,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC;;;OAGG;IACH,KAAK,EAAE,CAAC,iBAAiB,EAAE,iBAAiB,EAAE,iBAAiB,CAAC,CAAC;IACjE,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,gBAAgB,GACtB;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAuF5C"}
@@ -77,4 +77,104 @@ export function solveCentroid(input) {
77
77
  const y = a.axis === "y" ? a.value : b.value;
78
78
  return { x, y };
79
79
  }
80
+ const TWO_PI = Math.PI * 2;
81
+ /** Smallest absolute angular separation between two radian angles. */
82
+ function angularGap(a, b) {
83
+ const d = (((a - b) % TWO_PI) + TWO_PI) % TWO_PI;
84
+ return Math.min(d, TWO_PI - d);
85
+ }
86
+ /**
87
+ * Back-solve a piece's centroid AND rotation from three card measurements to
88
+ * specific footprint corners — the inverse needed for pieces at non-90° angles,
89
+ * where the card pins three corner-to-edge distances rather than one per axis.
90
+ *
91
+ * Closed form: with the (unknown) rotation θ, each corner `v` resolves to
92
+ * `centroid + R(θ)·v`. Subtracting two same-axis measurements cancels the
93
+ * centroid and leaves `A·cosθ + B·sinθ = C`, solved as `θ = atan2(B,A) ±
94
+ * acos(C/√(A²+B²))`; the root nearest `rotationHint` is chosen. One measurement
95
+ * on each axis then pins the centroid.
96
+ */
97
+ export function solveCentroidTriangulated(input) {
98
+ // Mirror-applied, pre-rotation offsets (θ is the unknown we're solving for).
99
+ const offsets = orientedOffsets(input.footprint, 0, input.mirror);
100
+ const items = input.lines.map((l) => {
101
+ const o = offsets[l.vertex];
102
+ if (!o)
103
+ throw new TerrainSolveError(`vertex index ${l.vertex} out of range`);
104
+ const axis = axisOfEdge(l.edge);
105
+ let target;
106
+ switch (l.edge) {
107
+ case "left":
108
+ target = l.distance;
109
+ break;
110
+ case "right":
111
+ target = input.board.width - l.distance;
112
+ break;
113
+ case "top":
114
+ target = l.distance;
115
+ break;
116
+ case "bottom":
117
+ target = input.board.height - l.distance;
118
+ break;
119
+ }
120
+ return { axis, target, o };
121
+ });
122
+ const xs = items.filter((i) => i.axis === "x");
123
+ const ys = items.filter((i) => i.axis === "y");
124
+ let pivot;
125
+ let pivotAxis;
126
+ if (xs.length >= 2 && ys.length >= 1) {
127
+ pivot = xs;
128
+ pivotAxis = "x";
129
+ }
130
+ else if (ys.length >= 2 && xs.length >= 1) {
131
+ pivot = ys;
132
+ pivotAxis = "y";
133
+ }
134
+ else {
135
+ throw new TerrainSolveError("triangulation needs two measurements from one pair of edges (left/right or top/bottom) and one from the other");
136
+ }
137
+ // Best-conditioned pair on the pivot axis (corners that are furthest apart).
138
+ let a = pivot[0];
139
+ let b = pivot[1];
140
+ let spread = -1;
141
+ for (let i = 0; i < pivot.length; i++) {
142
+ for (let j = i + 1; j < pivot.length; j++) {
143
+ const d = Math.hypot(pivot[i].o.x - pivot[j].o.x, pivot[i].o.y - pivot[j].o.y);
144
+ if (d > spread) {
145
+ spread = d;
146
+ a = pivot[i];
147
+ b = pivot[j];
148
+ }
149
+ }
150
+ }
151
+ // Subtract the two same-axis equations → A·cosθ + B·sinθ = C.
152
+ // x-axis vertex eq: cx + (cosθ·o.x − sinθ·o.y) = target
153
+ // y-axis vertex eq: cy + (sinθ·o.x + cosθ·o.y) = target
154
+ const dx = a.o.x - b.o.x;
155
+ const dy = a.o.y - b.o.y;
156
+ const C = a.target - b.target;
157
+ const A = pivotAxis === "x" ? dx : dy;
158
+ const B = pivotAxis === "x" ? -dy : dx;
159
+ const R = Math.hypot(A, B);
160
+ if (R < 1e-9) {
161
+ throw new TerrainSolveError("the two same-edge measurements must reference different corners");
162
+ }
163
+ const ratio = C / R;
164
+ if (ratio > 1 + 1e-6 || ratio < -1 - 1e-6) {
165
+ throw new TerrainSolveError("measurements are inconsistent — no orientation fits");
166
+ }
167
+ const phi = Math.atan2(B, A);
168
+ const base = Math.acos(Math.max(-1, Math.min(1, ratio)));
169
+ const hint = ((input.rotationHint ?? 0) * Math.PI) / 180;
170
+ const theta = [phi + base, phi - base].reduce((best, c) => angularGap(c, hint) < angularGap(best, hint) ? c : best);
171
+ const cos = Math.cos(theta);
172
+ const sin = Math.sin(theta);
173
+ const xLine = xs[0];
174
+ const yLine = ys[0];
175
+ const x = xLine.target - (cos * xLine.o.x - sin * xLine.o.y);
176
+ const y = yLine.target - (sin * yLine.o.x + cos * yLine.o.y);
177
+ const rotation = (((theta * 180) / Math.PI) % 360 + 360) % 360;
178
+ return { x, y, rotation };
179
+ }
80
180
  //# sourceMappingURL=solve.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"solve.js","sourceRoot":"","sources":["../../src/terrain/solve.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Card-measurement centroid solver — the inverse of the resolver's placement.\n *\n * Reference cards locate a terrain area by dimension lines: \"this feature of the\n * area is D inches from a board edge\". The feature referenced varies per card\n * and per piece, which is exactly why a single canonical anchor (the centroid)\n * is hard to read off a card directly. This solver lets a user transcribe the\n * card verbatim — pick the template, set the orientation shown, then enter one\n * horizontal and one vertical dimension line against whatever feature the card\n * happens to draw — and back-solves the centroid `position` the schema stores.\n *\n * Because the centroid is rotation- and mirror-invariant, orientation is fixed\n * first; each dimension line then pins one axis of the centroid in closed form.\n */\nimport { orientedOffsets, type Footprint, type Mirror, type Vec2 } from \"./resolve.js\";\n\n/** A board edge a card dimension is measured from. left/right pin x; top/bottom pin y. */\nexport type BoardEdge = \"left\" | \"right\" | \"top\" | \"bottom\";\n\n/**\n * Which feature of the placed area a dimension line reaches: a specific\n * footprint vertex (by index, in {@link footprintVertices} order), or one of\n * the placed area's axis-aligned bounding faces (\"the left face\", etc.).\n */\nexport type FeatureRef =\n | { kind: \"vertex\"; index: number }\n | { kind: \"face\"; side: \"min-x\" | \"max-x\" | \"min-y\" | \"max-y\" };\n\n/** One card dimension line: `distance` inches from `edge` to `feature`. */\nexport interface DimensionLine {\n edge: BoardEdge;\n distance: number;\n feature: FeatureRef;\n}\n\nexport interface SolveInput {\n footprint: Footprint;\n rotation: number;\n mirror: Mirror;\n /** Board extents in inches (40kdc standard is 60 × 44). */\n board: { width: number; height: number };\n /** Two perpendicular dimension lines: exactly one must pin x, one must pin y. */\n lines: [DimensionLine, DimensionLine];\n}\n\nexport class TerrainSolveError extends Error {}\n\n/** The signed offset (from the centroid) the given feature resolves to, on its axis. */\nfunction featureOffset(offsets: Vec2[], feature: FeatureRef, axis: \"x\" | \"y\"): number {\n if (feature.kind === \"vertex\") {\n const o = offsets[feature.index];\n if (!o) throw new TerrainSolveError(`vertex index ${feature.index} out of range`);\n return axis === \"x\" ? o.x : o.y;\n }\n const xs = offsets.map((o) => o.x);\n const ys = offsets.map((o) => o.y);\n switch (feature.side) {\n case \"min-x\":\n return Math.min(...xs);\n case \"max-x\":\n return Math.max(...xs);\n case \"min-y\":\n return Math.min(...ys);\n case \"max-y\":\n return Math.max(...ys);\n }\n}\n\nfunction axisOfEdge(edge: BoardEdge): \"x\" | \"y\" {\n return edge === \"left\" || edge === \"right\" ? \"x\" : \"y\";\n}\n\n/** Solve one axis of the centroid from a single dimension line. */\nfunction solveAxis(line: DimensionLine, offsets: Vec2[], board: { width: number; height: number }): { axis: \"x\" | \"y\"; value: number } {\n const axis = axisOfEdge(line.edge);\n const o = featureOffset(offsets, line.feature, axis);\n // edge → centroid: near-side edges measure from 0; far-side from the extent.\n let value: number;\n switch (line.edge) {\n case \"left\":\n value = line.distance - o;\n break;\n case \"right\":\n value = board.width - line.distance - o;\n break;\n case \"top\":\n value = line.distance - o;\n break;\n case \"bottom\":\n value = board.height - line.distance - o;\n break;\n }\n return { axis, value };\n}\n\n/**\n * Back-solve the centroid `position` from a template, its orientation, and two\n * perpendicular card dimension lines. Closed form — one x-line and one y-line\n * pin the two unknowns directly.\n */\nexport function solveCentroid(input: SolveInput): Vec2 {\n const offsets = orientedOffsets(input.footprint, input.rotation, input.mirror);\n const a = solveAxis(input.lines[0], offsets, input.board);\n const b = solveAxis(input.lines[1], offsets, input.board);\n if (a.axis === b.axis) {\n throw new TerrainSolveError(\n \"the two dimension lines must pin different axes (one of left/right, one of top/bottom)\",\n );\n }\n const x = a.axis === \"x\" ? a.value : b.value;\n const y = a.axis === \"y\" ? a.value : b.value;\n return { x, y };\n}\n"]}
1
+ {"version":3,"file":"solve.js","sourceRoot":"","sources":["../../src/terrain/solve.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Card-measurement centroid solver — the inverse of the resolver's placement.\n *\n * Reference cards locate a terrain area by dimension lines: \"this feature of the\n * area is D inches from a board edge\". The feature referenced varies per card\n * and per piece, which is exactly why a single canonical anchor (the centroid)\n * is hard to read off a card directly. This solver lets a user transcribe the\n * card verbatim — pick the template, set the orientation shown, then enter one\n * horizontal and one vertical dimension line against whatever feature the card\n * happens to draw — and back-solves the centroid `position` the schema stores.\n *\n * Because the centroid is rotation- and mirror-invariant, orientation is fixed\n * first; each dimension line then pins one axis of the centroid in closed form.\n */\nimport { orientedOffsets, type Footprint, type Mirror, type Vec2 } from \"./resolve.js\";\n\n/** A board edge a card dimension is measured from. left/right pin x; top/bottom pin y. */\nexport type BoardEdge = \"left\" | \"right\" | \"top\" | \"bottom\";\n\n/**\n * Which feature of the placed area a dimension line reaches: a specific\n * footprint vertex (by index, in {@link footprintVertices} order), or one of\n * the placed area's axis-aligned bounding faces (\"the left face\", etc.).\n */\nexport type FeatureRef =\n | { kind: \"vertex\"; index: number }\n | { kind: \"face\"; side: \"min-x\" | \"max-x\" | \"min-y\" | \"max-y\" };\n\n/** One card dimension line: `distance` inches from `edge` to `feature`. */\nexport interface DimensionLine {\n edge: BoardEdge;\n distance: number;\n feature: FeatureRef;\n}\n\nexport interface SolveInput {\n footprint: Footprint;\n rotation: number;\n mirror: Mirror;\n /** Board extents in inches (40kdc standard is 60 × 44). */\n board: { width: number; height: number };\n /** Two perpendicular dimension lines: exactly one must pin x, one must pin y. */\n lines: [DimensionLine, DimensionLine];\n}\n\nexport class TerrainSolveError extends Error {}\n\n/** The signed offset (from the centroid) the given feature resolves to, on its axis. */\nfunction featureOffset(offsets: Vec2[], feature: FeatureRef, axis: \"x\" | \"y\"): number {\n if (feature.kind === \"vertex\") {\n const o = offsets[feature.index];\n if (!o) throw new TerrainSolveError(`vertex index ${feature.index} out of range`);\n return axis === \"x\" ? o.x : o.y;\n }\n const xs = offsets.map((o) => o.x);\n const ys = offsets.map((o) => o.y);\n switch (feature.side) {\n case \"min-x\":\n return Math.min(...xs);\n case \"max-x\":\n return Math.max(...xs);\n case \"min-y\":\n return Math.min(...ys);\n case \"max-y\":\n return Math.max(...ys);\n }\n}\n\nfunction axisOfEdge(edge: BoardEdge): \"x\" | \"y\" {\n return edge === \"left\" || edge === \"right\" ? \"x\" : \"y\";\n}\n\n/** Solve one axis of the centroid from a single dimension line. */\nfunction solveAxis(line: DimensionLine, offsets: Vec2[], board: { width: number; height: number }): { axis: \"x\" | \"y\"; value: number } {\n const axis = axisOfEdge(line.edge);\n const o = featureOffset(offsets, line.feature, axis);\n // edge → centroid: near-side edges measure from 0; far-side from the extent.\n let value: number;\n switch (line.edge) {\n case \"left\":\n value = line.distance - o;\n break;\n case \"right\":\n value = board.width - line.distance - o;\n break;\n case \"top\":\n value = line.distance - o;\n break;\n case \"bottom\":\n value = board.height - line.distance - o;\n break;\n }\n return { axis, value };\n}\n\n/**\n * Back-solve the centroid `position` from a template, its orientation, and two\n * perpendicular card dimension lines. Closed form — one x-line and one y-line\n * pin the two unknowns directly.\n */\nexport function solveCentroid(input: SolveInput): Vec2 {\n const offsets = orientedOffsets(input.footprint, input.rotation, input.mirror);\n const a = solveAxis(input.lines[0], offsets, input.board);\n const b = solveAxis(input.lines[1], offsets, input.board);\n if (a.axis === b.axis) {\n throw new TerrainSolveError(\n \"the two dimension lines must pin different axes (one of left/right, one of top/bottom)\",\n );\n }\n const x = a.axis === \"x\" ? a.value : b.value;\n const y = a.axis === \"y\" ? a.value : b.value;\n return { x, y };\n}\n\n/**\n * One triangulation measurement: `distance` inches from board `edge` to a\n * specific footprint vertex (corner). Faces are intentionally excluded — an\n * arbitrarily-rotated piece has no axis-aligned face to measure to.\n */\nexport interface TriangulationLine {\n edge: BoardEdge;\n distance: number;\n vertex: number;\n}\n\nexport interface TriangulateInput {\n footprint: Footprint;\n mirror: Mirror;\n board: { width: number; height: number };\n /**\n * Three corner measurements. At least two must share an axis (left/right or\n * top/bottom) to fix the angle, and at least one must pin the other axis.\n */\n lines: [TriangulationLine, TriangulationLine, TriangulationLine];\n /** Current rotation in degrees, used to choose between the two angle roots. */\n rotationHint?: number;\n}\n\nconst TWO_PI = Math.PI * 2;\n/** Smallest absolute angular separation between two radian angles. */\nfunction angularGap(a: number, b: number): number {\n const d = (((a - b) % TWO_PI) + TWO_PI) % TWO_PI;\n return Math.min(d, TWO_PI - d);\n}\n\n/**\n * Back-solve a piece's centroid AND rotation from three card measurements to\n * specific footprint corners — the inverse needed for pieces at non-90° angles,\n * where the card pins three corner-to-edge distances rather than one per axis.\n *\n * Closed form: with the (unknown) rotation θ, each corner `v` resolves to\n * `centroid + R(θ)·v`. Subtracting two same-axis measurements cancels the\n * centroid and leaves `A·cosθ + B·sinθ = C`, solved as `θ = atan2(B,A) ±\n * acos(C/√(A²+B²))`; the root nearest `rotationHint` is chosen. One measurement\n * on each axis then pins the centroid.\n */\nexport function solveCentroidTriangulated(\n input: TriangulateInput,\n): { x: number; y: number; rotation: number } {\n // Mirror-applied, pre-rotation offsets (θ is the unknown we're solving for).\n const offsets = orientedOffsets(input.footprint, 0, input.mirror);\n const items = input.lines.map((l) => {\n const o = offsets[l.vertex];\n if (!o) throw new TerrainSolveError(`vertex index ${l.vertex} out of range`);\n const axis = axisOfEdge(l.edge);\n let target: number;\n switch (l.edge) {\n case \"left\":\n target = l.distance;\n break;\n case \"right\":\n target = input.board.width - l.distance;\n break;\n case \"top\":\n target = l.distance;\n break;\n case \"bottom\":\n target = input.board.height - l.distance;\n break;\n }\n return { axis, target, o };\n });\n const xs = items.filter((i) => i.axis === \"x\");\n const ys = items.filter((i) => i.axis === \"y\");\n\n let pivot: typeof items;\n let pivotAxis: \"x\" | \"y\";\n if (xs.length >= 2 && ys.length >= 1) {\n pivot = xs;\n pivotAxis = \"x\";\n } else if (ys.length >= 2 && xs.length >= 1) {\n pivot = ys;\n pivotAxis = \"y\";\n } else {\n throw new TerrainSolveError(\n \"triangulation needs two measurements from one pair of edges (left/right or top/bottom) and one from the other\",\n );\n }\n\n // Best-conditioned pair on the pivot axis (corners that are furthest apart).\n let a = pivot[0];\n let b = pivot[1];\n let spread = -1;\n for (let i = 0; i < pivot.length; i++) {\n for (let j = i + 1; j < pivot.length; j++) {\n const d = Math.hypot(pivot[i].o.x - pivot[j].o.x, pivot[i].o.y - pivot[j].o.y);\n if (d > spread) {\n spread = d;\n a = pivot[i];\n b = pivot[j];\n }\n }\n }\n\n // Subtract the two same-axis equations → A·cosθ + B·sinθ = C.\n // x-axis vertex eq: cx + (cosθ·o.x − sinθ·o.y) = target\n // y-axis vertex eq: cy + (sinθ·o.x + cosθ·o.y) = target\n const dx = a.o.x - b.o.x;\n const dy = a.o.y - b.o.y;\n const C = a.target - b.target;\n const A = pivotAxis === \"x\" ? dx : dy;\n const B = pivotAxis === \"x\" ? -dy : dx;\n const R = Math.hypot(A, B);\n if (R < 1e-9) {\n throw new TerrainSolveError(\"the two same-edge measurements must reference different corners\");\n }\n const ratio = C / R;\n if (ratio > 1 + 1e-6 || ratio < -1 - 1e-6) {\n throw new TerrainSolveError(\"measurements are inconsistent — no orientation fits\");\n }\n const phi = Math.atan2(B, A);\n const base = Math.acos(Math.max(-1, Math.min(1, ratio)));\n const hint = ((input.rotationHint ?? 0) * Math.PI) / 180;\n const theta = [phi + base, phi - base].reduce((best, c) =>\n angularGap(c, hint) < angularGap(best, hint) ? c : best,\n );\n\n const cos = Math.cos(theta);\n const sin = Math.sin(theta);\n const xLine = xs[0];\n const yLine = ys[0];\n const x = xLine.target - (cos * xLine.o.x - sin * xLine.o.y);\n const y = yLine.target - (sin * yLine.o.x + cos * yLine.o.y);\n const rotation = (((theta * 180) / Math.PI) % 360 + 360) % 360;\n return { x, y, rotation };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"condition.d.ts","sourceRoot":"","sources":["../../src/translate/condition.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,KAAK,GAAG,IAAI,GAAG,KAAK,CAAC;IAChC,QAAQ,CAAC,EAAE,SAAS,EAAE,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,kFAAkF;AAClF,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzC;AAWD,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,SAAS,GAAG,MAAM,CAoItD"}
1
+ {"version":3,"file":"condition.d.ts","sourceRoot":"","sources":["../../src/translate/condition.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,KAAK,GAAG,IAAI,GAAG,KAAK,CAAC;IAChC,QAAQ,CAAC,EAAE,SAAS,EAAE,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,kFAAkF;AAClF,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzC;AAWD,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,SAAS,GAAG,MAAM,CAwItD"}
@@ -107,6 +107,10 @@ export function describeCondition(c) {
107
107
  s += " while on an objective";
108
108
  return s;
109
109
  }
110
+ case "destroyed-in-tagged-terrain": {
111
+ const where = p.at_start_of_turn ? "that started the turn in" : "while in";
112
+ return `${negate}${count(p.count_min ?? 1, "enemy unit")} destroyed ${where} ${dekebab(str(p.tag))} terrain`;
113
+ }
110
114
  case "action-completed": {
111
115
  let s = `${negate}${count(p.count_min ?? 1, "action")} completed`;
112
116
  if (p.action_id != null)
@@ -1 +1 @@
1
- {"version":3,"file":"condition.js","sourceRoot":"","sources":["../../src/translate/condition.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Humanize an Ability-DSL / scoring `condition` into plain English.\n *\n * Shared by the ability-text CLI (`commands/translate.ts`) and the scoring-card\n * translator (`scoring.ts`). Output is **ASCII-only** with a fixed clause and\n * parameter order: it is pinned byte-for-byte across the TS and Rust ports by\n * the `conformance/scoring-translation` corpus, so any phrasing change here is a\n * semantic corpus change (bump `conformance/SPEC_VERSION`).\n */\n\n/**\n * Minimal structural view of a condition node. Matches both the ability-dsl\n * condition schema and the `secondary-card` award `when` field (a simple node\n * carries `type` + `parameters` + `negated`; a compound node carries\n * `operator` + `operands`).\n */\nexport interface Condition {\n type?: string;\n operator?: \"and\" | \"or\" | \"not\";\n operands?: Condition[];\n parameters?: Record<string, unknown>;\n negated?: boolean;\n}\n\n/** kebab-case → space-separated words (`enemy-territory` → `enemy territory`). */\nexport function dekebab(s: string): string {\n return s.replace(/-/g, \" \");\n}\n\nfunction str(v: unknown): string {\n return typeof v === \"string\" ? v : String(v);\n}\n\n/** `2` + `objective` → `2+ objectives`. Nouns here are all regular plurals. */\nfunction count(n: unknown, noun: string): string {\n return `${str(n)}+ ${noun}s`;\n}\n\nexport function describeCondition(c: Condition): string {\n // Compound nodes first — join the operands with lowercase connectives so the\n // result reads naturally inside a \"... when X and Y\" clause.\n if (c.operator === \"and\" && c.operands) {\n return c.operands.map(describeCondition).join(\" and \");\n }\n if (c.operator === \"or\" && c.operands) {\n return c.operands.map(describeCondition).join(\" or \");\n }\n if (c.operator === \"not\" && c.operands) {\n return `not (${c.operands.map(describeCondition).join(\", \")})`;\n }\n\n const negate = c.negated ? \"not \" : \"\";\n const p = c.parameters ?? {};\n\n switch (c.type) {\n // ── Ability-DSL conditions (ported from commands/translate.ts) ──────────\n case \"phase-is\":\n return `${negate}during the ${str(p.phase)} phase`;\n case \"timing-is\":\n return `${negate}at ${dekebab(str(p.timing))}`;\n case \"player-turn-is\":\n return `${negate}in ${p.turn === \"your-turn\" ? \"your\" : p.turn === \"opponent-turn\" ? \"the opponent's\" : \"either player's\"} turn`;\n case \"charged-this-turn\":\n return `${negate}the unit charged this turn`;\n case \"advanced-this-turn\":\n return `${negate}the unit advanced this turn`;\n case \"remained-stationary\":\n return `${negate}the unit remained stationary`;\n case \"unit-below-starting-strength\":\n return `${negate}the unit is below starting strength`;\n case \"unit-below-half-strength\":\n return `${negate}the unit is below half strength`;\n case \"unit-has-keyword\":\n return `${negate}the unit has \"${str(p.keyword)}\"`;\n case \"target-has-keyword\":\n return `${negate}the target has \"${str(p.keyword)}\"`;\n case \"model-is-leader\":\n return `${negate}the model is leading a unit`;\n case \"is-attached\":\n return `${negate}attached to a ${p.keyword ? `${str(p.keyword)} ` : \"\"}unit`;\n case \"attack-is-type\":\n return `${negate}for ${str(p.attack_type)} attacks`;\n case \"is-battle-shocked\":\n return `${negate}the unit is battle-shocked`;\n case \"has-lost-wounds\":\n return `${negate}the model has lost wounds`;\n case \"opponent-unit-within-range\":\n return `${negate}an enemy unit is within ${p.range === \"engagement\" ? \"engagement range\" : `${str(p.range)}\"`}`;\n case \"unit-within-range-of\":\n return `${negate}within ${str(p.range)}\" of ${str(p.target_type ?? \"target\")}${p.keyword ? ` (${str(p.keyword)})` : \"\"}`;\n case \"within-range-of-objective\":\n return `${negate}within range of an objective`;\n case \"has-fought-this-phase\":\n return `${negate}has fought this phase`;\n case \"destroyed-by-attack-type\":\n return `${negate}destroyed by a ${str(p.attack_type)} attack`;\n\n // ── Scoring conditions (secondary-card award `when`) ────────────────────\n case \"objective-majority\":\n return `${negate}you hold more objectives than the ${dekebab(str(p.relative_to ?? \"opponent\"))}`;\n case \"controls-objective\": {\n const noun = p.objective_role ? `${dekebab(str(p.objective_role))} objective` : \"objective\";\n let s = `${negate}you control ${count(p.count_min ?? 1, noun)}`;\n if (p.objective != null) s += ` (${dekebab(str(p.objective))})`;\n if (p.scope != null) s += ` in ${dekebab(str(p.scope))}`;\n if (p.exclude != null) s += ` (excluding ${dekebab(str(p.exclude))})`;\n return s;\n }\n case \"units-destroyed\":\n return `${negate}${count(p.count_min ?? 1, `${str(p.side)} unit`)} destroyed ${dekebab(str(p.window))}`;\n case \"units-destroyed-comparison\": {\n const subj = (p.subject ?? {}) as Record<string, unknown>;\n const ref = (p.reference ?? {}) as Record<string, unknown>;\n const cmp = p.comparator === \"greater-or-equal\" ? \"at least as many\" : \"more\";\n const link = p.comparator === \"greater-or-equal\" ? \"as\" : \"than\";\n return `${negate}you destroyed ${cmp} ${str(subj.side)} units ${dekebab(str(subj.window))} ${link} ${str(ref.side)} units ${dekebab(str(ref.window))}`;\n }\n case \"new-objective-controlled\":\n return `${negate}you newly control ${count(p.count_min ?? 1, \"objective\")} this turn`;\n case \"destroyed-while-on-objective\": {\n let s = `${negate}${count(p.count_min ?? 1, \"enemy unit\")} destroyed`;\n if (p.destroyer_on_objective) s += \" by a unit on an objective\";\n if (p.victim_on_objective) s += \" while on an objective\";\n return s;\n }\n case \"action-completed\": {\n let s = `${negate}${count(p.count_min ?? 1, \"action\")} completed`;\n if (p.action_id != null) s += ` (${dekebab(str(p.action_id))})`;\n if (p.target_kind != null) s += ` on ${dekebab(str(p.target_kind))}`;\n const tf = (p.target_filter ?? {}) as Record<string, unknown>;\n if (tf.objective_role != null) s += ` (${dekebab(str(tf.objective_role))})`;\n if (tf.in_enemy_territory) s += \" in enemy territory\";\n if (tf.exclude != null) s += ` (excluding ${dekebab(str(tf.exclude))})`;\n if (p.window != null) s += ` ${dekebab(str(p.window))}`;\n return s;\n }\n case \"objective-has-tag\": {\n let s = `${negate}${count(p.count_min ?? 1, \"objective\")} tagged ${dekebab(str(p.tag))}`;\n if (p.count_max != null) s += ` (at most ${str(p.count_max)})`;\n if (p.objective != null) s += ` (${dekebab(str(p.objective))})`;\n if (p.scope != null) s += ` in ${dekebab(str(p.scope))}`;\n if (p.last_marked) s += \" (most recently marked)\";\n return s;\n }\n case \"unit-has-tag\": {\n let s = `${negate}${count(p.count_min ?? 1, `${str(p.side)} unit`)} tagged ${dekebab(str(p.tag))}`;\n if (p.window != null) s += ` (${dekebab(str(p.window))})`;\n return s;\n }\n case \"terrain-has-tag\": {\n let s = `${negate}terrain tagged ${dekebab(str(p.tag))}`;\n if (p.friendly_units_min != null) s += ` with ${str(p.friendly_units_min)}+ friendly units`;\n if (p.enemy_units_max != null) s += ` and at most ${str(p.enemy_units_max)} enemy units`;\n if (p.last_marked) s += \" (most recently marked)\";\n if (p.in_enemy_dz) s += \" in the enemy deployment zone\";\n return s;\n }\n case \"terrain-area-control\":\n return `${negate}you control a terrain area with ${str(p.min_models ?? 1)}+ models`;\n case \"territory-control\": {\n let s = `${negate}you control ${dekebab(str(p.territory_ref ?? \"your-territory\"))}`;\n if (p.enemy_units_max != null) s += ` with at most ${str(p.enemy_units_max)} enemy units`;\n return s;\n }\n case \"engagement-fronts\":\n return `${negate}you are engaged on ${str(p.count_min ?? 1)}+ fronts`;\n\n default:\n return `${negate}${dekebab(c.type ?? \"unknown\")}`;\n }\n}\n"]}
1
+ {"version":3,"file":"condition.js","sourceRoot":"","sources":["../../src/translate/condition.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAgBH,kFAAkF;AAClF,MAAM,UAAU,OAAO,CAAC,CAAS;IAC/B,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;AAC9B,CAAC;AAED,SAAS,GAAG,CAAC,CAAU;IACrB,OAAO,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AAC/C,CAAC;AAED,+EAA+E;AAC/E,SAAS,KAAK,CAAC,CAAU,EAAE,IAAY;IACrC,OAAO,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,CAAC;AAC/B,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,CAAY;IAC5C,6EAA6E;IAC7E,6DAA6D;IAC7D,IAAI,CAAC,CAAC,QAAQ,KAAK,KAAK,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;QACvC,OAAO,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACzD,CAAC;IACD,IAAI,CAAC,CAAC,QAAQ,KAAK,IAAI,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;QACtC,OAAO,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACxD,CAAC;IACD,IAAI,CAAC,CAAC,QAAQ,KAAK,KAAK,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;QACvC,OAAO,QAAQ,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;IACjE,CAAC;IAED,MAAM,MAAM,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;IACvC,MAAM,CAAC,GAAG,CAAC,CAAC,UAAU,IAAI,EAAE,CAAC;IAE7B,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC;QACf,2EAA2E;QAC3E,KAAK,UAAU;YACb,OAAO,GAAG,MAAM,cAAc,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC;QACrD,KAAK,WAAW;YACd,OAAO,GAAG,MAAM,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;QACjD,KAAK,gBAAgB;YACnB,OAAO,GAAG,MAAM,MAAM,CAAC,CAAC,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,eAAe,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,iBAAiB,OAAO,CAAC;QACnI,KAAK,mBAAmB;YACtB,OAAO,GAAG,MAAM,4BAA4B,CAAC;QAC/C,KAAK,oBAAoB;YACvB,OAAO,GAAG,MAAM,6BAA6B,CAAC;QAChD,KAAK,qBAAqB;YACxB,OAAO,GAAG,MAAM,8BAA8B,CAAC;QACjD,KAAK,8BAA8B;YACjC,OAAO,GAAG,MAAM,qCAAqC,CAAC;QACxD,KAAK,0BAA0B;YAC7B,OAAO,GAAG,MAAM,iCAAiC,CAAC;QACpD,KAAK,kBAAkB;YACrB,OAAO,GAAG,MAAM,iBAAiB,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC;QACrD,KAAK,oBAAoB;YACvB,OAAO,GAAG,MAAM,mBAAmB,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC;QACvD,KAAK,iBAAiB;YACpB,OAAO,GAAG,MAAM,6BAA6B,CAAC;QAChD,KAAK,aAAa;YAChB,OAAO,GAAG,MAAM,iBAAiB,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC;QAC/E,KAAK,gBAAgB;YACnB,OAAO,GAAG,MAAM,OAAO,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,UAAU,CAAC;QACtD,KAAK,mBAAmB;YACtB,OAAO,GAAG,MAAM,4BAA4B,CAAC;QAC/C,KAAK,iBAAiB;YACpB,OAAO,GAAG,MAAM,2BAA2B,CAAC;QAC9C,KAAK,4BAA4B;YAC/B,OAAO,GAAG,MAAM,2BAA2B,CAAC,CAAC,KAAK,KAAK,YAAY,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;QAClH,KAAK,sBAAsB;YACzB,OAAO,GAAG,MAAM,UAAU,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,WAAW,IAAI,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;QAC3H,KAAK,2BAA2B;YAC9B,OAAO,GAAG,MAAM,8BAA8B,CAAC;QACjD,KAAK,uBAAuB;YAC1B,OAAO,GAAG,MAAM,uBAAuB,CAAC;QAC1C,KAAK,0BAA0B;YAC7B,OAAO,GAAG,MAAM,kBAAkB,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,SAAS,CAAC;QAEhE,2EAA2E;QAC3E,KAAK,oBAAoB;YACvB,OAAO,GAAG,MAAM,qCAAqC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,WAAW,IAAI,UAAU,CAAC,CAAC,EAAE,CAAC;QACnG,KAAK,oBAAoB,CAAC,CAAC,CAAC;YAC1B,MAAM,IAAI,GAAG,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,WAAW,CAAC;YAC5F,IAAI,CAAC,GAAG,GAAG,MAAM,eAAe,KAAK,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,EAAE,IAAI,CAAC,EAAE,CAAC;YAChE,IAAI,CAAC,CAAC,SAAS,IAAI,IAAI;gBAAE,CAAC,IAAI,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC;YAChE,IAAI,CAAC,CAAC,KAAK,IAAI,IAAI;gBAAE,CAAC,IAAI,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;YACzD,IAAI,CAAC,CAAC,OAAO,IAAI,IAAI;gBAAE,CAAC,IAAI,eAAe,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC;YACtE,OAAO,CAAC,CAAC;QACX,CAAC;QACD,KAAK,iBAAiB;YACpB,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,cAAc,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;QAC1G,KAAK,4BAA4B,CAAC,CAAC,CAAC;YAClC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,OAAO,IAAI,EAAE,CAA4B,CAAC;YAC1D,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,SAAS,IAAI,EAAE,CAA4B,CAAC;YAC3D,MAAM,GAAG,GAAG,CAAC,CAAC,UAAU,KAAK,kBAAkB,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,MAAM,CAAC;YAC9E,MAAM,IAAI,GAAG,CAAC,CAAC,UAAU,KAAK,kBAAkB,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC;YACjE,OAAO,GAAG,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,IAAI,IAAI,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;QACzJ,CAAC;QACD,KAAK,0BAA0B;YAC7B,OAAO,GAAG,MAAM,qBAAqB,KAAK,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,EAAE,WAAW,CAAC,YAAY,CAAC;QACxF,KAAK,8BAA8B,CAAC,CAAC,CAAC;YACpC,IAAI,CAAC,GAAG,GAAG,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,EAAE,YAAY,CAAC,YAAY,CAAC;YACtE,IAAI,CAAC,CAAC,sBAAsB;gBAAE,CAAC,IAAI,4BAA4B,CAAC;YAChE,IAAI,CAAC,CAAC,mBAAmB;gBAAE,CAAC,IAAI,wBAAwB,CAAC;YACzD,OAAO,CAAC,CAAC;QACX,CAAC;QACD,KAAK,6BAA6B,CAAC,CAAC,CAAC;YACnC,MAAM,KAAK,GAAG,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,0BAA0B,CAAC,CAAC,CAAC,UAAU,CAAC;YAC3E,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,EAAE,YAAY,CAAC,cAAc,KAAK,IAAI,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC;QAC/G,CAAC;QACD,KAAK,kBAAkB,CAAC,CAAC,CAAC;YACxB,IAAI,CAAC,GAAG,GAAG,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,EAAE,QAAQ,CAAC,YAAY,CAAC;YAClE,IAAI,CAAC,CAAC,SAAS,IAAI,IAAI;gBAAE,CAAC,IAAI,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC;YAChE,IAAI,CAAC,CAAC,WAAW,IAAI,IAAI;gBAAE,CAAC,IAAI,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC;YACrE,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,aAAa,IAAI,EAAE,CAA4B,CAAC;YAC9D,IAAI,EAAE,CAAC,cAAc,IAAI,IAAI;gBAAE,CAAC,IAAI,KAAK,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,cAAc,CAAC,CAAC,GAAG,CAAC;YAC5E,IAAI,EAAE,CAAC,kBAAkB;gBAAE,CAAC,IAAI,qBAAqB,CAAC;YACtD,IAAI,EAAE,CAAC,OAAO,IAAI,IAAI;gBAAE,CAAC,IAAI,eAAe,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC;YACxE,IAAI,CAAC,CAAC,MAAM,IAAI,IAAI;gBAAE,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC;YACxD,OAAO,CAAC,CAAC;QACX,CAAC;QACD,KAAK,mBAAmB,CAAC,CAAC,CAAC;YACzB,IAAI,CAAC,GAAG,GAAG,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,EAAE,WAAW,CAAC,WAAW,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YACzF,IAAI,CAAC,CAAC,SAAS,IAAI,IAAI;gBAAE,CAAC,IAAI,aAAa,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC;YAC/D,IAAI,CAAC,CAAC,SAAS,IAAI,IAAI;gBAAE,CAAC,IAAI,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC;YAChE,IAAI,CAAC,CAAC,KAAK,IAAI,IAAI;gBAAE,CAAC,IAAI,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;YACzD,IAAI,CAAC,CAAC,WAAW;gBAAE,CAAC,IAAI,yBAAyB,CAAC;YAClD,OAAO,CAAC,CAAC;QACX,CAAC;QACD,KAAK,cAAc,CAAC,CAAC,CAAC;YACpB,IAAI,CAAC,GAAG,GAAG,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YACnG,IAAI,CAAC,CAAC,MAAM,IAAI,IAAI;gBAAE,CAAC,IAAI,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC;YAC1D,OAAO,CAAC,CAAC;QACX,CAAC;QACD,KAAK,iBAAiB,CAAC,CAAC,CAAC;YACvB,IAAI,CAAC,GAAG,GAAG,MAAM,kBAAkB,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YACzD,IAAI,CAAC,CAAC,kBAAkB,IAAI,IAAI;gBAAE,CAAC,IAAI,SAAS,GAAG,CAAC,CAAC,CAAC,kBAAkB,CAAC,kBAAkB,CAAC;YAC5F,IAAI,CAAC,CAAC,eAAe,IAAI,IAAI;gBAAE,CAAC,IAAI,gBAAgB,GAAG,CAAC,CAAC,CAAC,eAAe,CAAC,cAAc,CAAC;YACzF,IAAI,CAAC,CAAC,WAAW;gBAAE,CAAC,IAAI,yBAAyB,CAAC;YAClD,IAAI,CAAC,CAAC,WAAW;gBAAE,CAAC,IAAI,+BAA+B,CAAC;YACxD,OAAO,CAAC,CAAC;QACX,CAAC;QACD,KAAK,sBAAsB;YACzB,OAAO,GAAG,MAAM,mCAAmC,GAAG,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,UAAU,CAAC;QACtF,KAAK,mBAAmB,CAAC,CAAC,CAAC;YACzB,IAAI,CAAC,GAAG,GAAG,MAAM,eAAe,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,aAAa,IAAI,gBAAgB,CAAC,CAAC,EAAE,CAAC;YACpF,IAAI,CAAC,CAAC,eAAe,IAAI,IAAI;gBAAE,CAAC,IAAI,iBAAiB,GAAG,CAAC,CAAC,CAAC,eAAe,CAAC,cAAc,CAAC;YAC1F,OAAO,CAAC,CAAC;QACX,CAAC;QACD,KAAK,mBAAmB;YACtB,OAAO,GAAG,MAAM,sBAAsB,GAAG,CAAC,CAAC,CAAC,SAAS,IAAI,CAAC,CAAC,UAAU,CAAC;QAExE;YACE,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,IAAI,SAAS,CAAC,EAAE,CAAC;IACtD,CAAC;AACH,CAAC","sourcesContent":["/**\n * Humanize an Ability-DSL / scoring `condition` into plain English.\n *\n * Shared by the ability-text CLI (`commands/translate.ts`) and the scoring-card\n * translator (`scoring.ts`). Output is **ASCII-only** with a fixed clause and\n * parameter order: it is pinned byte-for-byte across the TS and Rust ports by\n * the `conformance/scoring-translation` corpus, so any phrasing change here is a\n * semantic corpus change (bump `conformance/SPEC_VERSION`).\n */\n\n/**\n * Minimal structural view of a condition node. Matches both the ability-dsl\n * condition schema and the `secondary-card` award `when` field (a simple node\n * carries `type` + `parameters` + `negated`; a compound node carries\n * `operator` + `operands`).\n */\nexport interface Condition {\n type?: string;\n operator?: \"and\" | \"or\" | \"not\";\n operands?: Condition[];\n parameters?: Record<string, unknown>;\n negated?: boolean;\n}\n\n/** kebab-case → space-separated words (`enemy-territory` → `enemy territory`). */\nexport function dekebab(s: string): string {\n return s.replace(/-/g, \" \");\n}\n\nfunction str(v: unknown): string {\n return typeof v === \"string\" ? v : String(v);\n}\n\n/** `2` + `objective` → `2+ objectives`. Nouns here are all regular plurals. */\nfunction count(n: unknown, noun: string): string {\n return `${str(n)}+ ${noun}s`;\n}\n\nexport function describeCondition(c: Condition): string {\n // Compound nodes first — join the operands with lowercase connectives so the\n // result reads naturally inside a \"... when X and Y\" clause.\n if (c.operator === \"and\" && c.operands) {\n return c.operands.map(describeCondition).join(\" and \");\n }\n if (c.operator === \"or\" && c.operands) {\n return c.operands.map(describeCondition).join(\" or \");\n }\n if (c.operator === \"not\" && c.operands) {\n return `not (${c.operands.map(describeCondition).join(\", \")})`;\n }\n\n const negate = c.negated ? \"not \" : \"\";\n const p = c.parameters ?? {};\n\n switch (c.type) {\n // ── Ability-DSL conditions (ported from commands/translate.ts) ──────────\n case \"phase-is\":\n return `${negate}during the ${str(p.phase)} phase`;\n case \"timing-is\":\n return `${negate}at ${dekebab(str(p.timing))}`;\n case \"player-turn-is\":\n return `${negate}in ${p.turn === \"your-turn\" ? \"your\" : p.turn === \"opponent-turn\" ? \"the opponent's\" : \"either player's\"} turn`;\n case \"charged-this-turn\":\n return `${negate}the unit charged this turn`;\n case \"advanced-this-turn\":\n return `${negate}the unit advanced this turn`;\n case \"remained-stationary\":\n return `${negate}the unit remained stationary`;\n case \"unit-below-starting-strength\":\n return `${negate}the unit is below starting strength`;\n case \"unit-below-half-strength\":\n return `${negate}the unit is below half strength`;\n case \"unit-has-keyword\":\n return `${negate}the unit has \"${str(p.keyword)}\"`;\n case \"target-has-keyword\":\n return `${negate}the target has \"${str(p.keyword)}\"`;\n case \"model-is-leader\":\n return `${negate}the model is leading a unit`;\n case \"is-attached\":\n return `${negate}attached to a ${p.keyword ? `${str(p.keyword)} ` : \"\"}unit`;\n case \"attack-is-type\":\n return `${negate}for ${str(p.attack_type)} attacks`;\n case \"is-battle-shocked\":\n return `${negate}the unit is battle-shocked`;\n case \"has-lost-wounds\":\n return `${negate}the model has lost wounds`;\n case \"opponent-unit-within-range\":\n return `${negate}an enemy unit is within ${p.range === \"engagement\" ? \"engagement range\" : `${str(p.range)}\"`}`;\n case \"unit-within-range-of\":\n return `${negate}within ${str(p.range)}\" of ${str(p.target_type ?? \"target\")}${p.keyword ? ` (${str(p.keyword)})` : \"\"}`;\n case \"within-range-of-objective\":\n return `${negate}within range of an objective`;\n case \"has-fought-this-phase\":\n return `${negate}has fought this phase`;\n case \"destroyed-by-attack-type\":\n return `${negate}destroyed by a ${str(p.attack_type)} attack`;\n\n // ── Scoring conditions (secondary-card award `when`) ────────────────────\n case \"objective-majority\":\n return `${negate}you hold more objectives than the ${dekebab(str(p.relative_to ?? \"opponent\"))}`;\n case \"controls-objective\": {\n const noun = p.objective_role ? `${dekebab(str(p.objective_role))} objective` : \"objective\";\n let s = `${negate}you control ${count(p.count_min ?? 1, noun)}`;\n if (p.objective != null) s += ` (${dekebab(str(p.objective))})`;\n if (p.scope != null) s += ` in ${dekebab(str(p.scope))}`;\n if (p.exclude != null) s += ` (excluding ${dekebab(str(p.exclude))})`;\n return s;\n }\n case \"units-destroyed\":\n return `${negate}${count(p.count_min ?? 1, `${str(p.side)} unit`)} destroyed ${dekebab(str(p.window))}`;\n case \"units-destroyed-comparison\": {\n const subj = (p.subject ?? {}) as Record<string, unknown>;\n const ref = (p.reference ?? {}) as Record<string, unknown>;\n const cmp = p.comparator === \"greater-or-equal\" ? \"at least as many\" : \"more\";\n const link = p.comparator === \"greater-or-equal\" ? \"as\" : \"than\";\n return `${negate}you destroyed ${cmp} ${str(subj.side)} units ${dekebab(str(subj.window))} ${link} ${str(ref.side)} units ${dekebab(str(ref.window))}`;\n }\n case \"new-objective-controlled\":\n return `${negate}you newly control ${count(p.count_min ?? 1, \"objective\")} this turn`;\n case \"destroyed-while-on-objective\": {\n let s = `${negate}${count(p.count_min ?? 1, \"enemy unit\")} destroyed`;\n if (p.destroyer_on_objective) s += \" by a unit on an objective\";\n if (p.victim_on_objective) s += \" while on an objective\";\n return s;\n }\n case \"destroyed-in-tagged-terrain\": {\n const where = p.at_start_of_turn ? \"that started the turn in\" : \"while in\";\n return `${negate}${count(p.count_min ?? 1, \"enemy unit\")} destroyed ${where} ${dekebab(str(p.tag))} terrain`;\n }\n case \"action-completed\": {\n let s = `${negate}${count(p.count_min ?? 1, \"action\")} completed`;\n if (p.action_id != null) s += ` (${dekebab(str(p.action_id))})`;\n if (p.target_kind != null) s += ` on ${dekebab(str(p.target_kind))}`;\n const tf = (p.target_filter ?? {}) as Record<string, unknown>;\n if (tf.objective_role != null) s += ` (${dekebab(str(tf.objective_role))})`;\n if (tf.in_enemy_territory) s += \" in enemy territory\";\n if (tf.exclude != null) s += ` (excluding ${dekebab(str(tf.exclude))})`;\n if (p.window != null) s += ` ${dekebab(str(p.window))}`;\n return s;\n }\n case \"objective-has-tag\": {\n let s = `${negate}${count(p.count_min ?? 1, \"objective\")} tagged ${dekebab(str(p.tag))}`;\n if (p.count_max != null) s += ` (at most ${str(p.count_max)})`;\n if (p.objective != null) s += ` (${dekebab(str(p.objective))})`;\n if (p.scope != null) s += ` in ${dekebab(str(p.scope))}`;\n if (p.last_marked) s += \" (most recently marked)\";\n return s;\n }\n case \"unit-has-tag\": {\n let s = `${negate}${count(p.count_min ?? 1, `${str(p.side)} unit`)} tagged ${dekebab(str(p.tag))}`;\n if (p.window != null) s += ` (${dekebab(str(p.window))})`;\n return s;\n }\n case \"terrain-has-tag\": {\n let s = `${negate}terrain tagged ${dekebab(str(p.tag))}`;\n if (p.friendly_units_min != null) s += ` with ${str(p.friendly_units_min)}+ friendly units`;\n if (p.enemy_units_max != null) s += ` and at most ${str(p.enemy_units_max)} enemy units`;\n if (p.last_marked) s += \" (most recently marked)\";\n if (p.in_enemy_dz) s += \" in the enemy deployment zone\";\n return s;\n }\n case \"terrain-area-control\":\n return `${negate}you control a terrain area with ${str(p.min_models ?? 1)}+ models`;\n case \"territory-control\": {\n let s = `${negate}you control ${dekebab(str(p.territory_ref ?? \"your-territory\"))}`;\n if (p.enemy_units_max != null) s += ` with at most ${str(p.enemy_units_max)} enemy units`;\n return s;\n }\n case \"engagement-fronts\":\n return `${negate}you are engaged on ${str(p.count_min ?? 1)}+ fronts`;\n\n default:\n return `${negate}${dekebab(c.type ?? \"unknown\")}`;\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AAS3B,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AA6CD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,MAAM,EACf,GAAG,CAAC,EAAE,MAAM,GACX,OAAO,CAAC,gBAAgB,CAAC,CA+E3B"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,KAAK,CAAC;AAS3B,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAgDD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,MAAM,EACf,GAAG,CAAC,EAAE,MAAM,GACX,OAAO,CAAC,gBAAgB,CAAC,CAkF3B"}
package/dist/validate.js CHANGED
@@ -17,6 +17,7 @@ const SCHEMA_MAP = {
17
17
  enhancements: "https://40kdc.dev/schemas/core/enhancement.schema.json",
18
18
  stratagems: "https://40kdc.dev/schemas/core/stratagem.schema.json",
19
19
  "wargear-options": "https://40kdc.dev/schemas/core/wargear-option.schema.json",
20
+ wargear: "https://40kdc.dev/schemas/core/wargear.schema.json",
20
21
  "leader-attachments": "https://40kdc.dev/schemas/core/leader-attachment.schema.json",
21
22
  "unit-compositions": "https://40kdc.dev/schemas/core/unit-composition.schema.json",
22
23
  "force-dispositions": "https://40kdc.dev/schemas/core/force-disposition.schema.json",
@@ -34,14 +35,17 @@ const SCHEMA_MAP = {
34
35
  };
35
36
  /**
36
37
  * Determine which schema $id to use for a given data file path.
37
- * Convention: the file's base name prefix (before the first dot) maps to a schema.
38
+ * Convention: the file's base name starts with a SCHEMA_MAP prefix (real data is
39
+ * `<prefix>.json`; test fixtures are `<prefix>-good.json` / `<prefix>-bad.json`).
40
+ * Prefixes are tried longest-first so `wargear-options.json` resolves to the
41
+ * wargear-option schema rather than the shorter `wargear` key (distinct entities).
38
42
  */
43
+ const SCHEMA_PREFIXES = Object.keys(SCHEMA_MAP).sort((a, b) => b.length - a.length);
39
44
  function resolveSchemaId(filePath) {
40
45
  const base = basename(filePath);
41
- for (const [prefix, schemaId] of Object.entries(SCHEMA_MAP)) {
42
- if (base.startsWith(prefix)) {
43
- return schemaId;
44
- }
46
+ for (const prefix of SCHEMA_PREFIXES) {
47
+ if (base.startsWith(prefix))
48
+ return SCHEMA_MAP[prefix];
45
49
  }
46
50
  return null;
47
51
  }
@@ -60,6 +64,10 @@ export async function validateFiles(ajv, pattern, cwd) {
60
64
  errors: [],
61
65
  };
62
66
  for (const file of files) {
67
+ // Underscore-prefixed files are scratch/reports (e.g. the converter's
68
+ // `_wargear-options.unparsed.json`), not dataset entities — skip them.
69
+ if (basename(file).startsWith("_"))
70
+ continue;
63
71
  const schemaId = resolveSchemaId(file);
64
72
  if (!schemaId) {
65
73
  result.errors.push({