@alpaca-software/40kdc-data 0.1.3 → 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.
@@ -54,9 +54,19 @@ export type BuffContribution = {
54
54
  } | {
55
55
  type: "extra-keyword";
56
56
  keywordRef: WeaponKeywordRef;
57
- } | {
57
+ }
58
+ /**
59
+ * Feel-no-pain: roll one D6 per unsaved wound at `threshold`+, ignoring the
60
+ * wound on a pass. `scope` controls which wound stream it applies to:
61
+ * - `"all"` (default): every unsaved wound (main + mortal).
62
+ * - `"mortal"`: mortal-wound stream only (e.g. Death Guard 5+ FNP vs
63
+ * mortals). A target may carry both an all-FNP and a mortal-FNP; the
64
+ * engine rolls both against mortals.
65
+ */
66
+ | {
58
67
  type: "feel-no-pain";
59
68
  threshold: number;
69
+ scope?: "all" | "mortal";
60
70
  } | {
61
71
  type: "damage-mod";
62
72
  value: number;
@@ -84,6 +94,28 @@ export type BuffContribution = {
84
94
  | {
85
95
  type: "ap-mod";
86
96
  value: number;
97
+ }
98
+ /**
99
+ * Defender-side: subtract `value` from each unsaved damage point (floored at
100
+ * 1 by the engine). Multiple sources do NOT stack in 10e — the largest
101
+ * reduction wins. The corpus also encodes `"half"` and `"to-zero"`
102
+ * reductions; the buff layer only models the additive form because the
103
+ * other two are typically one-use ablation that doesn't fold into the
104
+ * expected-value math cleanly.
105
+ */
106
+ | {
107
+ type: "damage-reduction";
108
+ value: number;
109
+ }
110
+ /**
111
+ * Defender-side: ability-granted invulnerable save threshold (e.g. a buff
112
+ * that grants a 4+ invuln). Best (lowest) threshold wins; the engine then
113
+ * picks the better of `printed Sv after AP/cover` and `effective invuln`
114
+ * (invuln bypasses both AP and cover).
115
+ */
116
+ | {
117
+ type: "invulnerable-save";
118
+ threshold: number;
87
119
  };
88
120
  /** Optional gating; the resolver drops buffs whose gate fails. */
89
121
  export type BuffApplicability = {
@@ -172,10 +204,16 @@ export type ResolvedModifiers = {
172
204
  keywordRef: WeaponKeywordRef;
173
205
  source: BuffSource;
174
206
  }[];
207
+ /** All-wound FNP — fires on the main and mortal damage streams alike. */
175
208
  feelNoPain: {
176
209
  threshold: number;
177
210
  dominantSource: BuffSource;
178
211
  } | null;
212
+ /** Mortal-only FNP — fires only on the mortal-wound damage stream. */
213
+ feelNoPainMortal: {
214
+ threshold: number;
215
+ dominantSource: BuffSource;
216
+ } | null;
179
217
  damageMod: {
180
218
  value: number;
181
219
  sources: BuffSource[];
@@ -196,6 +234,24 @@ export type ResolvedModifiers = {
196
234
  value: number;
197
235
  sources: BuffSource[];
198
236
  };
237
+ /**
238
+ * Defender-side damage reduction. Highest-wins (multiple sources do not
239
+ * stack in 10e); the dominant source is the one whose value matches the
240
+ * surviving reduction.
241
+ */
242
+ damageReduction: {
243
+ value: number;
244
+ dominantSource: BuffSource | null;
245
+ };
246
+ /**
247
+ * Ability-granted invulnerable save. Best (lowest) threshold wins. `null`
248
+ * when no ability granted one; the engine still uses the unit's printed
249
+ * `invuln_sv` from the profile in that case.
250
+ */
251
+ invulnerable: {
252
+ threshold: number;
253
+ dominantSource: BuffSource;
254
+ } | null;
199
255
  };
200
256
  /**
201
257
  * Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure
@@ -1 +1 @@
1
- {"version":3,"file":"buffs.d.ts","sourceRoot":"","sources":["../../src/cruncher/buffs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAE7C,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAC/D;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EACP,MAAM,GACN,YAAY,GACZ,sBAAsB,GACtB,MAAM,GACN,UAAU,GACV,SAAS,CAAC;IACd;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GACD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC,oFAAoF;AACpF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;IAC1C,MAAM,EAAE,MAAM,GAAG,cAAc,CAAC;CACjC,GACD;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,UAAU,EAAE,gBAAgB,CAAA;CAAE,GACvD;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAC3C;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACvC,2EAA2E;GACzE;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACxC,yDAAyD;GACvD;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACzC,0DAA0D;GACxD;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AAC1C;;;;GAIG;GACD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC,kEAAkE;AAClE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;IAC/C,yDAAyD;IACzD,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,2DAA2D;IAC3D,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,MAAM,IAAI,GAAG;IACjB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,iBAAiB,CAAC;IACnC,YAAY,EAAE,gBAAgB,CAAC;CAChC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,KAAK,CAAC;IACb,oEAAoE;IACpE,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;;OAKG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,gEAAgE;IAChE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,uFAAuF;IACvF,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,yFAAyF;IACzF,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,kFAAkF;IAClF,gBAAgB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACzC,gFAAgF;IAChF,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,4EAA4E;AAC5E,MAAM,MAAM,cAAc,GAAG,aAAa,CAAC;AAE3C,oEAAoE;AACpE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE,CAAC;IAC7D,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE,CAAC;IAC/D,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IAClD,KAAK,EAAE;QAAE,MAAM,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE,CAAC;IACtD,OAAO,EAAE,OAAO,CACd,MAAM,CACJ,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,EACnC;QAAE,MAAM,EAAE,MAAM,GAAG,cAAc,CAAC;QAAC,cAAc,EAAE,UAAU,CAAA;KAAE,CAChE,CACF,CAAC;IACF,aAAa,EAAE;QAAE,UAAU,EAAE,gBAAgB,CAAC;QAAC,MAAM,EAAE,UAAU,CAAA;KAAE,EAAE,CAAC;IACtE,UAAU,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,CAAA;KAAE,GAAG,IAAI,CAAC;IACrE,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IACpD,UAAU,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IACrD,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IACtD,YAAY,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IACvD,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;CACjD,CAAC;AAqCF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,cAAc,GAAG,iBAAiB,CA+FlF"}
1
+ {"version":3,"file":"buffs.d.ts","sourceRoot":"","sources":["../../src/cruncher/buffs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAE7C,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,gBAAgB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAC/D;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EACP,MAAM,GACN,YAAY,GACZ,sBAAsB,GACtB,MAAM,GACN,UAAU,GACV,SAAS,CAAC;IACd;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GACD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC,oFAAoF;AACpF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IACE,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;IAC1C,MAAM,EAAE,MAAM,GAAG,cAAc,CAAC;CACjC,GACD;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,UAAU,EAAE,gBAAgB,CAAA;CAAE;AACzD;;;;;;;GAOG;GACD;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAA;CAAE,GACrE;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACvC,2EAA2E;GACzE;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACxC,yDAAyD;GACvD;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACzC,0DAA0D;GACxD;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AAC1C;;;;GAIG;GACD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AACnC;;;;;;;GAOG;GACD;IAAE,IAAI,EAAE,kBAAkB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AAC7C;;;;;GAKG;GACD;IAAE,IAAI,EAAE,mBAAmB,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAErD,kEAAkE;AAClE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;IAC/C,yDAAyD;IACzD,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,2DAA2D;IAC3D,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,CAAC;AAEF,+EAA+E;AAC/E,MAAM,MAAM,IAAI,GAAG;IACjB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,iBAAiB,CAAC;IACnC,YAAY,EAAE,gBAAgB,CAAC;CAChC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,KAAK,CAAC;IACb,oEAAoE;IACpE,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;;OAKG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,gEAAgE;IAChE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,uFAAuF;IACvF,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,yFAAyF;IACzF,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,kFAAkF;IAClF,gBAAgB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACzC,gFAAgF;IAChF,cAAc,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B,CAAC;AAEF,4EAA4E;AAC5E,MAAM,MAAM,cAAc,GAAG,aAAa,CAAC;AAE3C,oEAAoE;AACpE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE,CAAC;IAC7D,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE,CAAC;IAC/D,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IAClD,KAAK,EAAE;QAAE,MAAM,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE,CAAC;IACtD,OAAO,EAAE,OAAO,CACd,MAAM,CACJ,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,EACnC;QAAE,MAAM,EAAE,MAAM,GAAG,cAAc,CAAC;QAAC,cAAc,EAAE,UAAU,CAAA;KAAE,CAChE,CACF,CAAC;IACF,aAAa,EAAE;QAAE,UAAU,EAAE,gBAAgB,CAAC;QAAC,MAAM,EAAE,UAAU,CAAA;KAAE,EAAE,CAAC;IACtE,yEAAyE;IACzE,UAAU,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,CAAA;KAAE,GAAG,IAAI,CAAC;IACrE,sEAAsE;IACtE,gBAAgB,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3E,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IACpD,UAAU,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IACrD,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IACtD,YAAY,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IACvD,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IAChD;;;;OAIG;IACH,eAAe,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE,CAAC;IACtE;;;;OAIG;IACH,YAAY,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,UAAU,CAAA;KAAE,GAAG,IAAI,CAAC;CACxE,CAAC;AAqCF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,cAAc,GAAG,iBAAiB,CAgIlF"}
@@ -50,11 +50,14 @@ export function resolveBuffs(buffs, ctx) {
50
50
  rerolls: {},
51
51
  extraKeywords: [],
52
52
  feelNoPain: null,
53
+ feelNoPainMortal: null,
53
54
  damageMod: { value: 0, sources: [] },
54
55
  attacksMod: { value: 0, sources: [] },
55
56
  strengthMod: { value: 0, sources: [] },
56
57
  toughnessMod: { value: 0, sources: [] },
57
58
  apMod: { value: 0, sources: [] },
59
+ damageReduction: { value: 0, dominantSource: null },
60
+ invulnerable: null,
58
61
  };
59
62
  // Hit / wound mods: sum, then cap at ±1, with dominant source picked from
60
63
  // the contributors whose sign matches the surviving value.
@@ -100,11 +103,17 @@ export function resolveBuffs(buffs, ctx) {
100
103
  }
101
104
  break;
102
105
  }
103
- case "feel-no-pain":
104
- if (out.feelNoPain === null || c.threshold < out.feelNoPain.threshold) {
105
- out.feelNoPain = { threshold: c.threshold, dominantSource: b.source };
106
+ case "feel-no-pain": {
107
+ // Best (lowest) threshold wins per scope. An undeclared scope is
108
+ // treated as "all" that's the existing convention (unscoped FNP =
109
+ // applies to every wound) and keeps every shipped FNP buff regression-safe.
110
+ const scope = c.scope ?? "all";
111
+ const slot = scope === "mortal" ? "feelNoPainMortal" : "feelNoPain";
112
+ if (out[slot] === null || c.threshold < out[slot].threshold) {
113
+ out[slot] = { threshold: c.threshold, dominantSource: b.source };
106
114
  }
107
115
  break;
116
+ }
108
117
  case "damage-mod":
109
118
  out.damageMod.value += c.value;
110
119
  out.damageMod.sources.push(b.source);
@@ -125,6 +134,26 @@ export function resolveBuffs(buffs, ctx) {
125
134
  out.apMod.value += c.value;
126
135
  out.apMod.sources.push(b.source);
127
136
  break;
137
+ case "damage-reduction":
138
+ // Highest reduction wins (no stacking). Ties break by source rank so
139
+ // an ability source is preferred over a manual one for provenance
140
+ // purposes; either way the resolved value is unchanged.
141
+ if (out.damageReduction.dominantSource === null ||
142
+ c.value > out.damageReduction.value ||
143
+ (c.value === out.damageReduction.value &&
144
+ rank(b.source) < rank(out.damageReduction.dominantSource))) {
145
+ out.damageReduction = { value: c.value, dominantSource: b.source };
146
+ }
147
+ break;
148
+ case "invulnerable-save":
149
+ // Best (lowest threshold) wins. Same tie-break by source rank.
150
+ if (out.invulnerable === null ||
151
+ c.threshold < out.invulnerable.threshold ||
152
+ (c.threshold === out.invulnerable.threshold &&
153
+ rank(b.source) < rank(out.invulnerable.dominantSource))) {
154
+ out.invulnerable = { threshold: c.threshold, dominantSource: b.source };
155
+ }
156
+ break;
128
157
  }
129
158
  }
130
159
  out.hitMod = capModifier(hitContribs);
@@ -1 +1 @@
1
- {"version":3,"file":"buffs.js","sourceRoot":"","sources":["../../src/cruncher/buffs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The flat `Buff` type every contribution flows through, and the\n * {@link resolveBuffs} resolver that collapses a stack into a\n * {@link ResolvedModifiers} read-out the engine can consume.\n *\n * The same shape carries weapon-keyword effects, ability buffs, stratagem\n * effects, and manual UI toggles — reroll-stacking, hit/wound caps, and\n * feel-no-pain-best-threshold all fall out of one resolver rather than each\n * source kind reinventing precedence.\n *\n * @packageDocumentation\n */\nimport type { Phase } from \"../generated.js\";\n\n/** Where a buff originated. Drives stable tie-breaking inside `resolveBuffs`. */\nexport type BuffSource =\n | { kind: \"weapon-keyword\"; weaponId: string; keywordId: string }\n | {\n kind: \"ability\";\n abilityId: string;\n abilityKind:\n | \"army\"\n | \"detachment\"\n | \"detachment-stratagem\"\n | \"unit\"\n | \"attached\"\n | \"support\";\n /**\n * For `abilityKind: \"attached\"`, the combined-unit member the ability\n * came from (so the UI can name it and show its leader/bodyguard role).\n * Absent for other kinds.\n */\n sourceUnitId?: string;\n }\n | { kind: \"manual\"; label: string };\n\n/** A weapon-keyword reference (id + parameter map), as found on weapon profiles. */\nexport type WeaponKeywordRef = {\n keyword_id: string;\n parameters?: Record<string, unknown>;\n};\n\n/** One typed contribution; the engine reads `ResolvedModifiers` for the rest. */\nexport type BuffContribution =\n | { type: \"hit-mod\"; value: number }\n | { type: \"wound-mod\"; value: number }\n | { type: \"save-mod\"; value: number }\n | { type: \"cover\" }\n | {\n type: \"reroll\";\n roll: \"hit\" | \"wound\" | \"save\" | \"damage\";\n subset: \"ones\" | \"all-failures\";\n }\n | { type: \"extra-keyword\"; keywordRef: WeaponKeywordRef }\n | { type: \"feel-no-pain\"; threshold: number }\n | { type: \"damage-mod\"; value: number }\n /** Additive modifier to the attacker's per-model attack count (A stat). */\n | { type: \"attacks-mod\"; value: number }\n /** Additive modifier to the attacker's Strength stat. */\n | { type: \"strength-mod\"; value: number }\n /** Additive modifier to the defender's Toughness stat. */\n | { type: \"toughness-mod\"; value: number }\n /**\n * Additive modifier to the attacker's weapon AP. AP is signed against the\n * defender's save (negative = more piercing), so a value of `-1` here makes\n * the weapon one AP more piercing.\n */\n | { type: \"ap-mod\"; value: number };\n\n/** Optional gating; the resolver drops buffs whose gate fails. */\nexport type BuffApplicability = {\n phases?: Phase[];\n rollType?: \"hit\" | \"wound\" | \"save\" | \"damage\";\n /** Target must carry this keyword (case-insensitive). */\n requiresTargetKeyword?: string;\n /** Attacker must carry this keyword (case-insensitive). */\n requiresAttackerKeyword?: string;\n};\n\n/** A single buff: where it came from, when it applies, what it contributes. */\nexport type Buff = {\n source: BuffSource;\n applicableWhen?: BuffApplicability;\n contribution: BuffContribution;\n};\n\n/**\n * Shared engine context. Carries the phase plus a few attacker/target flags\n * the keyword translator and the resolver both need. The engine fills it from\n * its `EngineInput.context` plus the unit-keyword unions; the resolver reads\n * only the subset relevant to its `applicableWhen` checks.\n */\nexport type EngineContext = {\n phase: Phase;\n /** Attacker has not moved this turn — Heavy fires its +1 to hit. */\n attackerStationary?: boolean;\n /**\n * Attacker made a charge move this turn — drives the `charged-this-turn`\n * condition (e.g. World Eaters' Relentless Rage). Left undefined when the\n * caller can't determine it — the condition then evaluates as `\"unknown\"` and\n * the SPA surfaces a diagnostic (mirrors `attackerStationary` / `timing`).\n */\n attackerCharged?: boolean;\n /** Within half the weapon's range — Melta / Rapid Fire fire. */\n withinHalfRange?: boolean;\n /** Attacker benefits from cover (mostly informational; cover applies to defenders). */\n attackerInCover?: boolean;\n /** Target is in cover — the resolver flips on `cover`, the engine applies +1 to save. */\n targetInCover?: boolean;\n /** Attacker keywords (union of unit.keywords + faction_keywords), lower-cased. */\n attackerKeywords?: ReadonlyArray<string>;\n /** Target keywords (union of unit.keywords + faction_keywords), lower-cased. */\n targetKeywords?: ReadonlyArray<string>;\n /**\n * Sub-phase timing flag (e.g. `\"start-of-phase\"`, `\"end-of-phase\"`,\n * `\"on-destroyed\"`). Consumed by the `timing-is` condition. Left undefined\n * when the caller can't pin a sub-phase down — the condition then evaluates\n * as `\"unknown\"` and the SPA surfaces a diagnostic.\n */\n timing?: string;\n /**\n * The buffed unit is part of a combined (\"attached\") unit — a leader is\n * attached to a bodyguard, or vice-versa. Drives the `is-attached` and\n * `model-is-leader` conditions. Derived from a non-empty\n * `EligibilityInput.attachedUnitIds`. Left undefined when the caller can't\n * determine attachment — the conditions then evaluate as `\"unknown\"` and the\n * SPA surfaces a diagnostic (mirrors how `timing` undefined behaves).\n */\n attackerAttached?: boolean;\n};\n\n/** Back-compat alias — `resolveBuffs` accepts the shared engine context. */\nexport type ResolveContext = EngineContext;\n\n/** Read-out of a resolved buff stack, with provenance per field. */\nexport type ResolvedModifiers = {\n hitMod: { value: number; dominantSource: BuffSource | null };\n woundMod: { value: number; dominantSource: BuffSource | null };\n saveMod: { value: number; sources: BuffSource[] };\n cover: { active: boolean; source: BuffSource | null };\n rerolls: Partial<\n Record<\n \"hit\" | \"wound\" | \"save\" | \"damage\",\n { subset: \"ones\" | \"all-failures\"; dominantSource: BuffSource }\n >\n >;\n extraKeywords: { keywordRef: WeaponKeywordRef; source: BuffSource }[];\n feelNoPain: { threshold: number; dominantSource: BuffSource } | null;\n damageMod: { value: number; sources: BuffSource[] };\n attacksMod: { value: number; sources: BuffSource[] };\n strengthMod: { value: number; sources: BuffSource[] };\n toughnessMod: { value: number; sources: BuffSource[] };\n apMod: { value: number; sources: BuffSource[] };\n};\n\n/** Stable ordering used to break ties when multiple buffs claim the same field. */\nconst SOURCE_KIND_RANK: Record<string, number> = {\n \"ability:army\": 0,\n \"ability:detachment\": 1,\n \"ability:detachment-stratagem\": 2,\n \"ability:unit\": 3,\n \"ability:attached\": 4,\n \"ability:support\": 5,\n manual: 6,\n \"weapon-keyword\": 7,\n};\n\nfunction rank(s: BuffSource): number {\n if (s.kind === \"ability\") return SOURCE_KIND_RANK[`ability:${s.abilityKind}`] ?? 99;\n return SOURCE_KIND_RANK[s.kind] ?? 99;\n}\n\nfunction applies(buff: Buff, ctx: ResolveContext): boolean {\n const w = buff.applicableWhen;\n if (!w) return true;\n if (w.phases && w.phases.length > 0 && !w.phases.includes(ctx.phase)) return false;\n if (w.rollType && buff.contribution.type === \"reroll\" && buff.contribution.roll !== w.rollType) {\n return false;\n }\n if (w.requiresTargetKeyword) {\n const target = ctx.targetKeywords ?? [];\n if (!target.includes(w.requiresTargetKeyword.toLowerCase())) return false;\n }\n if (w.requiresAttackerKeyword) {\n const attacker = ctx.attackerKeywords ?? [];\n if (!attacker.includes(w.requiresAttackerKeyword.toLowerCase())) return false;\n }\n return true;\n}\n\n/**\n * Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure\n * function; the engine — and any UI that wants to render the resolved table\n * before crunching — both go through this.\n */\nexport function resolveBuffs(buffs: Buff[], ctx: ResolveContext): ResolvedModifiers {\n const live = buffs.filter((b) => applies(b, ctx));\n\n const out: ResolvedModifiers = {\n hitMod: { value: 0, dominantSource: null },\n woundMod: { value: 0, dominantSource: null },\n saveMod: { value: 0, sources: [] },\n cover: { active: false, source: null },\n rerolls: {},\n extraKeywords: [],\n feelNoPain: null,\n damageMod: { value: 0, sources: [] },\n attacksMod: { value: 0, sources: [] },\n strengthMod: { value: 0, sources: [] },\n toughnessMod: { value: 0, sources: [] },\n apMod: { value: 0, sources: [] },\n };\n\n // Hit / wound mods: sum, then cap at ±1, with dominant source picked from\n // the contributors whose sign matches the surviving value.\n const hitContribs: { value: number; source: BuffSource }[] = [];\n const woundContribs: { value: number; source: BuffSource }[] = [];\n\n for (const b of live) {\n const c = b.contribution;\n switch (c.type) {\n case \"hit-mod\":\n hitContribs.push({ value: c.value, source: b.source });\n break;\n case \"wound-mod\":\n woundContribs.push({ value: c.value, source: b.source });\n break;\n case \"save-mod\":\n out.saveMod.value += c.value;\n out.saveMod.sources.push(b.source);\n break;\n case \"cover\":\n if (!out.cover.active || rank(b.source) < rank(out.cover.source!)) {\n out.cover = { active: true, source: b.source };\n }\n break;\n case \"reroll\": {\n const cur = out.rerolls[c.roll];\n const incoming = c.subset;\n if (!cur) {\n out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };\n } else {\n const incomingStronger =\n (incoming === \"all-failures\" && cur.subset === \"ones\") ||\n (incoming === cur.subset && rank(b.source) < rank(cur.dominantSource));\n if (incomingStronger) {\n out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };\n }\n }\n break;\n }\n case \"extra-keyword\": {\n const key = `${c.keywordRef.keyword_id}::${JSON.stringify(c.keywordRef.parameters ?? {})}`;\n if (!out.extraKeywords.some((e) => keyOf(e.keywordRef) === key)) {\n out.extraKeywords.push({ keywordRef: c.keywordRef, source: b.source });\n }\n break;\n }\n case \"feel-no-pain\":\n if (out.feelNoPain === null || c.threshold < out.feelNoPain.threshold) {\n out.feelNoPain = { threshold: c.threshold, dominantSource: b.source };\n }\n break;\n case \"damage-mod\":\n out.damageMod.value += c.value;\n out.damageMod.sources.push(b.source);\n break;\n case \"attacks-mod\":\n out.attacksMod.value += c.value;\n out.attacksMod.sources.push(b.source);\n break;\n case \"strength-mod\":\n out.strengthMod.value += c.value;\n out.strengthMod.sources.push(b.source);\n break;\n case \"toughness-mod\":\n out.toughnessMod.value += c.value;\n out.toughnessMod.sources.push(b.source);\n break;\n case \"ap-mod\":\n out.apMod.value += c.value;\n out.apMod.sources.push(b.source);\n break;\n }\n }\n\n out.hitMod = capModifier(hitContribs);\n out.woundMod = capModifier(woundContribs);\n\n return out;\n}\n\nfunction keyOf(ref: WeaponKeywordRef): string {\n return `${ref.keyword_id}::${JSON.stringify(ref.parameters ?? {})}`;\n}\n\n/** Sum, clamp to ±1, then pick the dominant contributing source by rank. */\nfunction capModifier(\n contribs: { value: number; source: BuffSource }[],\n): { value: number; dominantSource: BuffSource | null } {\n if (contribs.length === 0) return { value: 0, dominantSource: null };\n const sum = contribs.reduce((a, c) => a + c.value, 0);\n const capped = Math.max(-1, Math.min(1, sum));\n if (capped === 0) return { value: 0, dominantSource: null };\n const sign = Math.sign(capped);\n const matching = contribs.filter((c) => Math.sign(c.value) === sign);\n matching.sort((a, b) => rank(a.source) - rank(b.source));\n return { value: capped, dominantSource: matching[0]?.source ?? null };\n}\n"]}
1
+ {"version":3,"file":"buffs.js","sourceRoot":"","sources":["../../src/cruncher/buffs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The flat `Buff` type every contribution flows through, and the\n * {@link resolveBuffs} resolver that collapses a stack into a\n * {@link ResolvedModifiers} read-out the engine can consume.\n *\n * The same shape carries weapon-keyword effects, ability buffs, stratagem\n * effects, and manual UI toggles — reroll-stacking, hit/wound caps, and\n * feel-no-pain-best-threshold all fall out of one resolver rather than each\n * source kind reinventing precedence.\n *\n * @packageDocumentation\n */\nimport type { Phase } from \"../generated.js\";\n\n/** Where a buff originated. Drives stable tie-breaking inside `resolveBuffs`. */\nexport type BuffSource =\n | { kind: \"weapon-keyword\"; weaponId: string; keywordId: string }\n | {\n kind: \"ability\";\n abilityId: string;\n abilityKind:\n | \"army\"\n | \"detachment\"\n | \"detachment-stratagem\"\n | \"unit\"\n | \"attached\"\n | \"support\";\n /**\n * For `abilityKind: \"attached\"`, the combined-unit member the ability\n * came from (so the UI can name it and show its leader/bodyguard role).\n * Absent for other kinds.\n */\n sourceUnitId?: string;\n }\n | { kind: \"manual\"; label: string };\n\n/** A weapon-keyword reference (id + parameter map), as found on weapon profiles. */\nexport type WeaponKeywordRef = {\n keyword_id: string;\n parameters?: Record<string, unknown>;\n};\n\n/** One typed contribution; the engine reads `ResolvedModifiers` for the rest. */\nexport type BuffContribution =\n | { type: \"hit-mod\"; value: number }\n | { type: \"wound-mod\"; value: number }\n | { type: \"save-mod\"; value: number }\n | { type: \"cover\" }\n | {\n type: \"reroll\";\n roll: \"hit\" | \"wound\" | \"save\" | \"damage\";\n subset: \"ones\" | \"all-failures\";\n }\n | { type: \"extra-keyword\"; keywordRef: WeaponKeywordRef }\n /**\n * Feel-no-pain: roll one D6 per unsaved wound at `threshold`+, ignoring the\n * wound on a pass. `scope` controls which wound stream it applies to:\n * - `\"all\"` (default): every unsaved wound (main + mortal).\n * - `\"mortal\"`: mortal-wound stream only (e.g. Death Guard 5+ FNP vs\n * mortals). A target may carry both an all-FNP and a mortal-FNP; the\n * engine rolls both against mortals.\n */\n | { type: \"feel-no-pain\"; threshold: number; scope?: \"all\" | \"mortal\" }\n | { type: \"damage-mod\"; value: number }\n /** Additive modifier to the attacker's per-model attack count (A stat). */\n | { type: \"attacks-mod\"; value: number }\n /** Additive modifier to the attacker's Strength stat. */\n | { type: \"strength-mod\"; value: number }\n /** Additive modifier to the defender's Toughness stat. */\n | { type: \"toughness-mod\"; value: number }\n /**\n * Additive modifier to the attacker's weapon AP. AP is signed against the\n * defender's save (negative = more piercing), so a value of `-1` here makes\n * the weapon one AP more piercing.\n */\n | { type: \"ap-mod\"; value: number }\n /**\n * Defender-side: subtract `value` from each unsaved damage point (floored at\n * 1 by the engine). Multiple sources do NOT stack in 10e — the largest\n * reduction wins. The corpus also encodes `\"half\"` and `\"to-zero\"`\n * reductions; the buff layer only models the additive form because the\n * other two are typically one-use ablation that doesn't fold into the\n * expected-value math cleanly.\n */\n | { type: \"damage-reduction\"; value: number }\n /**\n * Defender-side: ability-granted invulnerable save threshold (e.g. a buff\n * that grants a 4+ invuln). Best (lowest) threshold wins; the engine then\n * picks the better of `printed Sv after AP/cover` and `effective invuln`\n * (invuln bypasses both AP and cover).\n */\n | { type: \"invulnerable-save\"; threshold: number };\n\n/** Optional gating; the resolver drops buffs whose gate fails. */\nexport type BuffApplicability = {\n phases?: Phase[];\n rollType?: \"hit\" | \"wound\" | \"save\" | \"damage\";\n /** Target must carry this keyword (case-insensitive). */\n requiresTargetKeyword?: string;\n /** Attacker must carry this keyword (case-insensitive). */\n requiresAttackerKeyword?: string;\n};\n\n/** A single buff: where it came from, when it applies, what it contributes. */\nexport type Buff = {\n source: BuffSource;\n applicableWhen?: BuffApplicability;\n contribution: BuffContribution;\n};\n\n/**\n * Shared engine context. Carries the phase plus a few attacker/target flags\n * the keyword translator and the resolver both need. The engine fills it from\n * its `EngineInput.context` plus the unit-keyword unions; the resolver reads\n * only the subset relevant to its `applicableWhen` checks.\n */\nexport type EngineContext = {\n phase: Phase;\n /** Attacker has not moved this turn — Heavy fires its +1 to hit. */\n attackerStationary?: boolean;\n /**\n * Attacker made a charge move this turn — drives the `charged-this-turn`\n * condition (e.g. World Eaters' Relentless Rage). Left undefined when the\n * caller can't determine it — the condition then evaluates as `\"unknown\"` and\n * the SPA surfaces a diagnostic (mirrors `attackerStationary` / `timing`).\n */\n attackerCharged?: boolean;\n /** Within half the weapon's range — Melta / Rapid Fire fire. */\n withinHalfRange?: boolean;\n /** Attacker benefits from cover (mostly informational; cover applies to defenders). */\n attackerInCover?: boolean;\n /** Target is in cover — the resolver flips on `cover`, the engine applies +1 to save. */\n targetInCover?: boolean;\n /** Attacker keywords (union of unit.keywords + faction_keywords), lower-cased. */\n attackerKeywords?: ReadonlyArray<string>;\n /** Target keywords (union of unit.keywords + faction_keywords), lower-cased. */\n targetKeywords?: ReadonlyArray<string>;\n /**\n * Sub-phase timing flag (e.g. `\"start-of-phase\"`, `\"end-of-phase\"`,\n * `\"on-destroyed\"`). Consumed by the `timing-is` condition. Left undefined\n * when the caller can't pin a sub-phase down — the condition then evaluates\n * as `\"unknown\"` and the SPA surfaces a diagnostic.\n */\n timing?: string;\n /**\n * The buffed unit is part of a combined (\"attached\") unit — a leader is\n * attached to a bodyguard, or vice-versa. Drives the `is-attached` and\n * `model-is-leader` conditions. Derived from a non-empty\n * `EligibilityInput.attachedUnitIds`. Left undefined when the caller can't\n * determine attachment — the conditions then evaluate as `\"unknown\"` and the\n * SPA surfaces a diagnostic (mirrors how `timing` undefined behaves).\n */\n attackerAttached?: boolean;\n};\n\n/** Back-compat alias — `resolveBuffs` accepts the shared engine context. */\nexport type ResolveContext = EngineContext;\n\n/** Read-out of a resolved buff stack, with provenance per field. */\nexport type ResolvedModifiers = {\n hitMod: { value: number; dominantSource: BuffSource | null };\n woundMod: { value: number; dominantSource: BuffSource | null };\n saveMod: { value: number; sources: BuffSource[] };\n cover: { active: boolean; source: BuffSource | null };\n rerolls: Partial<\n Record<\n \"hit\" | \"wound\" | \"save\" | \"damage\",\n { subset: \"ones\" | \"all-failures\"; dominantSource: BuffSource }\n >\n >;\n extraKeywords: { keywordRef: WeaponKeywordRef; source: BuffSource }[];\n /** All-wound FNP — fires on the main and mortal damage streams alike. */\n feelNoPain: { threshold: number; dominantSource: BuffSource } | null;\n /** Mortal-only FNP — fires only on the mortal-wound damage stream. */\n feelNoPainMortal: { threshold: number; dominantSource: BuffSource } | null;\n damageMod: { value: number; sources: BuffSource[] };\n attacksMod: { value: number; sources: BuffSource[] };\n strengthMod: { value: number; sources: BuffSource[] };\n toughnessMod: { value: number; sources: BuffSource[] };\n apMod: { value: number; sources: BuffSource[] };\n /**\n * Defender-side damage reduction. Highest-wins (multiple sources do not\n * stack in 10e); the dominant source is the one whose value matches the\n * surviving reduction.\n */\n damageReduction: { value: number; dominantSource: BuffSource | null };\n /**\n * Ability-granted invulnerable save. Best (lowest) threshold wins. `null`\n * when no ability granted one; the engine still uses the unit's printed\n * `invuln_sv` from the profile in that case.\n */\n invulnerable: { threshold: number; dominantSource: BuffSource } | null;\n};\n\n/** Stable ordering used to break ties when multiple buffs claim the same field. */\nconst SOURCE_KIND_RANK: Record<string, number> = {\n \"ability:army\": 0,\n \"ability:detachment\": 1,\n \"ability:detachment-stratagem\": 2,\n \"ability:unit\": 3,\n \"ability:attached\": 4,\n \"ability:support\": 5,\n manual: 6,\n \"weapon-keyword\": 7,\n};\n\nfunction rank(s: BuffSource): number {\n if (s.kind === \"ability\") return SOURCE_KIND_RANK[`ability:${s.abilityKind}`] ?? 99;\n return SOURCE_KIND_RANK[s.kind] ?? 99;\n}\n\nfunction applies(buff: Buff, ctx: ResolveContext): boolean {\n const w = buff.applicableWhen;\n if (!w) return true;\n if (w.phases && w.phases.length > 0 && !w.phases.includes(ctx.phase)) return false;\n if (w.rollType && buff.contribution.type === \"reroll\" && buff.contribution.roll !== w.rollType) {\n return false;\n }\n if (w.requiresTargetKeyword) {\n const target = ctx.targetKeywords ?? [];\n if (!target.includes(w.requiresTargetKeyword.toLowerCase())) return false;\n }\n if (w.requiresAttackerKeyword) {\n const attacker = ctx.attackerKeywords ?? [];\n if (!attacker.includes(w.requiresAttackerKeyword.toLowerCase())) return false;\n }\n return true;\n}\n\n/**\n * Collapse a flat buff stack into a {@link ResolvedModifiers} read-out. Pure\n * function; the engine — and any UI that wants to render the resolved table\n * before crunching — both go through this.\n */\nexport function resolveBuffs(buffs: Buff[], ctx: ResolveContext): ResolvedModifiers {\n const live = buffs.filter((b) => applies(b, ctx));\n\n const out: ResolvedModifiers = {\n hitMod: { value: 0, dominantSource: null },\n woundMod: { value: 0, dominantSource: null },\n saveMod: { value: 0, sources: [] },\n cover: { active: false, source: null },\n rerolls: {},\n extraKeywords: [],\n feelNoPain: null,\n feelNoPainMortal: null,\n damageMod: { value: 0, sources: [] },\n attacksMod: { value: 0, sources: [] },\n strengthMod: { value: 0, sources: [] },\n toughnessMod: { value: 0, sources: [] },\n apMod: { value: 0, sources: [] },\n damageReduction: { value: 0, dominantSource: null },\n invulnerable: null,\n };\n\n // Hit / wound mods: sum, then cap at ±1, with dominant source picked from\n // the contributors whose sign matches the surviving value.\n const hitContribs: { value: number; source: BuffSource }[] = [];\n const woundContribs: { value: number; source: BuffSource }[] = [];\n\n for (const b of live) {\n const c = b.contribution;\n switch (c.type) {\n case \"hit-mod\":\n hitContribs.push({ value: c.value, source: b.source });\n break;\n case \"wound-mod\":\n woundContribs.push({ value: c.value, source: b.source });\n break;\n case \"save-mod\":\n out.saveMod.value += c.value;\n out.saveMod.sources.push(b.source);\n break;\n case \"cover\":\n if (!out.cover.active || rank(b.source) < rank(out.cover.source!)) {\n out.cover = { active: true, source: b.source };\n }\n break;\n case \"reroll\": {\n const cur = out.rerolls[c.roll];\n const incoming = c.subset;\n if (!cur) {\n out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };\n } else {\n const incomingStronger =\n (incoming === \"all-failures\" && cur.subset === \"ones\") ||\n (incoming === cur.subset && rank(b.source) < rank(cur.dominantSource));\n if (incomingStronger) {\n out.rerolls[c.roll] = { subset: incoming, dominantSource: b.source };\n }\n }\n break;\n }\n case \"extra-keyword\": {\n const key = `${c.keywordRef.keyword_id}::${JSON.stringify(c.keywordRef.parameters ?? {})}`;\n if (!out.extraKeywords.some((e) => keyOf(e.keywordRef) === key)) {\n out.extraKeywords.push({ keywordRef: c.keywordRef, source: b.source });\n }\n break;\n }\n case \"feel-no-pain\": {\n // Best (lowest) threshold wins per scope. An undeclared scope is\n // treated as \"all\" — that's the existing convention (unscoped FNP =\n // applies to every wound) and keeps every shipped FNP buff regression-safe.\n const scope = c.scope ?? \"all\";\n const slot = scope === \"mortal\" ? \"feelNoPainMortal\" : \"feelNoPain\";\n if (out[slot] === null || c.threshold < out[slot]!.threshold) {\n out[slot] = { threshold: c.threshold, dominantSource: b.source };\n }\n break;\n }\n case \"damage-mod\":\n out.damageMod.value += c.value;\n out.damageMod.sources.push(b.source);\n break;\n case \"attacks-mod\":\n out.attacksMod.value += c.value;\n out.attacksMod.sources.push(b.source);\n break;\n case \"strength-mod\":\n out.strengthMod.value += c.value;\n out.strengthMod.sources.push(b.source);\n break;\n case \"toughness-mod\":\n out.toughnessMod.value += c.value;\n out.toughnessMod.sources.push(b.source);\n break;\n case \"ap-mod\":\n out.apMod.value += c.value;\n out.apMod.sources.push(b.source);\n break;\n case \"damage-reduction\":\n // Highest reduction wins (no stacking). Ties break by source rank so\n // an ability source is preferred over a manual one for provenance\n // purposes; either way the resolved value is unchanged.\n if (\n out.damageReduction.dominantSource === null ||\n c.value > out.damageReduction.value ||\n (c.value === out.damageReduction.value &&\n rank(b.source) < rank(out.damageReduction.dominantSource))\n ) {\n out.damageReduction = { value: c.value, dominantSource: b.source };\n }\n break;\n case \"invulnerable-save\":\n // Best (lowest threshold) wins. Same tie-break by source rank.\n if (\n out.invulnerable === null ||\n c.threshold < out.invulnerable.threshold ||\n (c.threshold === out.invulnerable.threshold &&\n rank(b.source) < rank(out.invulnerable.dominantSource))\n ) {\n out.invulnerable = { threshold: c.threshold, dominantSource: b.source };\n }\n break;\n }\n }\n\n out.hitMod = capModifier(hitContribs);\n out.woundMod = capModifier(woundContribs);\n\n return out;\n}\n\nfunction keyOf(ref: WeaponKeywordRef): string {\n return `${ref.keyword_id}::${JSON.stringify(ref.parameters ?? {})}`;\n}\n\n/** Sum, clamp to ±1, then pick the dominant contributing source by rank. */\nfunction capModifier(\n contribs: { value: number; source: BuffSource }[],\n): { value: number; dominantSource: BuffSource | null } {\n if (contribs.length === 0) return { value: 0, dominantSource: null };\n const sum = contribs.reduce((a, c) => a + c.value, 0);\n const capped = Math.max(-1, Math.min(1, sum));\n if (capped === 0) return { value: 0, dominantSource: null };\n const sign = Math.sign(capped);\n const matching = contribs.filter((c) => Math.sign(c.value) === sign);\n matching.sort((a, b) => rank(a.source) - rank(b.source));\n return { value: capped, dominantSource: matching[0]?.source ?? null };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/cruncher/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,KAAK,IAAI,EACT,KAAK,aAAa,EAElB,KAAK,iBAAiB,EAEvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAE7C,MAAM,MAAM,gBAAgB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AACxE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,IAAI,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,KAAK,GAAG;IAClB,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,GAAG,eAAe,CAAC;IAC3F,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,QAAQ,EAAE,iBAAiB,CAAA;CAAE,CAAC;AAE5E;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,YAAY,CA0L1E;AAgKD,YAAY,EAAE,KAAK,EAAE,CAAC"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/cruncher/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,KAAK,IAAI,EACT,KAAK,aAAa,EAElB,KAAK,iBAAiB,EAEvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAE7C,MAAM,MAAM,gBAAgB,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AACxE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,IAAI,EAAE,IAAI,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,KAAK,GAAG;IAClB,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,GAAG,eAAe,CAAC;IAC3F,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,QAAQ,EAAE,iBAAiB,CAAA;CAAE,CAAC;AAE5E;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,YAAY,CA2M1E;AA0LD,YAAY,EAAE,KAAK,EAAE,CAAC"}
@@ -121,8 +121,16 @@ export function crunch(input, dataset) {
121
121
  const covered = resolved.cover.active && !ignoresCover && input.attacker.weapon.type === "ranged";
122
122
  const armorAfterCover = covered ? Math.max(3, armorTargetRaw - 1) : armorTargetRaw;
123
123
  const armorFinal = clamp(armorAfterCover, 2, 7);
124
- const invuln = unitProfile.invuln_sv ?? null;
125
- const effectiveSaveTarget = invuln !== null ? Math.min(armorFinal, invuln) : armorFinal;
124
+ // The unit's printed invuln (from the profile) and any ability-granted
125
+ // invuln combine best-wins (lowest threshold). Invuln bypasses AP and cover
126
+ // — only the armor branch above is affected by those — so the final save is
127
+ // min(armor-after-AP-and-cover, effective-invuln).
128
+ const printedInvuln = unitProfile.invuln_sv ?? null;
129
+ const abilityInvuln = resolved.invulnerable?.threshold ?? null;
130
+ const effectiveInvuln = printedInvuln !== null && abilityInvuln !== null
131
+ ? Math.min(printedInvuln, abilityInvuln)
132
+ : (printedInvuln ?? abilityInvuln);
133
+ const effectiveSaveTarget = effectiveInvuln !== null ? Math.min(armorFinal, effectiveInvuln) : armorFinal;
126
134
  const saveProbs = checkProbabilities({
127
135
  unmodifiedNeeded: effectiveSaveTarget,
128
136
  modifier: 0,
@@ -136,32 +144,38 @@ export function crunch(input, dataset) {
136
144
  stages.push({
137
145
  name: "unsaved",
138
146
  expected: unsaved,
139
- detail: `Sv${unitProfile.Sv}+, AP${signed(AP)}${apMod !== 0 ? ` (apmod ${signed(apMod)})` : ""}${saveMod !== 0 ? `, savemod ${signed(saveMod)}` : ""}${covered ? ", cover (+1, cap 3+)" : ""} → effective ${effectiveSaveTarget}+ (P(save)=${pSaved.toFixed(4)})`,
147
+ detail: `Sv${unitProfile.Sv}+, AP${signed(AP)}${apMod !== 0 ? ` (apmod ${signed(apMod)})` : ""}${saveMod !== 0 ? `, savemod ${signed(saveMod)}` : ""}${covered ? ", cover (+1, cap 3+)" : ""}${abilityInvuln !== null ? `, invuln ${abilityInvuln}+ (ability)` : ""} → effective ${effectiveSaveTarget}+ (P(save)=${pSaved.toFixed(4)})`,
140
148
  });
141
149
  // 5. Damage
142
150
  const baseD = evalStatValue(weaponProfile.stats.D);
143
151
  const melta = findKeyword(resolved, "melta");
144
152
  const meltaBonus = melta && halfRange ? evalStatValue(melta.parameters?.value) : 0;
145
- const damagePerHit = Math.max(0, baseD + meltaBonus + resolved.damageMod.value);
153
+ const beforeReduction = Math.max(0, baseD + meltaBonus + resolved.damageMod.value);
154
+ const damageReduction = resolved.damageReduction.value;
155
+ // 10e damage-reduction abilities always carry the canonical "to a minimum
156
+ // of 1" clause, so the floor lives in the math, not the data. The clause
157
+ // only applies when damage-reduction is active — without it, a D1 weapon
158
+ // with a -1 attacker damage-mod still produces 0 damage.
159
+ const damagePerHit = damageReduction > 0 ? Math.max(1, beforeReduction - damageReduction) : beforeReduction;
146
160
  const damageMain = unsaved * damagePerHit;
147
161
  const damageMortal = mortalWoundsStream * damagePerHit;
148
162
  const damage = damageMain + damageMortal;
149
163
  stages.push({
150
164
  name: "damage",
151
165
  expected: damage,
152
- detail: `D ${baseD}${meltaBonus ? ` + Melta ${meltaBonus} (half range)` : ""}${resolved.damageMod.value !== 0 ? ` ${signed(resolved.damageMod.value)} (mod)` : ""} = ${damagePerHit} per hit; main ${damageMain.toFixed(4)}, mortal ${damageMortal.toFixed(4)}`,
166
+ detail: `D ${baseD}${meltaBonus ? ` + Melta ${meltaBonus} (half range)` : ""}${resolved.damageMod.value !== 0 ? ` ${signed(resolved.damageMod.value)} (mod)` : ""}${damageReduction > 0 ? ` -${damageReduction} (defender, min 1)` : ""} = ${damagePerHit} per hit; main ${damageMain.toFixed(4)}, mortal ${damageMortal.toFixed(4)}`,
153
167
  });
154
168
  // 6. FNP
155
- let afterFnp = damage;
156
- let fnpDetail = "no FNP";
157
- const fnp = resolved.feelNoPain;
158
- if (fnp) {
159
- const pSucc = Math.max(0, Math.min(1, (7 - fnp.threshold) / 6));
160
- afterFnp = damage * (1 - pSucc);
161
- fnpDetail = `FNP ${fnp.threshold}+ (P=${pSucc.toFixed(4)})`;
162
- }
163
- // TODO M2: per-damage-point FNP rolls (e.g. Death Guard 5+ FNP only on
164
- // mortals); the current model applies FNP linearly to expected damage.
169
+ // Two scopes compose: an all-FNP fires on every unsaved wound; a mortal-FNP
170
+ // fires only on the mortal-wound stream (e.g. Death Guard 5+ FNP vs mortals).
171
+ // A target carrying both rolls both against mortals — independent Bernoulli
172
+ // trials, so the surviving fractions multiply.
173
+ const pSurviveAll = fnpSurvivalFraction(resolved.feelNoPain);
174
+ const pSurviveMortal = fnpSurvivalFraction(resolved.feelNoPainMortal);
175
+ const afterMain = damageMain * pSurviveAll;
176
+ const afterMortal = damageMortal * pSurviveAll * pSurviveMortal;
177
+ const afterFnp = afterMain + afterMortal;
178
+ const fnpDetail = describeFnp(resolved.feelNoPain, resolved.feelNoPainMortal);
165
179
  stages.push({ name: "after-fnp", expected: afterFnp, detail: fnpDetail });
166
180
  // 7. Models killed
167
181
  const W = unitProfile.W;
@@ -295,6 +309,27 @@ function signed(n) {
295
309
  return `${n}`;
296
310
  return "0";
297
311
  }
312
+ /** Fraction of damage that survives a single FNP roll (1 if no FNP). */
313
+ function fnpSurvivalFraction(fnp) {
314
+ if (!fnp)
315
+ return 1;
316
+ const pSucc = Math.max(0, Math.min(1, (7 - fnp.threshold) / 6));
317
+ return 1 - pSucc;
318
+ }
319
+ function describeFnp(all, mortal) {
320
+ if (!all && !mortal)
321
+ return "no FNP";
322
+ const parts = [];
323
+ if (all) {
324
+ const pSucc = (7 - all.threshold) / 6;
325
+ parts.push(`FNP ${all.threshold}+ (P=${pSucc.toFixed(4)})`);
326
+ }
327
+ if (mortal) {
328
+ const pSucc = (7 - mortal.threshold) / 6;
329
+ parts.push(`FNP ${mortal.threshold}+ vs mortals (P=${pSucc.toFixed(4)})`);
330
+ }
331
+ return parts.join(", ");
332
+ }
298
333
  function attacksDetail(models, per, rapidFire, blast) {
299
334
  const parts = [`${models} × ${per}`];
300
335
  if (rapidFire)
@@ -1 +1 @@
1
- {"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/cruncher/engine.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The expected-value damage engine.\n *\n * Closed-form math over schema profiles + a flat {@link Buff} stack. No\n * sampling, no I/O. Auto-injects every weapon-keyword on the attacker's\n * profile as a buff (so callers don't have to enumerate intrinsics), then\n * resolves the stack via {@link resolveBuffs}, then walks\n * attacks → hits → wounds → unsaved → damage → after-fnp → models-killed.\n *\n * The dataset is required (and defaults to the embedded one) — without it\n * the engine can't look up weapon-keyword effects.\n */\nimport type { Phase, Unit, Weapon } from \"../generated.js\";\nimport {\n type Buff,\n type EngineContext,\n resolveBuffs,\n type ResolvedModifiers,\n type WeaponKeywordRef,\n} from \"./buffs.js\";\nimport { Dataset } from \"../data/dataset.js\";\n\nexport type AttackProfileRef = { weapon: Weapon; profileIndex: number };\nexport type TargetProfileRef = {\n unit: Unit;\n profileIndex: number;\n /** Override target model count (otherwise read from `unit.model_count.min`). */\n modelCount?: number;\n};\n\nexport type Stage = {\n name: \"attacks\" | \"hits\" | \"wounds\" | \"unsaved\" | \"damage\" | \"after-fnp\" | \"models-killed\";\n expected: number;\n detail: string;\n};\n\nexport type EngineInput = {\n attacker: AttackProfileRef;\n target: TargetProfileRef;\n modelsFiring: number;\n /** User / ability / manual buffs. Weapon-keyword buffs are auto-injected. */\n buffs: Buff[];\n context: EngineContext;\n};\n\nexport type EngineOutput = { stages: Stage[]; resolved: ResolvedModifiers };\n\n/**\n * Compute the expected per-stage projection for one (attacker, target, buffs)\n * triple. The dataset defaults to the embedded one — pass an alternate when\n * crunching against a different bundle (e.g. tests).\n */\nexport function crunch(input: EngineInput, dataset?: Dataset): EngineOutput {\n const ds = dataset ?? lazyEmbeddedDataset();\n const weaponProfile = input.attacker.weapon.profiles[input.attacker.profileIndex];\n if (!weaponProfile) {\n throw new RangeError(\n `crunch: attacker.profileIndex=${input.attacker.profileIndex} is out of range for weapon ${input.attacker.weapon.id}`,\n );\n }\n const unitProfile = input.target.unit.profiles[input.target.profileIndex];\n if (!unitProfile) {\n throw new RangeError(\n `crunch: target.profileIndex=${input.target.profileIndex} is out of range for unit ${input.target.unit.id}`,\n );\n }\n\n const targetKeywords = unitKeywordsLower(input.target.unit);\n const ctx: EngineContext = {\n ...input.context,\n targetKeywords: input.context.targetKeywords ?? targetKeywords,\n };\n\n // Auto-inject weapon-keyword buffs from the attacker profile, then append\n // the caller-supplied stack. resolveBuffs deduplicates and ranks them.\n const profileBuffs = profileBuffsFor(input.attacker, ds, ctx);\n const resolved = resolveBuffs([...profileBuffs, ...input.buffs], ctx);\n\n const stages: Stage[] = [];\n\n // 1. Attacks\n const isMelee = input.attacker.weapon.type === \"melee\";\n const baseA = evalStatValue(weaponProfile.stats.A);\n const attacksPerModel = baseA + resolved.attacksMod.value;\n const rapidFire = findKeyword(resolved, \"rapid-fire\");\n const halfRange = ctx.withinHalfRange === true;\n const rapidFireExtraPerModel = rapidFire && halfRange ? evalStatValue(rapidFire.parameters?.value) : 0;\n const blast = findKeyword(resolved, \"blast\");\n const targetModelCount = input.target.modelCount ?? input.target.unit.model_count?.min ?? 1;\n const blastExtraPerModel = blast ? Math.floor(targetModelCount / 5) : 0;\n const attacks = input.modelsFiring * (attacksPerModel + rapidFireExtraPerModel + blastExtraPerModel);\n stages.push({\n name: \"attacks\",\n expected: attacks,\n detail: attacksDetail(input.modelsFiring, attacksPerModel, rapidFireExtraPerModel, blastExtraPerModel),\n });\n\n // 2. Hits\n const hitStat = isMelee ? weaponProfile.stats.WS : weaponProfile.stats.BS;\n const torrent = !!findKeyword(resolved, \"torrent\");\n let hits: number;\n let critHits: number;\n let hitsDetail: string;\n if (torrent) {\n hits = attacks;\n critHits = 0;\n hitsDetail = `Torrent: auto-hits (${attacks.toFixed(4)})`;\n } else {\n if (typeof hitStat !== \"number\") {\n throw new Error(\n `crunch: weapon ${input.attacker.weapon.id} profile ${input.attacker.profileIndex} missing ${isMelee ? \"WS\" : \"BS\"}`,\n );\n }\n const probs = checkProbabilities({\n unmodifiedNeeded: hitStat,\n modifier: resolved.hitMod.value,\n reroll: resolved.rerolls.hit?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: true,\n critThreshold: 6,\n });\n hits = attacks * probs.pass;\n critHits = attacks * probs.crit;\n hitsDetail = `${isMelee ? \"WS\" : \"BS\"}${hitStat}+ (mod ${signed(resolved.hitMod.value)}, reroll ${resolved.rerolls.hit?.subset ?? \"none\"}) → P(hit)=${probs.pass.toFixed(4)}, P(crit)=${probs.crit.toFixed(4)}`;\n }\n const sustained = findKeyword(resolved, \"sustained-hits\");\n if (sustained) {\n hits += critHits * evalStatValue(sustained.parameters?.value);\n hitsDetail += `; +Sustained Hits ${sustained.parameters?.value ?? 1} on ${critHits.toFixed(4)} crits`;\n }\n stages.push({ name: \"hits\", expected: hits, detail: hitsDetail });\n\n // 3. Wounds\n const S = evalStatValue(weaponProfile.stats.S) + resolved.strengthMod.value;\n const T = unitProfile.T + resolved.toughnessMod.value;\n const stdWoundNeeded = woundThreshold(S, T);\n const anti = findKeyword(resolved, \"anti\");\n let antiThreshold = 7; // unreachable\n if (anti) {\n const targetKw = (anti.parameters?.target_keyword as string | undefined)?.toLowerCase();\n if (targetKw && targetKeywords.includes(targetKw)) {\n const threshold = Number(anti.parameters?.threshold);\n if (Number.isFinite(threshold)) antiThreshold = threshold;\n }\n }\n const critWoundThreshold = Math.min(6, antiThreshold);\n\n const hasLethal = !!findKeyword(resolved, \"lethal-hits\");\n const hitsForWoundRoll = hasLethal ? hits - critHits : hits;\n const lethalAutoWounds = hasLethal ? critHits : 0;\n\n const woundProbs = checkProbabilities({\n unmodifiedNeeded: stdWoundNeeded,\n modifier: resolved.woundMod.value,\n reroll: resolved.rerolls.wound?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: true,\n critThreshold: critWoundThreshold,\n });\n const regularWoundsFromRoll = hitsForWoundRoll * (woundProbs.pass - woundProbs.crit);\n const critWoundsFromRoll = hitsForWoundRoll * woundProbs.crit;\n const totalRegularWounds = regularWoundsFromRoll + lethalAutoWounds;\n const hasDevastating = !!findKeyword(resolved, \"devastating-wounds\");\n const mortalWoundsStream = hasDevastating ? critWoundsFromRoll : 0;\n const regularWoundsForSaves = hasDevastating ? totalRegularWounds : totalRegularWounds + critWoundsFromRoll;\n const totalWounds = regularWoundsForSaves + mortalWoundsStream;\n stages.push({\n name: \"wounds\",\n expected: totalWounds,\n detail: `S${S} vs T${T} → need ${stdWoundNeeded}+, anti ${antiThreshold <= 6 ? `${antiThreshold}+ (active)` : \"n/a\"}, P(wound)=${woundProbs.pass.toFixed(4)} (${critWoundsFromRoll.toFixed(4)} crit), lethal ${hasLethal ? \"+\" + lethalAutoWounds.toFixed(4) : \"—\"}, devastating ${hasDevastating ? mortalWoundsStream.toFixed(4) + \" MW\" : \"—\"}`,\n });\n\n // 4. Saves\n const apMod = resolved.apMod.value;\n const AP = weaponProfile.stats.AP + apMod;\n const saveMod = resolved.saveMod.value;\n const armorTargetRaw = unitProfile.Sv - AP - saveMod;\n const ignoresCover = !!findKeyword(resolved, \"ignores-cover\");\n const covered =\n resolved.cover.active && !ignoresCover && input.attacker.weapon.type === \"ranged\";\n const armorAfterCover = covered ? Math.max(3, armorTargetRaw - 1) : armorTargetRaw;\n const armorFinal = clamp(armorAfterCover, 2, 7);\n const invuln = unitProfile.invuln_sv ?? null;\n const effectiveSaveTarget = invuln !== null ? Math.min(armorFinal, invuln) : armorFinal;\n\n const saveProbs = checkProbabilities({\n unmodifiedNeeded: effectiveSaveTarget,\n modifier: 0,\n reroll: resolved.rerolls.save?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: false,\n critThreshold: 7,\n });\n const pSaved = effectiveSaveTarget >= 7 ? 0 : saveProbs.pass;\n const unsaved = regularWoundsForSaves * (1 - pSaved);\n stages.push({\n name: \"unsaved\",\n expected: unsaved,\n detail: `Sv${unitProfile.Sv}+, AP${signed(AP)}${apMod !== 0 ? ` (apmod ${signed(apMod)})` : \"\"}${saveMod !== 0 ? `, savemod ${signed(saveMod)}` : \"\"}${covered ? \", cover (+1, cap 3+)\" : \"\"} → effective ${effectiveSaveTarget}+ (P(save)=${pSaved.toFixed(4)})`,\n });\n\n // 5. Damage\n const baseD = evalStatValue(weaponProfile.stats.D);\n const melta = findKeyword(resolved, \"melta\");\n const meltaBonus = melta && halfRange ? evalStatValue(melta.parameters?.value) : 0;\n const damagePerHit = Math.max(0, baseD + meltaBonus + resolved.damageMod.value);\n const damageMain = unsaved * damagePerHit;\n const damageMortal = mortalWoundsStream * damagePerHit;\n const damage = damageMain + damageMortal;\n stages.push({\n name: \"damage\",\n expected: damage,\n detail: `D ${baseD}${meltaBonus ? ` + Melta ${meltaBonus} (half range)` : \"\"}${resolved.damageMod.value !== 0 ? ` ${signed(resolved.damageMod.value)} (mod)` : \"\"} = ${damagePerHit} per hit; main ${damageMain.toFixed(4)}, mortal ${damageMortal.toFixed(4)}`,\n });\n\n // 6. FNP\n let afterFnp = damage;\n let fnpDetail = \"no FNP\";\n const fnp = resolved.feelNoPain;\n if (fnp) {\n const pSucc = Math.max(0, Math.min(1, (7 - fnp.threshold) / 6));\n afterFnp = damage * (1 - pSucc);\n fnpDetail = `FNP ${fnp.threshold}+ (P=${pSucc.toFixed(4)})`;\n }\n // TODO M2: per-damage-point FNP rolls (e.g. Death Guard 5+ FNP only on\n // mortals); the current model applies FNP linearly to expected damage.\n stages.push({ name: \"after-fnp\", expected: afterFnp, detail: fnpDetail });\n\n // 7. Models killed\n const W = unitProfile.W;\n const expectedModelsKilled = W > 0 ? Math.min(targetModelCount, afterFnp / W) : 0;\n stages.push({\n name: \"models-killed\",\n expected: expectedModelsKilled,\n detail: `W${W} per model, ${targetModelCount} models in target; ${afterFnp.toFixed(4)} damage / ${W} = ${(afterFnp / W).toFixed(4)} (capped at ${targetModelCount})`,\n });\n\n return { stages, resolved };\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Lower-cased union of a unit's `keywords` + `faction_keywords`. */\nfunction unitKeywordsLower(unit: Unit): string[] {\n const out: string[] = [];\n for (const k of unit.keywords ?? []) out.push(String(k).toLowerCase());\n for (const k of unit.faction_keywords ?? []) out.push(String(k).toLowerCase());\n return out;\n}\n\nfunction profileBuffsFor(\n attacker: AttackProfileRef,\n dataset: Dataset,\n ctx: EngineContext,\n): Buff[] {\n const weaponView = dataset.weapons.get(attacker.weapon.id);\n if (!weaponView) {\n // Weapon isn't in the dataset (probably a hand-built test fixture); fall\n // back to walking its catalog keywords manually.\n return manualWeaponKeywordBuffs(attacker, dataset, ctx);\n }\n return weaponView.profileBuffs(attacker.profileIndex, ctx);\n}\n\nfunction manualWeaponKeywordBuffs(\n attacker: AttackProfileRef,\n dataset: Dataset,\n ctx: EngineContext,\n): Buff[] {\n const profile = attacker.weapon.profiles[attacker.profileIndex];\n if (!profile) return [];\n const out: Buff[] = [];\n for (const ref of profile.keywords ?? []) {\n const view = dataset.weaponKeywords.get(ref.keyword_id);\n if (!view) continue;\n out.push(\n ...view.getBuffs(\n ref.parameters as Record<string, unknown> | undefined,\n attacker.weapon.id,\n ctx,\n ),\n );\n }\n return out;\n}\n\nfunction findKeyword(\n resolved: ResolvedModifiers,\n keywordId: string,\n): WeaponKeywordRef | undefined {\n return resolved.extraKeywords.find((e) => e.keywordRef.keyword_id === keywordId)?.keywordRef;\n}\n\n/** Standard 10e S-vs-T table → unmodified wound threshold (2..6). */\nfunction woundThreshold(S: number, T: number): number {\n if (S >= 2 * T) return 2;\n if (S > T) return 3;\n if (S === T) return 4;\n if (S * 2 > T) return 5;\n return 6;\n}\n\n/** Probability a single die check passes (and the conditional crit rate). */\nfunction checkProbabilities(args: {\n unmodifiedNeeded: number;\n modifier: number;\n reroll: \"none\" | \"ones\" | \"all-failures\";\n autoFailOnOne: boolean;\n autoPassOnSix: boolean;\n /** Natural roll ≥ this is a crit. Use 7 to disable crits. */\n critThreshold: number;\n}): { pass: number; crit: number } {\n function outcome(face: number): { pass: number; crit: number } {\n if (args.autoFailOnOne && face === 1) return { pass: 0, crit: 0 };\n if (face >= args.critThreshold) return { pass: 1, crit: 1 };\n if (args.autoPassOnSix && face === 6) return { pass: 1, crit: 0 };\n return (face + args.modifier) >= args.unmodifiedNeeded\n ? { pass: 1, crit: 0 }\n : { pass: 0, crit: 0 };\n }\n\n let pass = 0;\n let crit = 0;\n for (let face = 1; face <= 6; face++) {\n const initial = outcome(face);\n if (initial.pass === 1) {\n pass += 1 / 6;\n crit += initial.crit / 6;\n continue;\n }\n // Failed initial — eligible for reroll?\n const eligible =\n args.reroll === \"all-failures\" || (args.reroll === \"ones\" && face === 1);\n if (!eligible) continue;\n // Reroll: uniform over 1..6.\n let rerollPass = 0;\n let rerollCrit = 0;\n for (let f2 = 1; f2 <= 6; f2++) {\n const second = outcome(f2);\n rerollPass += second.pass / 6;\n rerollCrit += second.crit / 6;\n }\n pass += rerollPass / 6;\n crit += rerollCrit / 6;\n }\n return { pass, crit };\n}\n\n/**\n * Mean value of a stat (number or dice expression like `\"D6\"`, `\"2D6\"`,\n * `\"D3+1\"`, `\"D6-1\"`). Unrecognised strings throw — better to crash than to\n * silently return 0 and produce a confidently wrong damage projection.\n */\nfunction evalStatValue(v: unknown): number {\n if (typeof v === \"number\") return v;\n if (typeof v !== \"string\") return Number(v) || 0;\n const trimmed = v.trim();\n if (trimmed === \"\") return 0;\n const asNumber = Number(trimmed);\n if (Number.isFinite(asNumber)) return asNumber;\n const match = /^(\\d*)D(\\d+)([+-]\\d+)?$/i.exec(trimmed);\n if (!match) throw new Error(`evalStatValue: cannot parse \"${v}\"`);\n const count = match[1] === \"\" ? 1 : Number(match[1]);\n const die = Number(match[2]);\n const offset = match[3] ? Number(match[3]) : 0;\n return count * (die + 1) / 2 + offset;\n}\n\nfunction clamp(n: number, lo: number, hi: number): number {\n return Math.max(lo, Math.min(hi, n));\n}\n\nfunction signed(n: number): string {\n if (n > 0) return `+${n}`;\n if (n < 0) return `${n}`;\n return \"0\";\n}\n\nfunction attacksDetail(\n models: number,\n per: number,\n rapidFire: number,\n blast: number,\n): string {\n const parts = [`${models} × ${per}`];\n if (rapidFire) parts.push(`+ Rapid Fire ${rapidFire} (half range)`);\n if (blast) parts.push(`+ Blast ${blast}/model`);\n return parts.join(\" \");\n}\n\nlet _embeddedDataset: Dataset | null = null;\nfunction lazyEmbeddedDataset(): Dataset {\n if (!_embeddedDataset) _embeddedDataset = Dataset.embedded();\n return _embeddedDataset;\n}\n\nexport type { Phase };\n"]}
1
+ {"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/cruncher/engine.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The expected-value damage engine.\n *\n * Closed-form math over schema profiles + a flat {@link Buff} stack. No\n * sampling, no I/O. Auto-injects every weapon-keyword on the attacker's\n * profile as a buff (so callers don't have to enumerate intrinsics), then\n * resolves the stack via {@link resolveBuffs}, then walks\n * attacks → hits → wounds → unsaved → damage → after-fnp → models-killed.\n *\n * The dataset is required (and defaults to the embedded one) — without it\n * the engine can't look up weapon-keyword effects.\n */\nimport type { Phase, Unit, Weapon } from \"../generated.js\";\nimport {\n type Buff,\n type EngineContext,\n resolveBuffs,\n type ResolvedModifiers,\n type WeaponKeywordRef,\n} from \"./buffs.js\";\nimport { Dataset } from \"../data/dataset.js\";\n\nexport type AttackProfileRef = { weapon: Weapon; profileIndex: number };\nexport type TargetProfileRef = {\n unit: Unit;\n profileIndex: number;\n /** Override target model count (otherwise read from `unit.model_count.min`). */\n modelCount?: number;\n};\n\nexport type Stage = {\n name: \"attacks\" | \"hits\" | \"wounds\" | \"unsaved\" | \"damage\" | \"after-fnp\" | \"models-killed\";\n expected: number;\n detail: string;\n};\n\nexport type EngineInput = {\n attacker: AttackProfileRef;\n target: TargetProfileRef;\n modelsFiring: number;\n /** User / ability / manual buffs. Weapon-keyword buffs are auto-injected. */\n buffs: Buff[];\n context: EngineContext;\n};\n\nexport type EngineOutput = { stages: Stage[]; resolved: ResolvedModifiers };\n\n/**\n * Compute the expected per-stage projection for one (attacker, target, buffs)\n * triple. The dataset defaults to the embedded one — pass an alternate when\n * crunching against a different bundle (e.g. tests).\n */\nexport function crunch(input: EngineInput, dataset?: Dataset): EngineOutput {\n const ds = dataset ?? lazyEmbeddedDataset();\n const weaponProfile = input.attacker.weapon.profiles[input.attacker.profileIndex];\n if (!weaponProfile) {\n throw new RangeError(\n `crunch: attacker.profileIndex=${input.attacker.profileIndex} is out of range for weapon ${input.attacker.weapon.id}`,\n );\n }\n const unitProfile = input.target.unit.profiles[input.target.profileIndex];\n if (!unitProfile) {\n throw new RangeError(\n `crunch: target.profileIndex=${input.target.profileIndex} is out of range for unit ${input.target.unit.id}`,\n );\n }\n\n const targetKeywords = unitKeywordsLower(input.target.unit);\n const ctx: EngineContext = {\n ...input.context,\n targetKeywords: input.context.targetKeywords ?? targetKeywords,\n };\n\n // Auto-inject weapon-keyword buffs from the attacker profile, then append\n // the caller-supplied stack. resolveBuffs deduplicates and ranks them.\n const profileBuffs = profileBuffsFor(input.attacker, ds, ctx);\n const resolved = resolveBuffs([...profileBuffs, ...input.buffs], ctx);\n\n const stages: Stage[] = [];\n\n // 1. Attacks\n const isMelee = input.attacker.weapon.type === \"melee\";\n const baseA = evalStatValue(weaponProfile.stats.A);\n const attacksPerModel = baseA + resolved.attacksMod.value;\n const rapidFire = findKeyword(resolved, \"rapid-fire\");\n const halfRange = ctx.withinHalfRange === true;\n const rapidFireExtraPerModel = rapidFire && halfRange ? evalStatValue(rapidFire.parameters?.value) : 0;\n const blast = findKeyword(resolved, \"blast\");\n const targetModelCount = input.target.modelCount ?? input.target.unit.model_count?.min ?? 1;\n const blastExtraPerModel = blast ? Math.floor(targetModelCount / 5) : 0;\n const attacks = input.modelsFiring * (attacksPerModel + rapidFireExtraPerModel + blastExtraPerModel);\n stages.push({\n name: \"attacks\",\n expected: attacks,\n detail: attacksDetail(input.modelsFiring, attacksPerModel, rapidFireExtraPerModel, blastExtraPerModel),\n });\n\n // 2. Hits\n const hitStat = isMelee ? weaponProfile.stats.WS : weaponProfile.stats.BS;\n const torrent = !!findKeyword(resolved, \"torrent\");\n let hits: number;\n let critHits: number;\n let hitsDetail: string;\n if (torrent) {\n hits = attacks;\n critHits = 0;\n hitsDetail = `Torrent: auto-hits (${attacks.toFixed(4)})`;\n } else {\n if (typeof hitStat !== \"number\") {\n throw new Error(\n `crunch: weapon ${input.attacker.weapon.id} profile ${input.attacker.profileIndex} missing ${isMelee ? \"WS\" : \"BS\"}`,\n );\n }\n const probs = checkProbabilities({\n unmodifiedNeeded: hitStat,\n modifier: resolved.hitMod.value,\n reroll: resolved.rerolls.hit?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: true,\n critThreshold: 6,\n });\n hits = attacks * probs.pass;\n critHits = attacks * probs.crit;\n hitsDetail = `${isMelee ? \"WS\" : \"BS\"}${hitStat}+ (mod ${signed(resolved.hitMod.value)}, reroll ${resolved.rerolls.hit?.subset ?? \"none\"}) → P(hit)=${probs.pass.toFixed(4)}, P(crit)=${probs.crit.toFixed(4)}`;\n }\n const sustained = findKeyword(resolved, \"sustained-hits\");\n if (sustained) {\n hits += critHits * evalStatValue(sustained.parameters?.value);\n hitsDetail += `; +Sustained Hits ${sustained.parameters?.value ?? 1} on ${critHits.toFixed(4)} crits`;\n }\n stages.push({ name: \"hits\", expected: hits, detail: hitsDetail });\n\n // 3. Wounds\n const S = evalStatValue(weaponProfile.stats.S) + resolved.strengthMod.value;\n const T = unitProfile.T + resolved.toughnessMod.value;\n const stdWoundNeeded = woundThreshold(S, T);\n const anti = findKeyword(resolved, \"anti\");\n let antiThreshold = 7; // unreachable\n if (anti) {\n const targetKw = (anti.parameters?.target_keyword as string | undefined)?.toLowerCase();\n if (targetKw && targetKeywords.includes(targetKw)) {\n const threshold = Number(anti.parameters?.threshold);\n if (Number.isFinite(threshold)) antiThreshold = threshold;\n }\n }\n const critWoundThreshold = Math.min(6, antiThreshold);\n\n const hasLethal = !!findKeyword(resolved, \"lethal-hits\");\n const hitsForWoundRoll = hasLethal ? hits - critHits : hits;\n const lethalAutoWounds = hasLethal ? critHits : 0;\n\n const woundProbs = checkProbabilities({\n unmodifiedNeeded: stdWoundNeeded,\n modifier: resolved.woundMod.value,\n reroll: resolved.rerolls.wound?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: true,\n critThreshold: critWoundThreshold,\n });\n const regularWoundsFromRoll = hitsForWoundRoll * (woundProbs.pass - woundProbs.crit);\n const critWoundsFromRoll = hitsForWoundRoll * woundProbs.crit;\n const totalRegularWounds = regularWoundsFromRoll + lethalAutoWounds;\n const hasDevastating = !!findKeyword(resolved, \"devastating-wounds\");\n const mortalWoundsStream = hasDevastating ? critWoundsFromRoll : 0;\n const regularWoundsForSaves = hasDevastating ? totalRegularWounds : totalRegularWounds + critWoundsFromRoll;\n const totalWounds = regularWoundsForSaves + mortalWoundsStream;\n stages.push({\n name: \"wounds\",\n expected: totalWounds,\n detail: `S${S} vs T${T} → need ${stdWoundNeeded}+, anti ${antiThreshold <= 6 ? `${antiThreshold}+ (active)` : \"n/a\"}, P(wound)=${woundProbs.pass.toFixed(4)} (${critWoundsFromRoll.toFixed(4)} crit), lethal ${hasLethal ? \"+\" + lethalAutoWounds.toFixed(4) : \"—\"}, devastating ${hasDevastating ? mortalWoundsStream.toFixed(4) + \" MW\" : \"—\"}`,\n });\n\n // 4. Saves\n const apMod = resolved.apMod.value;\n const AP = weaponProfile.stats.AP + apMod;\n const saveMod = resolved.saveMod.value;\n const armorTargetRaw = unitProfile.Sv - AP - saveMod;\n const ignoresCover = !!findKeyword(resolved, \"ignores-cover\");\n const covered =\n resolved.cover.active && !ignoresCover && input.attacker.weapon.type === \"ranged\";\n const armorAfterCover = covered ? Math.max(3, armorTargetRaw - 1) : armorTargetRaw;\n const armorFinal = clamp(armorAfterCover, 2, 7);\n // The unit's printed invuln (from the profile) and any ability-granted\n // invuln combine best-wins (lowest threshold). Invuln bypasses AP and cover\n // — only the armor branch above is affected by those — so the final save is\n // min(armor-after-AP-and-cover, effective-invuln).\n const printedInvuln = unitProfile.invuln_sv ?? null;\n const abilityInvuln = resolved.invulnerable?.threshold ?? null;\n const effectiveInvuln =\n printedInvuln !== null && abilityInvuln !== null\n ? Math.min(printedInvuln, abilityInvuln)\n : (printedInvuln ?? abilityInvuln);\n const effectiveSaveTarget =\n effectiveInvuln !== null ? Math.min(armorFinal, effectiveInvuln) : armorFinal;\n\n const saveProbs = checkProbabilities({\n unmodifiedNeeded: effectiveSaveTarget,\n modifier: 0,\n reroll: resolved.rerolls.save?.subset ?? \"none\",\n autoFailOnOne: true,\n autoPassOnSix: false,\n critThreshold: 7,\n });\n const pSaved = effectiveSaveTarget >= 7 ? 0 : saveProbs.pass;\n const unsaved = regularWoundsForSaves * (1 - pSaved);\n stages.push({\n name: \"unsaved\",\n expected: unsaved,\n detail: `Sv${unitProfile.Sv}+, AP${signed(AP)}${apMod !== 0 ? ` (apmod ${signed(apMod)})` : \"\"}${saveMod !== 0 ? `, savemod ${signed(saveMod)}` : \"\"}${covered ? \", cover (+1, cap 3+)\" : \"\"}${abilityInvuln !== null ? `, invuln ${abilityInvuln}+ (ability)` : \"\"} → effective ${effectiveSaveTarget}+ (P(save)=${pSaved.toFixed(4)})`,\n });\n\n // 5. Damage\n const baseD = evalStatValue(weaponProfile.stats.D);\n const melta = findKeyword(resolved, \"melta\");\n const meltaBonus = melta && halfRange ? evalStatValue(melta.parameters?.value) : 0;\n const beforeReduction = Math.max(0, baseD + meltaBonus + resolved.damageMod.value);\n const damageReduction = resolved.damageReduction.value;\n // 10e damage-reduction abilities always carry the canonical \"to a minimum\n // of 1\" clause, so the floor lives in the math, not the data. The clause\n // only applies when damage-reduction is active — without it, a D1 weapon\n // with a -1 attacker damage-mod still produces 0 damage.\n const damagePerHit =\n damageReduction > 0 ? Math.max(1, beforeReduction - damageReduction) : beforeReduction;\n const damageMain = unsaved * damagePerHit;\n const damageMortal = mortalWoundsStream * damagePerHit;\n const damage = damageMain + damageMortal;\n stages.push({\n name: \"damage\",\n expected: damage,\n detail: `D ${baseD}${meltaBonus ? ` + Melta ${meltaBonus} (half range)` : \"\"}${resolved.damageMod.value !== 0 ? ` ${signed(resolved.damageMod.value)} (mod)` : \"\"}${damageReduction > 0 ? ` -${damageReduction} (defender, min 1)` : \"\"} = ${damagePerHit} per hit; main ${damageMain.toFixed(4)}, mortal ${damageMortal.toFixed(4)}`,\n });\n\n // 6. FNP\n // Two scopes compose: an all-FNP fires on every unsaved wound; a mortal-FNP\n // fires only on the mortal-wound stream (e.g. Death Guard 5+ FNP vs mortals).\n // A target carrying both rolls both against mortals — independent Bernoulli\n // trials, so the surviving fractions multiply.\n const pSurviveAll = fnpSurvivalFraction(resolved.feelNoPain);\n const pSurviveMortal = fnpSurvivalFraction(resolved.feelNoPainMortal);\n const afterMain = damageMain * pSurviveAll;\n const afterMortal = damageMortal * pSurviveAll * pSurviveMortal;\n const afterFnp = afterMain + afterMortal;\n const fnpDetail = describeFnp(resolved.feelNoPain, resolved.feelNoPainMortal);\n stages.push({ name: \"after-fnp\", expected: afterFnp, detail: fnpDetail });\n\n // 7. Models killed\n const W = unitProfile.W;\n const expectedModelsKilled = W > 0 ? Math.min(targetModelCount, afterFnp / W) : 0;\n stages.push({\n name: \"models-killed\",\n expected: expectedModelsKilled,\n detail: `W${W} per model, ${targetModelCount} models in target; ${afterFnp.toFixed(4)} damage / ${W} = ${(afterFnp / W).toFixed(4)} (capped at ${targetModelCount})`,\n });\n\n return { stages, resolved };\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Lower-cased union of a unit's `keywords` + `faction_keywords`. */\nfunction unitKeywordsLower(unit: Unit): string[] {\n const out: string[] = [];\n for (const k of unit.keywords ?? []) out.push(String(k).toLowerCase());\n for (const k of unit.faction_keywords ?? []) out.push(String(k).toLowerCase());\n return out;\n}\n\nfunction profileBuffsFor(\n attacker: AttackProfileRef,\n dataset: Dataset,\n ctx: EngineContext,\n): Buff[] {\n const weaponView = dataset.weapons.get(attacker.weapon.id);\n if (!weaponView) {\n // Weapon isn't in the dataset (probably a hand-built test fixture); fall\n // back to walking its catalog keywords manually.\n return manualWeaponKeywordBuffs(attacker, dataset, ctx);\n }\n return weaponView.profileBuffs(attacker.profileIndex, ctx);\n}\n\nfunction manualWeaponKeywordBuffs(\n attacker: AttackProfileRef,\n dataset: Dataset,\n ctx: EngineContext,\n): Buff[] {\n const profile = attacker.weapon.profiles[attacker.profileIndex];\n if (!profile) return [];\n const out: Buff[] = [];\n for (const ref of profile.keywords ?? []) {\n const view = dataset.weaponKeywords.get(ref.keyword_id);\n if (!view) continue;\n out.push(\n ...view.getBuffs(\n ref.parameters as Record<string, unknown> | undefined,\n attacker.weapon.id,\n ctx,\n ),\n );\n }\n return out;\n}\n\nfunction findKeyword(\n resolved: ResolvedModifiers,\n keywordId: string,\n): WeaponKeywordRef | undefined {\n return resolved.extraKeywords.find((e) => e.keywordRef.keyword_id === keywordId)?.keywordRef;\n}\n\n/** Standard 10e S-vs-T table → unmodified wound threshold (2..6). */\nfunction woundThreshold(S: number, T: number): number {\n if (S >= 2 * T) return 2;\n if (S > T) return 3;\n if (S === T) return 4;\n if (S * 2 > T) return 5;\n return 6;\n}\n\n/** Probability a single die check passes (and the conditional crit rate). */\nfunction checkProbabilities(args: {\n unmodifiedNeeded: number;\n modifier: number;\n reroll: \"none\" | \"ones\" | \"all-failures\";\n autoFailOnOne: boolean;\n autoPassOnSix: boolean;\n /** Natural roll ≥ this is a crit. Use 7 to disable crits. */\n critThreshold: number;\n}): { pass: number; crit: number } {\n function outcome(face: number): { pass: number; crit: number } {\n if (args.autoFailOnOne && face === 1) return { pass: 0, crit: 0 };\n if (face >= args.critThreshold) return { pass: 1, crit: 1 };\n if (args.autoPassOnSix && face === 6) return { pass: 1, crit: 0 };\n return (face + args.modifier) >= args.unmodifiedNeeded\n ? { pass: 1, crit: 0 }\n : { pass: 0, crit: 0 };\n }\n\n let pass = 0;\n let crit = 0;\n for (let face = 1; face <= 6; face++) {\n const initial = outcome(face);\n if (initial.pass === 1) {\n pass += 1 / 6;\n crit += initial.crit / 6;\n continue;\n }\n // Failed initial — eligible for reroll?\n const eligible =\n args.reroll === \"all-failures\" || (args.reroll === \"ones\" && face === 1);\n if (!eligible) continue;\n // Reroll: uniform over 1..6.\n let rerollPass = 0;\n let rerollCrit = 0;\n for (let f2 = 1; f2 <= 6; f2++) {\n const second = outcome(f2);\n rerollPass += second.pass / 6;\n rerollCrit += second.crit / 6;\n }\n pass += rerollPass / 6;\n crit += rerollCrit / 6;\n }\n return { pass, crit };\n}\n\n/**\n * Mean value of a stat (number or dice expression like `\"D6\"`, `\"2D6\"`,\n * `\"D3+1\"`, `\"D6-1\"`). Unrecognised strings throw — better to crash than to\n * silently return 0 and produce a confidently wrong damage projection.\n */\nfunction evalStatValue(v: unknown): number {\n if (typeof v === \"number\") return v;\n if (typeof v !== \"string\") return Number(v) || 0;\n const trimmed = v.trim();\n if (trimmed === \"\") return 0;\n const asNumber = Number(trimmed);\n if (Number.isFinite(asNumber)) return asNumber;\n const match = /^(\\d*)D(\\d+)([+-]\\d+)?$/i.exec(trimmed);\n if (!match) throw new Error(`evalStatValue: cannot parse \"${v}\"`);\n const count = match[1] === \"\" ? 1 : Number(match[1]);\n const die = Number(match[2]);\n const offset = match[3] ? Number(match[3]) : 0;\n return count * (die + 1) / 2 + offset;\n}\n\nfunction clamp(n: number, lo: number, hi: number): number {\n return Math.max(lo, Math.min(hi, n));\n}\n\nfunction signed(n: number): string {\n if (n > 0) return `+${n}`;\n if (n < 0) return `${n}`;\n return \"0\";\n}\n\n/** Fraction of damage that survives a single FNP roll (1 if no FNP). */\nfunction fnpSurvivalFraction(\n fnp: { threshold: number; dominantSource: unknown } | null,\n): number {\n if (!fnp) return 1;\n const pSucc = Math.max(0, Math.min(1, (7 - fnp.threshold) / 6));\n return 1 - pSucc;\n}\n\nfunction describeFnp(\n all: { threshold: number; dominantSource: unknown } | null,\n mortal: { threshold: number; dominantSource: unknown } | null,\n): string {\n if (!all && !mortal) return \"no FNP\";\n const parts: string[] = [];\n if (all) {\n const pSucc = (7 - all.threshold) / 6;\n parts.push(`FNP ${all.threshold}+ (P=${pSucc.toFixed(4)})`);\n }\n if (mortal) {\n const pSucc = (7 - mortal.threshold) / 6;\n parts.push(`FNP ${mortal.threshold}+ vs mortals (P=${pSucc.toFixed(4)})`);\n }\n return parts.join(\", \");\n}\n\nfunction attacksDetail(\n models: number,\n per: number,\n rapidFire: number,\n blast: number,\n): string {\n const parts = [`${models} × ${per}`];\n if (rapidFire) parts.push(`+ Rapid Fire ${rapidFire} (half range)`);\n if (blast) parts.push(`+ Blast ${blast}/model`);\n return parts.join(\" \");\n}\n\nlet _embeddedDataset: Dataset | null = null;\nfunction lazyEmbeddedDataset(): Dataset {\n if (!_embeddedDataset) _embeddedDataset = Dataset.embedded();\n return _embeddedDataset;\n}\n\nexport type { Phase };\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"from-dsl.d.ts","sourceRoot":"","sources":["../../src/cruncher/from-dsl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EACV,IAAI,EAGJ,UAAU,EACV,aAAa,EACb,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAGpB,8EAA8E;AAC9E,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,kEAAkE;IAClE,EAAE,EAAE,MAAM,CAAC;IACX,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAC;IACd,uEAAuE;IACvE,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,6DAA6D;IAC7D,KAAK,CAAC,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,IAAI,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,EAAE,CAAC;IACnC,4EAA4E;IAC5E,WAAW,EAAE,eAAe,EAAE,CAAC;CAChC,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,sBAAsB,GAAG,UAAU,GAAG,QAAQ,CAAC;AAiB3D;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,aAAa,EACtB,WAAW,GAAE,sBAAmC,GAC/C,iBAAiB,CAKnB;AAi8BD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAuBtE"}
1
+ {"version":3,"file":"from-dsl.d.ts","sourceRoot":"","sources":["../../src/cruncher/from-dsl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EACV,IAAI,EAGJ,UAAU,EACV,aAAa,EACb,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAGpB,8EAA8E;AAC9E,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,kEAAkE;IAClE,EAAE,EAAE,MAAM,CAAC;IACX,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAC;IACd,uEAAuE;IACvE,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,6DAA6D;IAC7D,KAAK,CAAC,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,IAAI,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,EAAE,CAAC;IACnC,4EAA4E;IAC5E,WAAW,EAAE,eAAe,EAAE,CAAC;CAChC,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,sBAAsB,GAAG,UAAU,GAAG,QAAQ,CAAC;AAiB3D;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,aAAa,EACtB,WAAW,GAAE,sBAAmC,GAC/C,iBAAiB,CAKnB;AA0jCD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAuBtE"}