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

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 (128) hide show
  1. package/README.md +2 -0
  2. package/dist/abilities-resolver/resolver.d.ts +13 -4
  3. package/dist/abilities-resolver/resolver.d.ts.map +1 -1
  4. package/dist/abilities-resolver/resolver.js +22 -15
  5. package/dist/abilities-resolver/resolver.js.map +1 -1
  6. package/dist/audit-coverage.d.ts +78 -0
  7. package/dist/audit-coverage.d.ts.map +1 -0
  8. package/dist/audit-coverage.js +341 -0
  9. package/dist/audit-coverage.js.map +1 -0
  10. package/dist/author-batch.d.ts +147 -0
  11. package/dist/author-batch.d.ts.map +1 -0
  12. package/dist/author-batch.js +675 -0
  13. package/dist/author-batch.js.map +1 -0
  14. package/dist/author-input.d.ts +37 -0
  15. package/dist/author-input.d.ts.map +1 -0
  16. package/dist/author-input.js +162 -0
  17. package/dist/author-input.js.map +1 -0
  18. package/dist/cli.js +7 -0
  19. package/dist/cli.js.map +1 -1
  20. package/dist/commands/translate.d.ts.map +1 -1
  21. package/dist/commands/translate.js +9 -4
  22. package/dist/commands/translate.js.map +1 -1
  23. package/dist/cruncher/attribution.d.ts +66 -0
  24. package/dist/cruncher/attribution.d.ts.map +1 -0
  25. package/dist/cruncher/attribution.js +88 -0
  26. package/dist/cruncher/attribution.js.map +1 -0
  27. package/dist/cruncher/buffs.d.ts +80 -2
  28. package/dist/cruncher/buffs.d.ts.map +1 -1
  29. package/dist/cruncher/buffs.js +33 -4
  30. package/dist/cruncher/buffs.js.map +1 -1
  31. package/dist/cruncher/engine.d.ts.map +1 -1
  32. package/dist/cruncher/engine.js +50 -15
  33. package/dist/cruncher/engine.js.map +1 -1
  34. package/dist/cruncher/from-dsl.d.ts +32 -0
  35. package/dist/cruncher/from-dsl.d.ts.map +1 -1
  36. package/dist/cruncher/from-dsl.js +605 -45
  37. package/dist/cruncher/from-dsl.js.map +1 -1
  38. package/dist/cruncher/index.d.ts +1 -0
  39. package/dist/cruncher/index.d.ts.map +1 -1
  40. package/dist/cruncher/index.js +1 -0
  41. package/dist/cruncher/index.js.map +1 -1
  42. package/dist/data/bundle.generated.js +1 -1
  43. package/dist/data/bundle.generated.js.map +1 -1
  44. package/dist/data/collection.d.ts +9 -0
  45. package/dist/data/collection.d.ts.map +1 -1
  46. package/dist/data/collection.js +14 -0
  47. package/dist/data/collection.js.map +1 -1
  48. package/dist/data/dataset.d.ts +80 -2
  49. package/dist/data/dataset.d.ts.map +1 -1
  50. package/dist/data/dataset.js +143 -6
  51. package/dist/data/dataset.js.map +1 -1
  52. package/dist/data/entities.d.ts +2 -5
  53. package/dist/data/entities.d.ts.map +1 -1
  54. package/dist/data/entities.js.map +1 -1
  55. package/dist/data/index.d.ts +3 -2
  56. package/dist/data/index.d.ts.map +1 -1
  57. package/dist/data/index.js +1 -1
  58. package/dist/data/index.js.map +1 -1
  59. package/dist/data/normalize.d.ts.map +1 -1
  60. package/dist/data/normalize.js +8 -1
  61. package/dist/data/normalize.js.map +1 -1
  62. package/dist/data/roster-resolve.d.ts +26 -1
  63. package/dist/data/roster-resolve.d.ts.map +1 -1
  64. package/dist/data/roster-resolve.js +46 -0
  65. package/dist/data/roster-resolve.js.map +1 -1
  66. package/dist/export/index.d.ts +1 -0
  67. package/dist/export/index.d.ts.map +1 -1
  68. package/dist/export/index.js +3 -0
  69. package/dist/export/index.js.map +1 -1
  70. package/dist/export/rosterizer.d.ts +3 -0
  71. package/dist/export/rosterizer.d.ts.map +1 -0
  72. package/dist/export/rosterizer.js +144 -0
  73. package/dist/export/rosterizer.js.map +1 -0
  74. package/dist/export/serializer.d.ts +1 -1
  75. package/dist/export/serializer.d.ts.map +1 -1
  76. package/dist/export/serializer.js.map +1 -1
  77. package/dist/gen-conformance.js +212 -11
  78. package/dist/gen-conformance.js.map +1 -1
  79. package/dist/import/gw.d.ts +69 -0
  80. package/dist/import/gw.d.ts.map +1 -0
  81. package/dist/import/gw.js +245 -0
  82. package/dist/import/gw.js.map +1 -0
  83. package/dist/import/import-roster.d.ts +52 -3
  84. package/dist/import/import-roster.d.ts.map +1 -1
  85. package/dist/import/import-roster.js +114 -4
  86. package/dist/import/import-roster.js.map +1 -1
  87. package/dist/import/index.d.ts +2 -2
  88. package/dist/import/index.d.ts.map +1 -1
  89. package/dist/import/index.js +1 -1
  90. package/dist/import/index.js.map +1 -1
  91. package/dist/import/listforge.d.ts.map +1 -1
  92. package/dist/import/listforge.js +15 -1
  93. package/dist/import/listforge.js.map +1 -1
  94. package/dist/import/newrecruit-text.d.ts +3 -0
  95. package/dist/import/newrecruit-text.d.ts.map +1 -1
  96. package/dist/import/newrecruit-text.js +6 -0
  97. package/dist/import/newrecruit-text.js.map +1 -1
  98. package/dist/import/newrecruit-wtc.d.ts.map +1 -1
  99. package/dist/import/newrecruit-wtc.js +10 -7
  100. package/dist/import/newrecruit-wtc.js.map +1 -1
  101. package/dist/import/rosterizer.d.ts +70 -0
  102. package/dist/import/rosterizer.d.ts.map +1 -0
  103. package/dist/import/rosterizer.js +348 -0
  104. package/dist/import/rosterizer.js.map +1 -0
  105. package/dist/import/types.d.ts +1 -1
  106. package/dist/import/types.d.ts.map +1 -1
  107. package/dist/import/types.js.map +1 -1
  108. package/dist/index.d.ts +3 -3
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +2 -2
  111. package/dist/index.js.map +1 -1
  112. package/dist/migrations/2026-weapon-keywords.js +4 -0
  113. package/dist/migrations/2026-weapon-keywords.js.map +1 -1
  114. package/dist/runner.d.ts +38 -0
  115. package/dist/runner.d.ts.map +1 -0
  116. package/dist/runner.js +537 -0
  117. package/dist/runner.js.map +1 -0
  118. package/dist/scrub-defensive-flag.d.ts +23 -0
  119. package/dist/scrub-defensive-flag.d.ts.map +1 -0
  120. package/dist/scrub-defensive-flag.js +149 -0
  121. package/dist/scrub-defensive-flag.js.map +1 -0
  122. package/dist/scrub-ip.d.ts +14 -0
  123. package/dist/scrub-ip.d.ts.map +1 -0
  124. package/dist/scrub-ip.js +88 -0
  125. package/dist/scrub-ip.js.map +1 -0
  126. package/package.json +10 -2
  127. package/schemas/core/roster.schema.json +3 -1
  128. package/schemas/enrichment/ability-dsl/effect.schema.json +1 -1
