@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.
- package/dist/author-input.d.ts +20 -1
- package/dist/author-input.d.ts.map +1 -1
- package/dist/author-input.js +64 -8
- package/dist/author-input.js.map +1 -1
- package/dist/author-seed.d.ts +62 -0
- package/dist/author-seed.d.ts.map +1 -0
- package/dist/author-seed.js +194 -0
- package/dist/author-seed.js.map +1 -0
- package/dist/commands/translate.d.ts.map +1 -1
- package/dist/commands/translate.js +6 -68
- package/dist/commands/translate.js.map +1 -1
- package/dist/cruncher/buffs.d.ts +57 -1
- package/dist/cruncher/buffs.d.ts.map +1 -1
- package/dist/cruncher/buffs.js +32 -3
- package/dist/cruncher/buffs.js.map +1 -1
- package/dist/cruncher/engine.d.ts.map +1 -1
- package/dist/cruncher/engine.js +50 -15
- package/dist/cruncher/engine.js.map +1 -1
- package/dist/cruncher/from-dsl.d.ts.map +1 -1
- package/dist/cruncher/from-dsl.js +121 -6
- package/dist/cruncher/from-dsl.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/normalize.d.ts.map +1 -1
- package/dist/data/normalize.js +8 -1
- package/dist/data/normalize.js.map +1 -1
- package/dist/gen-conformance.js +22 -1
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +181 -146
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +85 -24
- package/dist/runner.js.map +1 -1
- package/dist/scrub-defensive-flag.d.ts +23 -0
- package/dist/scrub-defensive-flag.d.ts.map +1 -0
- package/dist/scrub-defensive-flag.js +149 -0
- package/dist/scrub-defensive-flag.js.map +1 -0
- package/dist/translate/condition.d.ts +26 -0
- package/dist/translate/condition.d.ts.map +1 -0
- package/dist/translate/condition.js +171 -0
- package/dist/translate/condition.js.map +1 -0
- package/dist/translate/index.d.ts +9 -0
- package/dist/translate/index.d.ts.map +1 -0
- package/dist/translate/index.js +9 -0
- package/dist/translate/index.js.map +1 -0
- package/dist/translate/scoring.d.ts +38 -0
- package/dist/translate/scoring.d.ts.map +1 -0
- package/dist/translate/scoring.js +80 -0
- package/dist/translate/scoring.js.map +1 -0
- package/package.json +3 -1
- package/schemas/core/secondary-card.schema.json +50 -28
- package/schemas/enrichment/ability-dsl/condition.schema.json +5 -2
- package/schemas/enrichment/ability-dsl/effect.schema.json +2 -1
|
@@ -6,79 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
-
|
|
10
|
-
function translateCondition(c) {
|
|
11
|
-
const negate = c.negated ? "not " : "";
|
|
12
|
-
// Compound conditions
|
|
13
|
-
if (c.operator === "and" && c.operands) {
|
|
14
|
-
const parts = c.operands.map(translateCondition);
|
|
15
|
-
return parts.join(" AND ");
|
|
16
|
-
}
|
|
17
|
-
if (c.operator === "or" && c.operands) {
|
|
18
|
-
const parts = c.operands.map(translateCondition);
|
|
19
|
-
return parts.join(" OR ");
|
|
20
|
-
}
|
|
21
|
-
if (c.operator === "not" && c.operands) {
|
|
22
|
-
return `NOT (${c.operands.map(translateCondition).join(", ")})`;
|
|
23
|
-
}
|
|
24
|
-
const p = c.parameters ?? {};
|
|
25
|
-
switch (c.type) {
|
|
26
|
-
case "phase-is":
|
|
27
|
-
return `${negate}during the ${p.phase} phase`;
|
|
28
|
-
case "timing-is":
|
|
29
|
-
return `${negate}at ${formatTiming(p.timing)}`;
|
|
30
|
-
case "player-turn-is":
|
|
31
|
-
return `${negate}in ${p.turn === "your-turn" ? "your" : p.turn === "opponent-turn" ? "opponent's" : "either player's"} turn`;
|
|
32
|
-
case "charged-this-turn":
|
|
33
|
-
return `${negate}unit charged this turn`;
|
|
34
|
-
case "advanced-this-turn":
|
|
35
|
-
return `${negate}unit advanced this turn`;
|
|
36
|
-
case "remained-stationary":
|
|
37
|
-
return `${negate}unit remained stationary`;
|
|
38
|
-
case "unit-below-starting-strength":
|
|
39
|
-
return `${negate}target is below starting strength`;
|
|
40
|
-
case "unit-below-half-strength":
|
|
41
|
-
return `${negate}target is below half strength`;
|
|
42
|
-
case "unit-has-keyword":
|
|
43
|
-
return `${negate}unit has "${p.keyword}"`;
|
|
44
|
-
case "target-has-keyword":
|
|
45
|
-
return `${negate}target has "${p.keyword}"`;
|
|
46
|
-
case "model-is-leader":
|
|
47
|
-
return `${negate}model is leading a unit`;
|
|
48
|
-
case "is-attached":
|
|
49
|
-
return `${negate}attached to a ${p.keyword ?? ""} unit`;
|
|
50
|
-
case "attack-is-type":
|
|
51
|
-
return `${negate}for ${p.attack_type} attacks`;
|
|
52
|
-
case "is-battle-shocked":
|
|
53
|
-
return `${negate}unit is battle-shocked`;
|
|
54
|
-
case "has-lost-wounds":
|
|
55
|
-
return `${negate}model has lost wounds`;
|
|
56
|
-
case "opponent-unit-within-range":
|
|
57
|
-
return `${negate}enemy unit within ${p.range === "engagement" ? "engagement range" : p.range + '"'}`;
|
|
58
|
-
case "unit-within-range-of":
|
|
59
|
-
return `${negate}within ${p.range}" of ${p.target_type ?? "target"}${p.keyword ? ` (${p.keyword})` : ""}`;
|
|
60
|
-
case "within-range-of-objective":
|
|
61
|
-
return `${negate}within range of an objective`;
|
|
62
|
-
case "controls-objective":
|
|
63
|
-
return `${negate}controlling an objective`;
|
|
64
|
-
case "has-fought-this-phase":
|
|
65
|
-
return `${negate}has fought this phase`;
|
|
66
|
-
case "destroyed-by-attack-type":
|
|
67
|
-
return `${negate}destroyed by ${p.attack_type} attack`;
|
|
68
|
-
default:
|
|
69
|
-
return `${negate}[${c.type ?? "unknown"}]`;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
function formatTiming(t) {
|
|
73
|
-
return t.replace(/-/g, " ");
|
|
74
|
-
}
|
|
9
|
+
import { describeCondition } from "../translate/condition.js";
|
|
75
10
|
// ─── Effect translator ───────────────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// Condition humanization lives in ../translate/condition.ts (shared with the
|
|
13
|
+
// scoring-card translator and pinned by the conformance corpus).
|
|
76
14
|
function translateEffect(e, depth = 0) {
|
|
77
15
|
const indent = " ".repeat(depth);
|
|
78
16
|
const arrow = depth > 0 ? "→ " : "";
|
|
79
17
|
switch (e.type) {
|
|
80
18
|
case "conditional":
|
|
81
|
-
return (`${indent}If ${
|
|
19
|
+
return (`${indent}If ${describeCondition(e.condition)}:\n` +
|
|
82
20
|
translateEffect(e.effect, depth + 1));
|
|
83
21
|
case "sequence":
|
|
84
22
|
return e
|
|
@@ -177,7 +115,7 @@ function translateEffectInline(e) {
|
|
|
177
115
|
return `modify OC of ${target} by ${m.value}`;
|
|
178
116
|
// Container types — recurse
|
|
179
117
|
case "conditional":
|
|
180
|
-
return `if ${
|
|
118
|
+
return `if ${describeCondition(e.condition)}: ${translateEffectInline(e.effect)}`;
|
|
181
119
|
case "sequence":
|
|
182
120
|
return e.steps.map(translateEffectInline).join("; ");
|
|
183
121
|
case "dice-gated": {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"translate.js","sourceRoot":"","sources":["../../src/commands/translate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Translates ability DSL entries into plain English descriptions.\n *\n * Recursively walks the effect/condition tree and produces human-readable\n * text purely from the structured data — no external text sources needed.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\n// ─── Types (minimal, matching schema shapes) ─────────────────────────\n\ninterface Condition {\n type?: string;\n operator?: string;\n operands?: Condition[];\n parameters?: Record<string, unknown>;\n negated?: boolean;\n}\n\ninterface Effect {\n type: string;\n target?: string;\n modifier?: Record<string, unknown>;\n condition?: Condition;\n effect?: Effect;\n steps?: Effect[];\n options?: (Effect & { name?: string; requirement?: Record<string, unknown>; choice_label?: string })[];\n choice_label?: string;\n dice?: string;\n threshold?: number | string;\n comparison?: string;\n on_success?: Effect | null;\n on_fail?: Effect | null;\n pool?: { count: number; die: string };\n max_activations?: number;\n}\n\ninterface Ability {\n ability_id: string;\n name: string;\n ability_type?: string;\n behavior?: string;\n detachment_id?: string | null;\n faction_id?: string | null;\n unit_ids?: string[];\n effect: Effect;\n scope?: { range: string; duration: string; range_inches?: number };\n}\n\n// ─── Condition translator ────────────────────────────────────────────\n\nfunction translateCondition(c: Condition): string {\n const negate = c.negated ? \"not \" : \"\";\n\n // Compound conditions\n if (c.operator === \"and\" && c.operands) {\n const parts = c.operands.map(translateCondition);\n return parts.join(\" AND \");\n }\n if (c.operator === \"or\" && c.operands) {\n const parts = c.operands.map(translateCondition);\n return parts.join(\" OR \");\n }\n if (c.operator === \"not\" && c.operands) {\n return `NOT (${c.operands.map(translateCondition).join(\", \")})`;\n }\n\n const p = c.parameters ?? {};\n\n switch (c.type) {\n case \"phase-is\":\n return `${negate}during the ${p.phase} phase`;\n case \"timing-is\":\n return `${negate}at ${formatTiming(p.timing as string)}`;\n case \"player-turn-is\":\n return `${negate}in ${p.turn === \"your-turn\" ? \"your\" : p.turn === \"opponent-turn\" ? \"opponent's\" : \"either player's\"} turn`;\n case \"charged-this-turn\":\n return `${negate}unit charged this turn`;\n case \"advanced-this-turn\":\n return `${negate}unit advanced this turn`;\n case \"remained-stationary\":\n return `${negate}unit remained stationary`;\n case \"unit-below-starting-strength\":\n return `${negate}target is below starting strength`;\n case \"unit-below-half-strength\":\n return `${negate}target is below half strength`;\n case \"unit-has-keyword\":\n return `${negate}unit has \"${p.keyword}\"`;\n case \"target-has-keyword\":\n return `${negate}target has \"${p.keyword}\"`;\n case \"model-is-leader\":\n return `${negate}model is leading a unit`;\n case \"is-attached\":\n return `${negate}attached to a ${p.keyword ?? \"\"} unit`;\n case \"attack-is-type\":\n return `${negate}for ${p.attack_type} attacks`;\n case \"is-battle-shocked\":\n return `${negate}unit is battle-shocked`;\n case \"has-lost-wounds\":\n return `${negate}model has lost wounds`;\n case \"opponent-unit-within-range\":\n return `${negate}enemy unit within ${p.range === \"engagement\" ? \"engagement range\" : p.range + '\"'}`;\n case \"unit-within-range-of\":\n return `${negate}within ${p.range}\" of ${p.target_type ?? \"target\"}${p.keyword ? ` (${p.keyword})` : \"\"}`;\n case \"within-range-of-objective\":\n return `${negate}within range of an objective`;\n case \"controls-objective\":\n return `${negate}controlling an objective`;\n case \"has-fought-this-phase\":\n return `${negate}has fought this phase`;\n case \"destroyed-by-attack-type\":\n return `${negate}destroyed by ${p.attack_type} attack`;\n default:\n return `${negate}[${c.type ?? \"unknown\"}]`;\n }\n}\n\nfunction formatTiming(t: string): string {\n return t.replace(/-/g, \" \");\n}\n\n// ─── Effect translator ───────────────────────────────────────────────\n\nfunction translateEffect(e: Effect, depth: number = 0): string {\n const indent = \" \".repeat(depth);\n const arrow = depth > 0 ? \"→ \" : \"\";\n\n switch (e.type) {\n case \"conditional\":\n return (\n `${indent}If ${translateCondition(e.condition!)}:\\n` +\n translateEffect(e.effect!, depth + 1)\n );\n\n case \"sequence\":\n return e\n .steps!.map((s) => translateEffect(s, depth))\n .join(\"\\n\");\n\n case \"choice\":\n return (\n `${indent}${arrow}Choose one${e.choice_label ? ` (${e.choice_label})` : \"\"}:\\n` +\n e\n .options!.map((o, i) => `${indent} ${i + 1}. ${translateEffectInline(o)}`)\n .join(\"\\n\")\n );\n\n case \"dice-gated\": {\n const comp = formatComparison(e.comparison ?? \"gte\", e.threshold!);\n const success = e.on_success\n ? translateEffectInline(e.on_success)\n : \"nothing\";\n const fail = e.on_fail\n ? `, otherwise ${translateEffectInline(e.on_fail)}`\n : \"\";\n return `${indent}${arrow}Roll ${e.dice}: on ${comp}, ${success}${fail}`;\n }\n\n case \"dice-pool-allocation\": {\n const poolStr = `${e.pool!.count}${e.pool!.die}`;\n const lines = [`${indent}${arrow}Roll ${poolStr} (max ${e.max_activations} activations):`];\n for (const opt of e.options!) {\n const req = opt.requirement!;\n lines.push(\n `${indent} - ${opt.name}: need ${req.type} of ${req.min_value}+ → ${translateEffectInline(opt.effect!)}`\n );\n }\n return lines.join(\"\\n\");\n }\n\n default:\n return `${indent}${arrow}${translateEffectInline(e)}`;\n }\n}\n\n/** Single-line translation for leaf effects. */\nfunction translateEffectInline(e: Effect): string {\n const m = e.modifier ?? {};\n const target = formatTarget(e.target);\n\n switch (e.type) {\n case \"stat-modifier\": {\n const op = m.operation === \"add\" ? \"+\" : m.operation === \"subtract\" ? \"-\" : `${m.operation} `;\n const scope = m.attack_type ? ` (${m.attack_type})` : \"\";\n return `${op}${m.value} ${m.stat}${scope} for ${target}`;\n }\n case \"roll-modifier\": {\n const op = m.operation === \"add\" ? \"+\" : \"-\";\n return `${op}${m.value} to ${m.roll} rolls for ${target}`;\n }\n case \"re-roll\":\n return `re-roll ${m.roll}${m.value ? ` (${m.value}s)` : \"\"} for ${target}`;\n case \"mortal-wounds\":\n return `deal ${m.amount ?? m.amount_table ? \"variable\" : \"?\"} mortal wounds to ${target}`;\n case \"feel-no-pain\":\n return `${target} gains Feel No Pain ${m.threshold}+`;\n case \"keyword-grant\": {\n // Grants come as singular `keyword` or the (dominant) `keywords` array.\n const kw = Array.isArray(m.keywords) ? m.keywords.join(\", \") : (m.keyword ?? \"keywords\");\n return `${target}'s ${m.weapon_type ?? \"all\"} weapons gain ${kw}`;\n }\n case \"ability-grant\":\n return `${target} gains ${formatGrantType((m.grant_type ?? m.ability_id) as string | undefined)}`;\n case \"movement-modifier\":\n return `${target} gains ${m.move_type}${m.value ? ` ${m.value}\"` : \"\"}`;\n case \"damage-reduction\":\n return `reduce incoming damage to ${target} by ${m.amount}`;\n case \"resurrection\":\n return `return ${m.count ?? 1} model(s) to ${target} with ${m.wounds_remaining ?? \"full\"} wounds`;\n case \"model-destruction\":\n return `destroy ${m.count} non-leader model(s) from ${target}`;\n case \"cp-gain\":\n return `gain ${m.amount} CP`;\n case \"cp-refund\":\n return `refund ${m.amount} CP`;\n case \"resource-gain\":\n return `gain ${m.amount} to ${m.pool_id}`;\n case \"resource-spend\":\n return `spend ${m.amount} from ${m.pool_id}`;\n case \"invulnerable-save\":\n return `${target} gains ${m.value}+ invulnerable save`;\n case \"leadership-modifier\":\n return `force battle-shock test on ${target}`;\n case \"fight-on-death\":\n return `${target} fights on death`;\n case \"shoot-on-death\":\n return `${target} shoots on death`;\n case \"fight-first\":\n return `${target} fights first`;\n case \"fight-last\":\n return `${target} fights last`;\n case \"deep-strike\":\n return `${target} can deep strike`;\n case \"fallback-and-act\":\n return `${target} can fall back and act`;\n case \"attack-restriction\":\n return `${target}: ${m.restriction_type ?? \"restriction\"} (max ${m.max_models ?? \"?\"} models)`;\n case \"objective-control-modifier\":\n return `modify OC of ${target} by ${m.value}`;\n\n // Container types — recurse\n case \"conditional\":\n return `if ${translateCondition(e.condition!)}: ${translateEffectInline(e.effect!)}`;\n case \"sequence\":\n return e.steps!.map(translateEffectInline).join(\"; \");\n case \"dice-gated\": {\n const comp = formatComparison(e.comparison ?? \"gte\", e.threshold!);\n return `roll ${e.dice} (${comp}): ${e.on_success ? translateEffectInline(e.on_success) : \"nothing\"}`;\n }\n\n default:\n return `[${e.type}]`;\n }\n}\n\nfunction formatTarget(t?: string): string {\n if (!t) return \"target\";\n return t.replace(/-/g, \" \");\n}\n\nfunction formatGrantType(g: string | undefined): string {\n // ability-grant carries either `grant_type` or `ability_id`; an unauthored\n // stub may carry neither. Don't crash the whole translation on a missing key.\n return g ? g.replace(/-/g, \" \") : \"an ability\";\n}\n\nfunction formatComparison(comp: string, threshold: number | string): string {\n const thStr = typeof threshold === \"string\" ? threshold : `${threshold}`;\n switch (comp) {\n case \"gte\": return `${thStr}+`;\n case \"lte\": return `${thStr} or less`;\n case \"gt\": return `greater than ${thStr}`;\n case \"lt\": return `less than ${thStr}`;\n case \"eq\": return `exactly ${thStr}`;\n default: return `${thStr}+`;\n }\n}\n\n// ─── Scope translator ────────────────────────────────────────────────\n\nfunction translateScope(s?: { range: string; duration: string; range_inches?: number }): string {\n if (!s) return \"\";\n const range = s.range.replace(/-/g, \" \");\n const duration = s.duration.replace(/-/g, \" \");\n return `Scope: ${range}${s.range_inches ? ` (${s.range_inches}\")` : \"\"}. Duration: ${duration}.`;\n}\n\n// ─── Main command ────────────────────────────────────────────────────\n\nexport async function translateCommand(\n path?: string\n): Promise<void> {\n const filePath = resolve(\n process.cwd(),\n path ?? \"../data/enrichment/world-eaters/abilities.json\"\n );\n const abilities: Ability[] = JSON.parse(readFileSync(filePath, \"utf-8\"));\n\n for (const a of abilities) {\n const meta: string[] = [];\n if (a.ability_type) meta.push(a.ability_type);\n if (a.behavior) meta.push(a.behavior);\n if (a.detachment_id) meta.push(`detachment: ${a.detachment_id}`);\n if (a.unit_ids?.length) meta.push(`units: ${a.unit_ids.join(\", \")}`);\n\n console.log(`\\n═══ ${a.name} [${a.ability_id}] ═══`);\n if (meta.length) console.log(` ${meta.join(\" | \")}`);\n console.log(translateEffect(a.effect));\n const scope = translateScope(a.scope);\n if (scope) console.log(scope);\n }\n\n console.log(`\\n── ${abilities.length} abilities translated ──`);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"translate.js","sourceRoot":"","sources":["../../src/commands/translate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Translates ability DSL entries into plain English descriptions.\n *\n * Recursively walks the effect/condition tree and produces human-readable\n * text purely from the structured data — no external text sources needed.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\n\nimport { describeCondition, type Condition } from \"../translate/condition.js\";\n\n// ─── Types (minimal, matching schema shapes) ─────────────────────────\n\ninterface Effect {\n type: string;\n target?: string;\n modifier?: Record<string, unknown>;\n condition?: Condition;\n effect?: Effect;\n steps?: Effect[];\n options?: (Effect & { name?: string; requirement?: Record<string, unknown>; choice_label?: string })[];\n choice_label?: string;\n dice?: string;\n threshold?: number | string;\n comparison?: string;\n on_success?: Effect | null;\n on_fail?: Effect | null;\n pool?: { count: number; die: string };\n max_activations?: number;\n}\n\ninterface Ability {\n ability_id: string;\n name: string;\n ability_type?: string;\n behavior?: string;\n detachment_id?: string | null;\n faction_id?: string | null;\n unit_ids?: string[];\n effect: Effect;\n scope?: { range: string; duration: string; range_inches?: number };\n}\n\n// ─── Effect translator ───────────────────────────────────────────────\n//\n// Condition humanization lives in ../translate/condition.ts (shared with the\n// scoring-card translator and pinned by the conformance corpus).\n\nfunction translateEffect(e: Effect, depth: number = 0): string {\n const indent = \" \".repeat(depth);\n const arrow = depth > 0 ? \"→ \" : \"\";\n\n switch (e.type) {\n case \"conditional\":\n return (\n `${indent}If ${describeCondition(e.condition!)}:\\n` +\n translateEffect(e.effect!, depth + 1)\n );\n\n case \"sequence\":\n return e\n .steps!.map((s) => translateEffect(s, depth))\n .join(\"\\n\");\n\n case \"choice\":\n return (\n `${indent}${arrow}Choose one${e.choice_label ? ` (${e.choice_label})` : \"\"}:\\n` +\n e\n .options!.map((o, i) => `${indent} ${i + 1}. ${translateEffectInline(o)}`)\n .join(\"\\n\")\n );\n\n case \"dice-gated\": {\n const comp = formatComparison(e.comparison ?? \"gte\", e.threshold!);\n const success = e.on_success\n ? translateEffectInline(e.on_success)\n : \"nothing\";\n const fail = e.on_fail\n ? `, otherwise ${translateEffectInline(e.on_fail)}`\n : \"\";\n return `${indent}${arrow}Roll ${e.dice}: on ${comp}, ${success}${fail}`;\n }\n\n case \"dice-pool-allocation\": {\n const poolStr = `${e.pool!.count}${e.pool!.die}`;\n const lines = [`${indent}${arrow}Roll ${poolStr} (max ${e.max_activations} activations):`];\n for (const opt of e.options!) {\n const req = opt.requirement!;\n lines.push(\n `${indent} - ${opt.name}: need ${req.type} of ${req.min_value}+ → ${translateEffectInline(opt.effect!)}`\n );\n }\n return lines.join(\"\\n\");\n }\n\n default:\n return `${indent}${arrow}${translateEffectInline(e)}`;\n }\n}\n\n/** Single-line translation for leaf effects. */\nfunction translateEffectInline(e: Effect): string {\n const m = e.modifier ?? {};\n const target = formatTarget(e.target);\n\n switch (e.type) {\n case \"stat-modifier\": {\n const op = m.operation === \"add\" ? \"+\" : m.operation === \"subtract\" ? \"-\" : `${m.operation} `;\n const scope = m.attack_type ? ` (${m.attack_type})` : \"\";\n return `${op}${m.value} ${m.stat}${scope} for ${target}`;\n }\n case \"roll-modifier\": {\n const op = m.operation === \"add\" ? \"+\" : \"-\";\n return `${op}${m.value} to ${m.roll} rolls for ${target}`;\n }\n case \"re-roll\":\n return `re-roll ${m.roll}${m.value ? ` (${m.value}s)` : \"\"} for ${target}`;\n case \"mortal-wounds\":\n return `deal ${m.amount ?? m.amount_table ? \"variable\" : \"?\"} mortal wounds to ${target}`;\n case \"feel-no-pain\":\n return `${target} gains Feel No Pain ${m.threshold}+`;\n case \"keyword-grant\": {\n // Grants come as singular `keyword` or the (dominant) `keywords` array.\n const kw = Array.isArray(m.keywords) ? m.keywords.join(\", \") : (m.keyword ?? \"keywords\");\n return `${target}'s ${m.weapon_type ?? \"all\"} weapons gain ${kw}`;\n }\n case \"ability-grant\":\n return `${target} gains ${formatGrantType((m.grant_type ?? m.ability_id) as string | undefined)}`;\n case \"movement-modifier\":\n return `${target} gains ${m.move_type}${m.value ? ` ${m.value}\"` : \"\"}`;\n case \"damage-reduction\":\n return `reduce incoming damage to ${target} by ${m.amount}`;\n case \"resurrection\":\n return `return ${m.count ?? 1} model(s) to ${target} with ${m.wounds_remaining ?? \"full\"} wounds`;\n case \"model-destruction\":\n return `destroy ${m.count} non-leader model(s) from ${target}`;\n case \"cp-gain\":\n return `gain ${m.amount} CP`;\n case \"cp-refund\":\n return `refund ${m.amount} CP`;\n case \"resource-gain\":\n return `gain ${m.amount} to ${m.pool_id}`;\n case \"resource-spend\":\n return `spend ${m.amount} from ${m.pool_id}`;\n case \"invulnerable-save\":\n return `${target} gains ${m.value}+ invulnerable save`;\n case \"leadership-modifier\":\n return `force battle-shock test on ${target}`;\n case \"fight-on-death\":\n return `${target} fights on death`;\n case \"shoot-on-death\":\n return `${target} shoots on death`;\n case \"fight-first\":\n return `${target} fights first`;\n case \"fight-last\":\n return `${target} fights last`;\n case \"deep-strike\":\n return `${target} can deep strike`;\n case \"fallback-and-act\":\n return `${target} can fall back and act`;\n case \"attack-restriction\":\n return `${target}: ${m.restriction_type ?? \"restriction\"} (max ${m.max_models ?? \"?\"} models)`;\n case \"objective-control-modifier\":\n return `modify OC of ${target} by ${m.value}`;\n\n // Container types — recurse\n case \"conditional\":\n return `if ${describeCondition(e.condition!)}: ${translateEffectInline(e.effect!)}`;\n case \"sequence\":\n return e.steps!.map(translateEffectInline).join(\"; \");\n case \"dice-gated\": {\n const comp = formatComparison(e.comparison ?? \"gte\", e.threshold!);\n return `roll ${e.dice} (${comp}): ${e.on_success ? translateEffectInline(e.on_success) : \"nothing\"}`;\n }\n\n default:\n return `[${e.type}]`;\n }\n}\n\nfunction formatTarget(t?: string): string {\n if (!t) return \"target\";\n return t.replace(/-/g, \" \");\n}\n\nfunction formatGrantType(g: string | undefined): string {\n // ability-grant carries either `grant_type` or `ability_id`; an unauthored\n // stub may carry neither. Don't crash the whole translation on a missing key.\n return g ? g.replace(/-/g, \" \") : \"an ability\";\n}\n\nfunction formatComparison(comp: string, threshold: number | string): string {\n const thStr = typeof threshold === \"string\" ? threshold : `${threshold}`;\n switch (comp) {\n case \"gte\": return `${thStr}+`;\n case \"lte\": return `${thStr} or less`;\n case \"gt\": return `greater than ${thStr}`;\n case \"lt\": return `less than ${thStr}`;\n case \"eq\": return `exactly ${thStr}`;\n default: return `${thStr}+`;\n }\n}\n\n// ─── Scope translator ────────────────────────────────────────────────\n\nfunction translateScope(s?: { range: string; duration: string; range_inches?: number }): string {\n if (!s) return \"\";\n const range = s.range.replace(/-/g, \" \");\n const duration = s.duration.replace(/-/g, \" \");\n return `Scope: ${range}${s.range_inches ? ` (${s.range_inches}\")` : \"\"}. Duration: ${duration}.`;\n}\n\n// ─── Main command ────────────────────────────────────────────────────\n\nexport async function translateCommand(\n path?: string\n): Promise<void> {\n const filePath = resolve(\n process.cwd(),\n path ?? \"../data/enrichment/world-eaters/abilities.json\"\n );\n const abilities: Ability[] = JSON.parse(readFileSync(filePath, \"utf-8\"));\n\n for (const a of abilities) {\n const meta: string[] = [];\n if (a.ability_type) meta.push(a.ability_type);\n if (a.behavior) meta.push(a.behavior);\n if (a.detachment_id) meta.push(`detachment: ${a.detachment_id}`);\n if (a.unit_ids?.length) meta.push(`units: ${a.unit_ids.join(\", \")}`);\n\n console.log(`\\n═══ ${a.name} [${a.ability_id}] ═══`);\n if (meta.length) console.log(` ${meta.join(\" | \")}`);\n console.log(translateEffect(a.effect));\n const scope = translateScope(a.scope);\n if (scope) console.log(scope);\n }\n\n console.log(`\\n── ${abilities.length} abilities translated ──`);\n}\n"]}
|
package/dist/cruncher/buffs.d.ts
CHANGED
|
@@ -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
|
|
1
|
+
{"version":3,"file":"buffs.d.ts","sourceRoot":"","sources":["../../src/cruncher/buffs.ts"],"names":[],"mappings":"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"}
|
package/dist/cruncher/buffs.js
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
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,
|
|
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"}
|
package/dist/cruncher/engine.js
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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)
|