@alpaca-software/40kdc-data 0.1.3 → 0.3.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 (58) hide show
  1. package/dist/author-input.d.ts +20 -1
  2. package/dist/author-input.d.ts.map +1 -1
  3. package/dist/author-input.js +64 -8
  4. package/dist/author-input.js.map +1 -1
  5. package/dist/author-seed.d.ts +62 -0
  6. package/dist/author-seed.d.ts.map +1 -0
  7. package/dist/author-seed.js +194 -0
  8. package/dist/author-seed.js.map +1 -0
  9. package/dist/commands/translate.d.ts.map +1 -1
  10. package/dist/commands/translate.js +6 -68
  11. package/dist/commands/translate.js.map +1 -1
  12. package/dist/cruncher/buffs.d.ts +57 -1
  13. package/dist/cruncher/buffs.d.ts.map +1 -1
  14. package/dist/cruncher/buffs.js +32 -3
  15. package/dist/cruncher/buffs.js.map +1 -1
  16. package/dist/cruncher/engine.d.ts.map +1 -1
  17. package/dist/cruncher/engine.js +50 -15
  18. package/dist/cruncher/engine.js.map +1 -1
  19. package/dist/cruncher/from-dsl.d.ts.map +1 -1
  20. package/dist/cruncher/from-dsl.js +121 -6
  21. package/dist/cruncher/from-dsl.js.map +1 -1
  22. package/dist/data/bundle.generated.js +1 -1
  23. package/dist/data/bundle.generated.js.map +1 -1
  24. package/dist/data/normalize.d.ts.map +1 -1
  25. package/dist/data/normalize.js +8 -1
  26. package/dist/data/normalize.js.map +1 -1
  27. package/dist/gen-conformance.js +22 -1
  28. package/dist/gen-conformance.js.map +1 -1
  29. package/dist/generated.d.ts +181 -146
  30. package/dist/generated.d.ts.map +1 -1
  31. package/dist/generated.js.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/runner.d.ts.map +1 -1
  37. package/dist/runner.js +85 -24
  38. package/dist/runner.js.map +1 -1
  39. package/dist/scrub-defensive-flag.d.ts +23 -0
  40. package/dist/scrub-defensive-flag.d.ts.map +1 -0
  41. package/dist/scrub-defensive-flag.js +149 -0
  42. package/dist/scrub-defensive-flag.js.map +1 -0
  43. package/dist/translate/condition.d.ts +26 -0
  44. package/dist/translate/condition.d.ts.map +1 -0
  45. package/dist/translate/condition.js +171 -0
  46. package/dist/translate/condition.js.map +1 -0
  47. package/dist/translate/index.d.ts +9 -0
  48. package/dist/translate/index.d.ts.map +1 -0
  49. package/dist/translate/index.js +9 -0
  50. package/dist/translate/index.js.map +1 -0
  51. package/dist/translate/scoring.d.ts +38 -0
  52. package/dist/translate/scoring.d.ts.map +1 -0
  53. package/dist/translate/scoring.js +80 -0
  54. package/dist/translate/scoring.js.map +1 -0
  55. package/package.json +3 -1
  56. package/schemas/core/secondary-card.schema.json +50 -28
  57. package/schemas/enrichment/ability-dsl/condition.schema.json +5 -2
  58. package/schemas/enrichment/ability-dsl/effect.schema.json +2 -1
@@ -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"}
@@ -45,6 +45,12 @@ function walk(node, source, opts, out) {
45
45
  case "bs-modifier":
46
46
  translateBsModifier(node, source, opts, out);
47
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;
48
54
  case "conditional":
49
55
  translateConditional(node, source, opts, out);
50
56
  return;
@@ -186,11 +192,26 @@ function translateRollModifier(node, source, opts, out) {
186
192
  return; // saves apply to the defender, not the attacker.
187
193
  }
188
194
  else {
189
- // target perspective: only `save` rolls on the buffed unit fire here.
190
- if (roll !== "save")
191
- return;
192
- 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 {
193
213
  return;
214
+ }
194
215
  }
195
216
  switch (roll) {
196
217
  case "hit":
@@ -356,7 +377,28 @@ function translateFeelNoPain(node, source, opts, out) {
356
377
  });
357
378
  return;
358
379
  }
359
- 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 });
360
402
  }
361
403
  function translateKeywordGrant(node, source, opts, out) {
362
404
  // Weapon-keyword grants ride with the attacker's profile (e.g. "your
@@ -437,6 +479,73 @@ const UNHONORABLE_NARROWING = ["weapon_name", "weapon_profile", "weapon_keyword"
437
479
  function unhonorableNarrowing(modifier) {
438
480
  return UNHONORABLE_NARROWING.find((k) => modifier[k] != null);
439
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;
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 } });
548
+ }
440
549
  function translateBsModifier(node, source, opts, out) {
441
550
  // A bs-modifier on `target: "attacker"` is a defender-side rule: it
442
551
  // penalises *incoming* hit rolls (e.g. Benefit of Cover). Translate it
@@ -749,7 +858,13 @@ function describeContribution(c) {
749
858
  case "reroll":
750
859
  return `re-roll ${c.roll}${c.subset === "ones" ? " 1s" : ""}`;
751
860
  case "feel-no-pain":
752
- return `feel no pain ${c.threshold}+`;
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`;
753
868
  case "cover":
754
869
  return "cover";
755
870
  }