@@ -17,8 +17,9 @@ const DEFENDER_TARGETS = new Set(["defender", "enemy-within-aura", "all-enemy"])
17
17
  * naming any branches the buff layer can't express today.
18
18
  */
19
19
  export function effectToBuffs(effect, source, context, perspective = "attacker") {
20
- const out = { applied: [], unsupported: [] };
21
- walk(effect, source, { context, perspective }, out);
20
+ const out = { applied: [], unsupported: [], activatable: [] };
21
+ const abilityId = source.kind === "ability" ? source.abilityId : "effect";
22
+ walk(effect, source, { context, perspective, abilityId }, out);
22
23
  return out;
23
24
  }
24
25
  function walk(node, source, opts, out) {
@@ -44,6 +45,12 @@ function walk(node, source, opts, out) {
44
45
  case "bs-modifier":
45
46
  translateBsModifier(node, source, opts, out);
46
47
  return;
48
+ case "damage-reduction":
49
+ translateDamageReduction(node, source, opts, out);
50
+ return;
51
+ case "invulnerable-save":
52
+ translateInvulnerableSave(node, source, opts, out);
53
+ return;
47
54
  case "conditional":
48
55
  translateConditional(node, source, opts, out);
49
56
  return;
@@ -52,11 +59,8 @@ function walk(node, source, opts, out) {
52
59
  walk(step, source, opts, out);
53
60
  return;
54
61
  case "choice":
55
- // Player decision — auto-applying every branch would double-count.
56
- out.unsupported.push({
57
- reason: "choice: player picks one option; the buff layer can't choose",
58
- effectFragment: node,
59
- });
62
+ // Player decision — each branch becomes an opt-in lever (pick one).
63
+ enumerateChoice(node, source, opts, out);
60
64
  return;
61
65
  case "dice-gated":
62
66
  // Probabilistic; the buff layer is deterministic.
@@ -66,10 +70,9 @@ function walk(node, source, opts, out) {
66
70
  });
67
71
  return;
68
72
  case "dice-pool-allocation":
69
- out.unsupported.push({
70
- reason: "dice-pool-allocation: player allocates dice at runtime",
71
- effectFragment: node,
72
- });
73
+ // Player spends dice on options at runtime — each buff-bearing option
74
+ // becomes an opt-in lever, grouped under the pool's activation cap.
75
+ enumerateDicePool(node, source, opts, out);
73
76
  return;
74
77
  default:
75
78
  // Unknown effect — record it. Covers ability-grant, deep-strike,
@@ -132,8 +135,17 @@ function translateReroll(node, source, opts, out) {
132
135
  out.unsupported.push({ reason: "re-roll: missing modifier object", effectFragment: node });
133
136
  return;
134
137
  }
138
+ const narrowed = unhonorableNarrowing(modifier);
139
+ if (narrowed) {
140
+ out.unsupported.push({ reason: `re-roll: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
141
+ return;
142
+ }
135
143
  const roll = modifier.roll;
136
- const subset = modifier.subset;
144
+ // A `value: 1` on a re-roll modifier unambiguously means "re-roll rolls of 1".
145
+ // A historical migration (2026-weapon-keywords) mis-defaulted such nodes to
146
+ // `subset: "all-failures"`; honor the value as the source of truth so any
147
+ // stray data of that shape can't silently over-apply the reroll.
148
+ const subset = modifier.value === 1 ? "ones" : modifier.subset;
137
149
  // Under target perspective, only "save" rerolls fire on the buffed unit.
138
150
  if (opts.perspective === "target" && roll !== "save")
139
151
  return;
@@ -156,6 +168,11 @@ function translateRollModifier(node, source, opts, out) {
156
168
  });
157
169
  return;
158
170
  }
171
+ const narrowed = unhonorableNarrowing(modifier);
172
+ if (narrowed) {
173
+ out.unsupported.push({ reason: `roll-modifier: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
174
+ return;
175
+ }
159
176
  const value = signedValue(modifier);
160
177
  if (value === null) {
161
178
  out.unsupported.push({
@@ -175,11 +192,26 @@ function translateRollModifier(node, source, opts, out) {
175
192
  return; // saves apply to the defender, not the attacker.
176
193
  }
177
194
  else {
178
- // target perspective: only `save` rolls on the buffed unit fire here.
179
- if (roll !== "save")
180
- return;
181
- if (!appliesToBuffedUnit(node, "target"))
195
+ // Target perspective accepts two shapes:
196
+ // - `target: "self"/"unit"/...` + `roll: "save"` — the buffed unit's
197
+ // own save rolls (mirrors the attacker path's reroll/stat handling).
198
+ // - `target: "attacker"` + `roll: "hit"/"wound"` — a defender-side
199
+ // rule that penalises the *incoming* attacker's hit/wound rolls (e.g.
200
+ // "subtract 1 from hit rolls targeting this unit"). Functionally
201
+ // identical to a `bs-modifier {target:"attacker"}` but with the
202
+ // canonical `roll-modifier` shape; the data uses both forms.
203
+ const cls = classifyTarget(node);
204
+ if (cls === "attacker") {
205
+ if (roll !== "hit" && roll !== "wound")
206
+ return; // damage/save against the attacker make no sense here.
207
+ }
208
+ else if (cls === "self") {
209
+ if (roll !== "save")
210
+ return;
211
+ }
212
+ else {
182
213
  return;
214
+ }
183
215
  }
184
216
  switch (roll) {
185
217
  case "hit":
@@ -210,6 +242,27 @@ function translateStatModifier(node, source, opts, out) {
210
242
  });
211
243
  return;
212
244
  }
245
+ const narrowed = unhonorableNarrowing(modifier);
246
+ if (narrowed) {
247
+ out.unsupported.push({ reason: `stat-modifier: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
248
+ return;
249
+ }
250
+ const stat = modifier.stat;
251
+ const isOnBuffedUnit = appliesToBuffedUnit(node, opts.perspective);
252
+ // `attack_type: melee|ranged` scopes the mod to that attack — express it as a
253
+ // phase gate so e.g. a melee +1 Attack doesn't fire in the shooting phase.
254
+ const applicability = attackTypeApplicability(modifier);
255
+ const emit = (contribution) => {
256
+ const buff = { source, contribution };
257
+ out.applied.push(applicability ? { ...buff, applicableWhen: applicability } : buff);
258
+ };
259
+ // AP has an inverted sign convention (stored negative; more negative = more
260
+ // piercing) and offensive/defensive variants, so it computes its own delta
261
+ // and routes by attacker/defender rather than going through `signedValue`.
262
+ if (stat === "AP") {
263
+ translateApModifier(node, modifier, opts, out, emit);
264
+ return;
265
+ }
213
266
  const value = signedValue(modifier);
214
267
  if (value === null) {
215
268
  out.unsupported.push({
@@ -218,18 +271,16 @@ function translateStatModifier(node, source, opts, out) {
218
271
  });
219
272
  return;
220
273
  }
221
- const stat = modifier.stat;
222
- const isOnBuffedUnit = appliesToBuffedUnit(node, opts.perspective);
223
274
  switch (stat) {
224
275
  case "A":
225
276
  if (opts.perspective !== "attacker" || !isOnBuffedUnit)
226
277
  return;
227
- out.applied.push({ source, contribution: { type: "attacks-mod", value } });
278
+ emit({ type: "attacks-mod", value });
228
279
  return;
229
280
  case "S":
230
281
  if (opts.perspective !== "attacker" || !isOnBuffedUnit)
231
282
  return;
232
- out.applied.push({ source, contribution: { type: "strength-mod", value } });
283
+ emit({ type: "strength-mod", value });
233
284
  return;
234
285
  case "T":
235
286
  // Defender stat. Only relevant under target perspective.
@@ -242,7 +293,7 @@ function translateStatModifier(node, source, opts, out) {
242
293
  }
243
294
  if (!isOnBuffedUnit)
244
295
  return;
245
- out.applied.push({ source, contribution: { type: "toughness-mod", value } });
296
+ emit({ type: "toughness-mod", value });
246
297
  return;
247
298
  case "Sv":
248
299
  // Saves improve when the *defender* gets +Sv. A +1 to Sv in printed
@@ -259,16 +310,7 @@ function translateStatModifier(node, source, opts, out) {
259
310
  }
260
311
  if (!isOnBuffedUnit)
261
312
  return;
262
- out.applied.push({ source, contribution: { type: "save-mod", value: -value } });
263
- return;
264
- case "AP":
265
- // AP rides on the attacker's weapon profile and is stored as a negative
266
- // number in the data (e.g. AP -1). The data's `{operation:"add", value:-1}`
267
- // form means "AP becomes one more negative" → more piercing. `signedValue`
268
- // already returns that negative number directly, so pass it through.
269
- if (opts.perspective !== "attacker" || !isOnBuffedUnit)
270
- return;
271
- out.applied.push({ source, contribution: { type: "ap-mod", value } });
313
+ emit({ type: "save-mod", value: -value });
272
314
  return;
273
315
  default:
274
316
  out.unsupported.push({
@@ -277,6 +319,40 @@ function translateStatModifier(node, source, opts, out) {
277
319
  });
278
320
  }
279
321
  }
322
+ /**
323
+ * Translate an `AP` stat-modifier. AP rides on the attacker's weapon profile and
324
+ * is stored as a negative number (e.g. AP -1); more negative = more piercing.
325
+ *
326
+ * Two variants exist in the data:
327
+ * - **offensive** (`target` self/unit): the buffed unit's own weapons gain AP —
328
+ * an attacker-side `ap-mod`. `improve N` → `-N` (more piercing), `worsen N` →
329
+ * `+N`, and the legacy `add`/`subtract` forms (which already pass a signed,
330
+ * usually negative, value) flow through `apDelta` unchanged.
331
+ * - **defensive** (`target: "attacker"`): "enemy weapons targeting this unit
332
+ * have AP worsened". This applies when the buffed unit is the *target*; we do
333
+ * not model it as an attacker-side buff (that would wrongly weaken the buffed
334
+ * unit's own attacks), so it is surfaced as `unsupported`.
335
+ */
336
+ function translateApModifier(node, modifier, opts, out, emit) {
337
+ if (classifyTarget(node) === "attacker") {
338
+ out.unsupported.push({
339
+ reason: "stat-modifier AP on the attacker: defender-side AP reduction is not modelled by the buff layer",
340
+ effectFragment: node,
341
+ });
342
+ return;
343
+ }
344
+ if (opts.perspective !== "attacker" || !appliesToBuffedUnit(node, "attacker"))
345
+ return;
346
+ const delta = apDelta(modifier);
347
+ if (delta === null) {
348
+ out.unsupported.push({
349
+ reason: `stat-modifier AP: operation "${String(modifier.operation)}" not supported`,
350
+ effectFragment: node,
351
+ });
352
+ return;
353
+ }
354
+ emit({ type: "ap-mod", value: delta });
355
+ }
280
356
  function translateFeelNoPain(node, source, opts, out) {
281
357
  // FNP applies when the buffed unit is the *target* — it ablates incoming
282
358
  // damage. Under attacker perspective the FNP is irrelevant (the unit is
@@ -301,7 +377,28 @@ function translateFeelNoPain(node, source, opts, out) {
301
377
  });
302
378
  return;
303
379
  }
304
- out.applied.push({ source, contribution: { type: "feel-no-pain", threshold } });
380
+ // `modifier.scope` {"all", "mortal"} (default "all"). Schema's `modifier`
381
+ // is `additionalProperties: true`, so any string lands here; we accept the
382
+ // two documented values and route everything else to unsupported so a typo
383
+ // ("mortals", "mortal-wound") can't silently masquerade as an all-FNP.
384
+ const rawScope = modifier.scope;
385
+ let scope = "all";
386
+ if (rawScope !== undefined) {
387
+ if (rawScope === "all" || rawScope === "mortal") {
388
+ scope = rawScope;
389
+ }
390
+ else {
391
+ out.unsupported.push({
392
+ reason: `feel-no-pain: unrecognised scope "${String(rawScope)}" (expected "all" or "mortal")`,
393
+ effectFragment: node,
394
+ });
395
+ return;
396
+ }
397
+ }
398
+ const contribution = scope === "mortal"
399
+ ? { type: "feel-no-pain", threshold, scope: "mortal" }
400
+ : { type: "feel-no-pain", threshold };
401
+ out.applied.push({ source, contribution });
305
402
  }
306
403
  function translateKeywordGrant(node, source, opts, out) {
307
404
  // Weapon-keyword grants ride with the attacker's profile (e.g. "your
@@ -315,12 +412,15 @@ function translateKeywordGrant(node, source, opts, out) {
315
412
  const modifier = node.modifier;
316
413
  if (!isObject(modifier))
317
414
  return;
318
- const keywords = modifier.keywords;
319
- if (!Array.isArray(keywords))
415
+ // The DSL grants keywords in two shapes: a singular `keyword` string (often
416
+ // with a `weapon_type`) or a `keywords` array. Accept both.
417
+ const raws = keywordGrantList(modifier);
418
+ if (raws.length === 0)
320
419
  return;
321
- for (const raw of keywords) {
322
- if (typeof raw !== "string")
323
- continue;
420
+ // `weapon_type: melee|ranged` scopes the grant to that attack — a melee-only
421
+ // keyword shouldn't fire in the shooting phase. Express it as a phase gate.
422
+ const applicability = weaponTypeApplicability(modifier);
423
+ for (const raw of raws) {
324
424
  const ref = parseKeywordGrant(raw);
325
425
  if (!ref) {
326
426
  out.unsupported.push({
@@ -329,8 +429,122 @@ function translateKeywordGrant(node, source, opts, out) {
329
429
  });
330
430
  continue;
331
431
  }
332
- out.applied.push({ source, contribution: { type: "extra-keyword", keywordRef: ref } });
432
+ const buff = { source, contribution: { type: "extra-keyword", keywordRef: ref } };
433
+ out.applied.push(applicability ? { ...buff, applicableWhen: applicability } : buff);
434
+ }
435
+ }
436
+ /** Normalise a keyword-grant modifier's singular `keyword` and/or `keywords` array. */
437
+ function keywordGrantList(modifier) {
438
+ const out = [];
439
+ if (typeof modifier.keyword === "string")
440
+ out.push(modifier.keyword);
441
+ if (Array.isArray(modifier.keywords)) {
442
+ for (const k of modifier.keywords)
443
+ if (typeof k === "string")
444
+ out.push(k);
445
+ }
446
+ return out;
447
+ }
448
+ /** Map a keyword-grant's `weapon_type` to the phase its weapons fire in. */
449
+ function weaponTypeApplicability(modifier) {
450
+ if (modifier.weapon_type === "melee")
451
+ return { phases: ["fight"] };
452
+ if (modifier.weapon_type === "ranged")
453
+ return { phases: ["shooting"] };
454
+ return undefined;
455
+ }
456
+ /**
457
+ * Map a stat-modifier's `attack_type` (or the equivalent `weapon_type`) to the
458
+ * phase that attack happens in. Both spellings carry the same melee/ranged
459
+ * intent; honoring `weapon_type` lets a "+1 A to melee weapons" mod phase-gate
460
+ * correctly instead of leaking into the shooting phase.
461
+ */
462
+ function attackTypeApplicability(modifier) {
463
+ const kind = modifier.attack_type ?? modifier.weapon_type;
464
+ if (kind === "melee")
465
+ return { phases: ["fight"] };
466
+ if (kind === "ranged")
467
+ return { phases: ["shooting"] };
468
+ return undefined;
469
+ }
470
+ /**
471
+ * Narrowing keys that scope a buff to a named weapon or a model subset the
472
+ * cruncher can't resolve at translation time (it has no weapon/model context
473
+ * here). When present on a damage-path leaf, applying the buff unfiltered would
474
+ * silently OVER-APPLY it, so we surface it as `unsupported` instead — the data
475
+ * stays faithful for other consumers; the optimizer just doesn't assume it.
476
+ * `weapon_type`/`attack_type` are NOT here — those map cleanly to a phase gate.
477
+ */
478
+ const UNHONORABLE_NARROWING = ["weapon_name", "weapon_profile", "weapon_keyword", "weapon_filter", "model_filter", "model_scope"];
479
+ function unhonorableNarrowing(modifier) {
480
+ return UNHONORABLE_NARROWING.find((k) => modifier[k] != null);
481
+ }
482
+ /**
483
+ * Defender-side damage-reduction (`{type: "damage-reduction", modifier:
484
+ * {reduction: N | "half" | "to-zero"}}`). The buff layer only models the
485
+ * additive numeric form — `"half"` and `"to-zero"` are one-use ablation
486
+ * effects that don't fold into a deterministic expected-value crunch, so
487
+ * they surface as `unsupported`. Attacker-perspective walks drop silently
488
+ * (this is a defender stat).
489
+ */
490
+ function translateDamageReduction(node, source, opts, out) {
491
+ if (opts.perspective !== "target")
492
+ return;
493
+ if (!appliesToBuffedUnit(node, "target"))
494
+ return;
495
+ const modifier = node.modifier;
496
+ if (!isObject(modifier)) {
497
+ out.unsupported.push({
498
+ reason: "damage-reduction: missing modifier object",
499
+ effectFragment: node,
500
+ });
501
+ return;
502
+ }
503
+ const reduction = modifier.reduction;
504
+ if (typeof reduction === "number" && Number.isFinite(reduction) && reduction > 0) {
505
+ out.applied.push({ source, contribution: { type: "damage-reduction", value: reduction } });
506
+ return;
507
+ }
508
+ if (reduction === "half" || reduction === "to-zero") {
509
+ out.unsupported.push({
510
+ reason: `damage-reduction: "${reduction}" is a one-use ablation effect, not modelled by the expected-value engine`,
511
+ effectFragment: node,
512
+ });
513
+ return;
514
+ }
515
+ out.unsupported.push({
516
+ reason: `damage-reduction: unrecognised reduction "${String(reduction)}"`,
517
+ effectFragment: node,
518
+ });
519
+ }
520
+ /**
521
+ * Defender-side ability-granted invulnerable save (`{type: "invulnerable-save",
522
+ * modifier: {invuln_sv: N}}`). Best (lowest threshold) wins, combined with the
523
+ * unit's printed invuln by the engine. Attacker-perspective walks drop
524
+ * silently (this is a defender stat).
525
+ */
526
+ function translateInvulnerableSave(node, source, opts, out) {
527
+ if (opts.perspective !== "target")
528
+ return;
529
+ if (!appliesToBuffedUnit(node, "target"))
530
+ return;
531
+ const modifier = node.modifier;
532
+ if (!isObject(modifier)) {
533
+ out.unsupported.push({
534
+ reason: "invulnerable-save: missing modifier object",
535
+ effectFragment: node,
536
+ });
537
+ return;
333
538
  }
539
+ const threshold = Number(modifier.invuln_sv);
540
+ if (!Number.isFinite(threshold) || threshold < 2 || threshold > 7) {
541
+ out.unsupported.push({
542
+ reason: `invulnerable-save: invuln_sv "${String(modifier.invuln_sv)}" is not a valid save threshold (2–7)`,
543
+ effectFragment: node,
544
+ });
545
+ return;
546
+ }
547
+ out.applied.push({ source, contribution: { type: "invulnerable-save", threshold } });
334
548
  }
335
549
  function translateBsModifier(node, source, opts, out) {
336
550
  // A bs-modifier on `target: "attacker"` is a defender-side rule: it
@@ -358,10 +572,18 @@ function translateConditional(node, source, opts, out) {
358
572
  const negated = condition.negated === true;
359
573
  const verdict = evaluateCondition(condition, opts.context);
360
574
  if (verdict === "unknown") {
361
- out.unsupported.push({
362
- reason: `conditional: cannot evaluate condition "${String(condition.type)}" against current context`,
363
- effectFragment: node,
364
- });
575
+ // A timing the player controls (e.g. "start of phase") isn't a wall — it's
576
+ // an activation the player can opt into. Surface it as a lever rather than
577
+ // dropping it. Other unevaluatable conditions stay unsupported.
578
+ if (conditionMentionsTiming(condition)) {
579
+ enumerateTimingGate(node, source, opts, out);
580
+ }
581
+ else {
582
+ out.unsupported.push({
583
+ reason: `conditional: cannot evaluate condition "${String(condition.type)}" against current context`,
584
+ effectFragment: node,
585
+ });
586
+ }
365
587
  return;
366
588
  }
367
589
  const active = negated ? !verdict : verdict;
@@ -370,6 +592,300 @@ function translateConditional(node, source, opts, out) {
370
592
  walk(effect, source, opts, out);
371
593
  }
372
594
  // ---------------------------------------------------------------------------
595
+ // Activatable-lever enumeration
596
+ //
597
+ // Player-controlled gates — a `timing-is` the context can't pin down, each
598
+ // `dice-pool-allocation` option, each `choice` branch — aren't walls for a
599
+ // damage optimizer; they're the search space. Instead of dropping them to
600
+ // `unsupported`, we descend through them and surface every buff-bearing branch
601
+ // as an opt-in {@link ActivatableBuff}. The descent reuses the normal leaf
602
+ // translators (so a lever applies exactly what it advertises) and turns the
603
+ // conditions a branch still carries (target keyword, phase) into declarative
604
+ // `applicableWhen` so the resolver gates them per-target.
605
+ // ---------------------------------------------------------------------------
606
+ /** Emit one lever per `choice` branch that yields a buff (pick exactly one). */
607
+ function enumerateChoice(node, source, opts, out) {
608
+ const options = Array.isArray(node.options) ? node.options : [];
609
+ options.forEach((opt, i) => {
610
+ const buffs = [];
611
+ collectGatedBuffs(opt, source, opts, {}, buffs);
612
+ if (buffs.length === 0)
613
+ return;
614
+ out.activatable.push({
615
+ id: `${opts.abilityId}?${i}`,
616
+ label: labelForBuffs(buffs),
617
+ buffs,
618
+ group: { id: `${opts.abilityId}?choice`, maxActivations: 1 },
619
+ });
620
+ });
621
+ }
622
+ /** Emit one lever per buff-bearing dice-pool option, capped by `max_activations`. */
623
+ function enumerateDicePool(node, source, opts, out) {
624
+ const options = Array.isArray(node.options) ? node.options : [];
625
+ const maxActivations = typeof node.max_activations === "number" ? node.max_activations : options.length;
626
+ for (const opt of options) {
627
+ if (!isObject(opt))
628
+ continue;
629
+ const buffs = [];
630
+ collectGatedBuffs(opt.effect, source, opts, {}, buffs);
631
+ if (buffs.length === 0)
632
+ continue;
633
+ const name = typeof opt.name === "string" && opt.name ? opt.name : labelForBuffs(buffs);
634
+ out.activatable.push({
635
+ id: `${opts.abilityId}#${name}`,
636
+ label: name,
637
+ buffs,
638
+ group: { id: opts.abilityId, maxActivations },
639
+ });
640
+ }
641
+ }
642
+ /**
643
+ * Surface a timing-gated activation. The timing itself is just "when" — opting
644
+ * in satisfies it — so we descend into the body: an inner `dice-pool-allocation`
645
+ * or `choice` surfaces its *own* option levers (e.g. Blessings of Khorne's
646
+ * three keyword grants), while inner always-on buffs bundle into a single
647
+ * timing lever. A body with no modelable combat buff (a `resurrection` or
648
+ * `dice-gated`, like Berzerker Frenzy) yields nothing.
649
+ */
650
+ function enumerateTimingGate(node, source, opts, out) {
651
+ const condition = node.condition;
652
+ if (!isObject(condition))
653
+ return;
654
+ const sub = { applied: [], unsupported: [], activatable: [] };
655
+ walk(node.effect, source, opts, sub);
656
+ // Inner independent decisions (dice-pool options, choice branches) pass
657
+ // straight through as their own levers.
658
+ out.activatable.push(...sub.activatable);
659
+ // Inner unconditional buffs become one lever gated only on the timing.
660
+ if (sub.applied.length > 0) {
661
+ const timing = extractTiming(condition) ?? "timing";
662
+ out.activatable.push({
663
+ id: `${opts.abilityId}@${timing}`,
664
+ label: labelForBuffs(sub.applied),
665
+ buffs: sub.applied,
666
+ });
667
+ }
668
+ }
669
+ /**
670
+ * Walk the body of a player gate, collecting the buffs it would contribute.
671
+ * Conditions are deferred to `applicableWhen` where expressible; nested
672
+ * decisions and stochastic rolls inside an activation are not modelled.
673
+ */
674
+ function collectGatedBuffs(node, source, opts, applicability, outBuffs) {
675
+ if (!isObject(node))
676
+ return;
677
+ switch (node.type) {
678
+ case "conditional": {
679
+ const condition = node.condition;
680
+ if (!isObject(condition))
681
+ return;
682
+ const app = conditionToApplicability(condition);
683
+ if (app === "gate") {
684
+ // A nested timing gate: opting into the activation satisfies it, so
685
+ // keep descending without adding a constraint.
686
+ collectGatedBuffs(node.effect, source, opts, applicability, outBuffs);
687
+ return;
688
+ }
689
+ if (app === "context") {
690
+ // Can't express as a buff gate — fall back to the current context and
691
+ // only descend when the condition is definitely active.
692
+ if (evaluateCondition(condition, opts.context) === true) {
693
+ collectGatedBuffs(node.effect, source, opts, applicability, outBuffs);
694
+ }
695
+ return;
696
+ }
697
+ collectGatedBuffs(node.effect, source, opts, combineApplicability(applicability, app), outBuffs);
698
+ return;
699
+ }
700
+ case "sequence":
701
+ for (const step of node.steps ?? []) {
702
+ collectGatedBuffs(step, source, opts, applicability, outBuffs);
703
+ }
704
+ return;
705
+ case "choice":
706
+ case "dice-pool-allocation":
707
+ case "dice-gated":
708
+ // A decision (or stochastic roll) nested inside an activation. The outer
709
+ // lever already stands for a player choice; we don't model the inner one.
710
+ return;
711
+ default: {
712
+ // Leaf effect — run the normal leaf translators into a throwaway sink,
713
+ // then attach the accumulated applicability so target/phase gating
714
+ // defers to the resolver instead of vanishing the lever.
715
+ const tmp = { applied: [], unsupported: [], activatable: [] };
716
+ walk(node, source, opts, tmp);
717
+ for (const b of tmp.applied)
718
+ outBuffs.push(applyApplicability(b, applicability));
719
+ return;
720
+ }
721
+ }
722
+ }
723
+ /** Does this condition (or any operand) gate on a player-controlled timing? */
724
+ function conditionMentionsTiming(condition) {
725
+ if (condition.type === "timing-is")
726
+ return true;
727
+ if (typeof condition.operator === "string" && Array.isArray(condition.operands)) {
728
+ return condition.operands.some((o) => isObject(o) && conditionMentionsTiming(o));
729
+ }
730
+ return false;
731
+ }
732
+ /** Pull the first `timing-is` timing value out of a (possibly compound) condition. */
733
+ function extractTiming(condition) {
734
+ if (condition.type === "timing-is") {
735
+ const t = condition.parameters?.timing;
736
+ return typeof t === "string" ? t : undefined;
737
+ }
738
+ if (Array.isArray(condition.operands)) {
739
+ for (const o of condition.operands) {
740
+ if (isObject(o)) {
741
+ const t = extractTiming(o);
742
+ if (t)
743
+ return t;
744
+ }
745
+ }
746
+ }
747
+ return undefined;
748
+ }
749
+ /**
750
+ * Translate a condition into a {@link BuffApplicability} the resolver can gate
751
+ * on. Returns `"gate"` for a player-controlled timing (satisfied by opting in),
752
+ * or `"context"` when the condition has no declarative buff representation and
753
+ * must fall back to context evaluation.
754
+ */
755
+ function conditionToApplicability(condition) {
756
+ if (condition.negated === true)
757
+ return "context";
758
+ if (typeof condition.operator === "string" && Array.isArray(condition.operands)) {
759
+ if (condition.operator !== "and")
760
+ return "context";
761
+ let merged = {};
762
+ for (const operand of condition.operands) {
763
+ if (!isObject(operand))
764
+ return "context";
765
+ const a = conditionToApplicability(operand);
766
+ if (a === "gate")
767
+ continue; // timing operand: satisfied by opting in.
768
+ if (a === "context")
769
+ return "context";
770
+ merged = combineApplicability(merged, a);
771
+ }
772
+ return merged;
773
+ }
774
+ const params = condition.parameters;
775
+ switch (condition.type) {
776
+ case "timing-is":
777
+ return "gate";
778
+ case "phase-is": {
779
+ const phase = params?.phase;
780
+ return typeof phase === "string" ? { phases: [phase] } : "context";
781
+ }
782
+ case "target-has-keyword": {
783
+ const kw = params?.keyword;
784
+ return typeof kw === "string" ? { requiresTargetKeyword: kw } : "context";
785
+ }
786
+ case "unit-has-keyword": {
787
+ const kw = params?.keyword;
788
+ return typeof kw === "string" ? { requiresAttackerKeyword: kw } : "context";
789
+ }
790
+ case "attack-is-type": {
791
+ const t = params?.attack_type;
792
+ if (t === "melee")
793
+ return { phases: ["fight"] };
794
+ if (t === "ranged")
795
+ return { phases: ["shooting"] };
796
+ return "context";
797
+ }
798
+ default:
799
+ return "context";
800
+ }
801
+ }
802
+ /** Merge two applicabilities; `phases` intersect, the rest narrow. */
803
+ function combineApplicability(a, b) {
804
+ const out = { ...a };
805
+ if (b.phases) {
806
+ out.phases = a.phases ? a.phases.filter((p) => b.phases.includes(p)) : b.phases;
807
+ }
808
+ if (b.rollType)
809
+ out.rollType = b.rollType;
810
+ if (b.requiresTargetKeyword)
811
+ out.requiresTargetKeyword = b.requiresTargetKeyword;
812
+ if (b.requiresAttackerKeyword)
813
+ out.requiresAttackerKeyword = b.requiresAttackerKeyword;
814
+ return out;
815
+ }
816
+ /** Attach an accumulated applicability to a buff (no-op when empty). */
817
+ function applyApplicability(buff, applicability) {
818
+ if (Object.keys(applicability).length === 0)
819
+ return buff;
820
+ const merged = buff.applicableWhen
821
+ ? combineApplicability(buff.applicableWhen, applicability)
822
+ : applicability;
823
+ return { ...buff, applicableWhen: merged };
824
+ }
825
+ /** A short, deduped human label summarising a lever's contributions. */
826
+ function labelForBuffs(buffs) {
827
+ const seen = new Set();
828
+ const parts = [];
829
+ for (const b of buffs) {
830
+ const p = describeContribution(b.contribution);
831
+ if (!seen.has(p)) {
832
+ seen.add(p);
833
+ parts.push(p);
834
+ }
835
+ }
836
+ return parts.join(", ") || "buff";
837
+ }
838
+ function describeContribution(c) {
839
+ switch (c.type) {
840
+ case "extra-keyword":
841
+ return keywordLabel(c.keywordRef);
842
+ case "hit-mod":
843
+ return `${signed(c.value)} to hit`;
844
+ case "wound-mod":
845
+ return `${signed(c.value)} to wound`;
846
+ case "save-mod":
847
+ return `${signed(c.value)} to save`;
848
+ case "damage-mod":
849
+ return `${signed(c.value)} damage`;
850
+ case "attacks-mod":
851
+ return `${signed(c.value)} attacks`;
852
+ case "strength-mod":
853
+ return `${signed(c.value)} strength`;
854
+ case "toughness-mod":
855
+ return `${signed(c.value)} toughness`;
856
+ case "ap-mod":
857
+ return `AP ${c.value}`;
858
+ case "reroll":
859
+ return `re-roll ${c.roll}${c.subset === "ones" ? " 1s" : ""}`;
860
+ case "feel-no-pain":
861
+ return c.scope === "mortal"
862
+ ? `feel no pain ${c.threshold}+ vs mortals`
863
+ : `feel no pain ${c.threshold}+`;
864
+ case "damage-reduction":
865
+ return `-${c.value} damage`;
866
+ case "invulnerable-save":
867
+ return `${c.threshold}+ invuln`;
868
+ case "cover":
869
+ return "cover";
870
+ }
871
+ }
872
+ function signed(n) {
873
+ return n >= 0 ? `+${n}` : `${n}`;
874
+ }
875
+ /** Render a weapon-keyword ref back to its printed form (best-effort). */
876
+ function keywordLabel(ref) {
877
+ const params = ref.parameters ?? {};
878
+ if (ref.keyword_id === "anti" && typeof params.target_keyword === "string") {
879
+ const th = params.threshold;
880
+ return `Anti-${params.target_keyword}${typeof th === "number" ? ` ${th}+` : ""}`;
881
+ }
882
+ const base = ref.keyword_id
883
+ .split("-")
884
+ .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w))
885
+ .join(" ");
886
+ return typeof params.value === "number" ? `${base} ${params.value}` : base;
887
+ }
888
+ // ---------------------------------------------------------------------------
373
889
  // Condition evaluator
374
890
  // ---------------------------------------------------------------------------
375
891
  function evaluateCondition(condition, ctx) {
@@ -398,6 +914,13 @@ function evaluateCondition(condition, ctx) {
398
914
  }
399
915
  case "remained-stationary":
400
916
  return ctx.attackerStationary === true;
917
+ case "charged-this-turn":
918
+ // A player-controlled context flag (did the buffed unit charge this turn?),
919
+ // mirroring `remained-stationary`. Undefined → the caller couldn't pin it
920
+ // down, so stay "unknown" and let the SPA surface the gap.
921
+ if (ctx.attackerCharged === undefined)
922
+ return "unknown";
923
+ return ctx.attackerCharged;
401
924
  case "target-has-keyword": {
402
925
  const kw = condition.parameters?.keyword;
403
926
  if (typeof kw !== "string")
@@ -411,9 +934,14 @@ function evaluateCondition(condition, ctx) {
411
934
  return (ctx.attackerKeywords ?? []).includes(kw.toLowerCase());
412
935
  }
413
936
  case "is-attached":
414
- // The resolver knows whether a leader is attached; absent that signal
415
- // here, treat as unknown so the SPA can surface the gap.
416
- return "unknown";
937
+ case "model-is-leader":
938
+ // True whenever the buffed unit is a combined ("attached") unit. We do
939
+ // not thread per-member leader identity — "attachment present" is the
940
+ // signal both conditions gate on. Undefined flag (caller couldn't
941
+ // determine attachment) stays "unknown" so the SPA surfaces the gap.
942
+ if (ctx.attackerAttached === undefined)
943
+ return "unknown";
944
+ return ctx.attackerAttached;
417
945
  default:
418
946
  return "unknown";
419
947
  }
@@ -471,6 +999,38 @@ function signedValue(modifier) {
471
999
  if (!Number.isFinite(value))
472
1000
  return null;
473
1001
  switch (modifier.operation) {
1002
+ case "add":
1003
+ return value;
1004
+ case "subtract":
1005
+ return -value;
1006
+ // For the symmetric stats (A/S/T) and roll-/bs-modifiers, "improve" moves
1007
+ // the number up (beneficial) and "worsen" down. AP is handled separately by
1008
+ // `apDelta`, which inverts this because AP's beneficial direction is more
1009
+ // negative.
1010
+ case "improve":
1011
+ return value;
1012
+ case "worsen":
1013
+ return -value;
1014
+ default:
1015
+ // set / halve / multiply: not a single signed delta — left unsupported.
1016
+ return null;
1017
+ }
1018
+ }
1019
+ /**
1020
+ * Read the AP delta out of a stat-modifier `{operation, value}` pair. AP is
1021
+ * stored negative (more negative = more piercing), so "improve" makes it more
1022
+ * negative and "worsen" less. The legacy `add`/`subtract` forms pass a signed
1023
+ * value through directly (the data already encodes the sign).
1024
+ */
1025
+ function apDelta(modifier) {
1026
+ const value = Number(modifier.value);
1027
+ if (!Number.isFinite(value))
1028
+ return null;
1029
+ switch (modifier.operation) {
1030
+ case "improve":
1031
+ return -Math.abs(value);
1032
+ case "worsen":
1033
+ return Math.abs(value);
474
1034
  case "add":
475
1035
  return value;
476
1036
  case "subtract":