@alpaca-software/40kdc-data 0.1.2 → 0.1.3
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/README.md +2 -0
- package/dist/abilities-resolver/resolver.d.ts +13 -4
- package/dist/abilities-resolver/resolver.d.ts.map +1 -1
- package/dist/abilities-resolver/resolver.js +22 -15
- package/dist/abilities-resolver/resolver.js.map +1 -1
- package/dist/audit-coverage.d.ts +78 -0
- package/dist/audit-coverage.d.ts.map +1 -0
- package/dist/audit-coverage.js +341 -0
- package/dist/audit-coverage.js.map +1 -0
- package/dist/author-batch.d.ts +147 -0
- package/dist/author-batch.d.ts.map +1 -0
- package/dist/author-batch.js +675 -0
- package/dist/author-batch.js.map +1 -0
- package/dist/author-input.d.ts +37 -0
- package/dist/author-input.d.ts.map +1 -0
- package/dist/author-input.js +162 -0
- package/dist/author-input.js.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/translate.d.ts.map +1 -1
- package/dist/commands/translate.js +9 -4
- package/dist/commands/translate.js.map +1 -1
- package/dist/cruncher/attribution.d.ts +66 -0
- package/dist/cruncher/attribution.d.ts.map +1 -0
- package/dist/cruncher/attribution.js +88 -0
- package/dist/cruncher/attribution.js.map +1 -0
- package/dist/cruncher/buffs.d.ts +23 -1
- package/dist/cruncher/buffs.d.ts.map +1 -1
- package/dist/cruncher/buffs.js +1 -1
- package/dist/cruncher/buffs.js.map +1 -1
- package/dist/cruncher/from-dsl.d.ts +32 -0
- package/dist/cruncher/from-dsl.d.ts.map +1 -1
- package/dist/cruncher/from-dsl.js +485 -40
- package/dist/cruncher/from-dsl.js.map +1 -1
- package/dist/cruncher/index.d.ts +1 -0
- package/dist/cruncher/index.d.ts.map +1 -1
- package/dist/cruncher/index.js +1 -0
- package/dist/cruncher/index.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/collection.d.ts +9 -0
- package/dist/data/collection.d.ts.map +1 -1
- package/dist/data/collection.js +14 -0
- package/dist/data/collection.js.map +1 -1
- package/dist/data/dataset.d.ts +80 -2
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +143 -6
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/entities.d.ts +2 -5
- package/dist/data/entities.d.ts.map +1 -1
- package/dist/data/entities.js.map +1 -1
- package/dist/data/index.d.ts +3 -2
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +1 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/roster-resolve.d.ts +26 -1
- package/dist/data/roster-resolve.d.ts.map +1 -1
- package/dist/data/roster-resolve.js +46 -0
- package/dist/data/roster-resolve.js.map +1 -1
- package/dist/export/index.d.ts +1 -0
- package/dist/export/index.d.ts.map +1 -1
- package/dist/export/index.js +3 -0
- package/dist/export/index.js.map +1 -1
- package/dist/export/rosterizer.d.ts +3 -0
- package/dist/export/rosterizer.d.ts.map +1 -0
- package/dist/export/rosterizer.js +144 -0
- package/dist/export/rosterizer.js.map +1 -0
- package/dist/export/serializer.d.ts +1 -1
- package/dist/export/serializer.d.ts.map +1 -1
- package/dist/export/serializer.js.map +1 -1
- package/dist/gen-conformance.js +212 -11
- package/dist/gen-conformance.js.map +1 -1
- package/dist/import/gw.d.ts +69 -0
- package/dist/import/gw.d.ts.map +1 -0
- package/dist/import/gw.js +245 -0
- package/dist/import/gw.js.map +1 -0
- package/dist/import/import-roster.d.ts +52 -3
- package/dist/import/import-roster.d.ts.map +1 -1
- package/dist/import/import-roster.js +114 -4
- package/dist/import/import-roster.js.map +1 -1
- package/dist/import/index.d.ts +2 -2
- package/dist/import/index.d.ts.map +1 -1
- package/dist/import/index.js +1 -1
- package/dist/import/index.js.map +1 -1
- package/dist/import/listforge.d.ts.map +1 -1
- package/dist/import/listforge.js +15 -1
- package/dist/import/listforge.js.map +1 -1
- package/dist/import/newrecruit-text.d.ts +3 -0
- package/dist/import/newrecruit-text.d.ts.map +1 -1
- package/dist/import/newrecruit-text.js +6 -0
- package/dist/import/newrecruit-text.js.map +1 -1
- package/dist/import/newrecruit-wtc.d.ts.map +1 -1
- package/dist/import/newrecruit-wtc.js +10 -7
- package/dist/import/newrecruit-wtc.js.map +1 -1
- package/dist/import/rosterizer.d.ts +70 -0
- package/dist/import/rosterizer.d.ts.map +1 -0
- package/dist/import/rosterizer.js +348 -0
- package/dist/import/rosterizer.js.map +1 -0
- package/dist/import/types.d.ts +1 -1
- package/dist/import/types.d.ts.map +1 -1
- package/dist/import/types.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/migrations/2026-weapon-keywords.js +4 -0
- package/dist/migrations/2026-weapon-keywords.js.map +1 -1
- package/dist/runner.d.ts +38 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +492 -0
- package/dist/runner.js.map +1 -0
- package/dist/scrub-ip.d.ts +14 -0
- package/dist/scrub-ip.d.ts.map +1 -0
- package/dist/scrub-ip.js +88 -0
- package/dist/scrub-ip.js.map +1 -0
- package/package.json +9 -2
- package/schemas/core/roster.schema.json +3 -1
|
@@ -17,8 +17,9 @@ const DEFENDER_TARGETS = new Set(["defender", "enemy-within-aura", "all-enemy"])
|
|
|
17
17
|
* naming any branches the buff layer can't express today.
|
|
18
18
|
*/
|
|
19
19
|
export function effectToBuffs(effect, source, context, perspective = "attacker") {
|
|
20
|
-
const out = { applied: [], unsupported: [] };
|
|
21
|
-
|
|
20
|
+
const out = { applied: [], unsupported: [], activatable: [] };
|
|
21
|
+
const abilityId = source.kind === "ability" ? source.abilityId : "effect";
|
|
22
|
+
walk(effect, source, { context, perspective, abilityId }, out);
|
|
22
23
|
return out;
|
|
23
24
|
}
|
|
24
25
|
function walk(node, source, opts, out) {
|
|
@@ -52,11 +53,8 @@ function walk(node, source, opts, out) {
|
|
|
52
53
|
walk(step, source, opts, out);
|
|
53
54
|
return;
|
|
54
55
|
case "choice":
|
|
55
|
-
// Player decision —
|
|
56
|
-
out
|
|
57
|
-
reason: "choice: player picks one option; the buff layer can't choose",
|
|
58
|
-
effectFragment: node,
|
|
59
|
-
});
|
|
56
|
+
// Player decision — each branch becomes an opt-in lever (pick one).
|
|
57
|
+
enumerateChoice(node, source, opts, out);
|
|
60
58
|
return;
|
|
61
59
|
case "dice-gated":
|
|
62
60
|
// Probabilistic; the buff layer is deterministic.
|
|
@@ -66,10 +64,9 @@ function walk(node, source, opts, out) {
|
|
|
66
64
|
});
|
|
67
65
|
return;
|
|
68
66
|
case "dice-pool-allocation":
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
67
|
+
// Player spends dice on options at runtime — each buff-bearing option
|
|
68
|
+
// becomes an opt-in lever, grouped under the pool's activation cap.
|
|
69
|
+
enumerateDicePool(node, source, opts, out);
|
|
73
70
|
return;
|
|
74
71
|
default:
|
|
75
72
|
// Unknown effect — record it. Covers ability-grant, deep-strike,
|
|
@@ -132,8 +129,17 @@ function translateReroll(node, source, opts, out) {
|
|
|
132
129
|
out.unsupported.push({ reason: "re-roll: missing modifier object", effectFragment: node });
|
|
133
130
|
return;
|
|
134
131
|
}
|
|
132
|
+
const narrowed = unhonorableNarrowing(modifier);
|
|
133
|
+
if (narrowed) {
|
|
134
|
+
out.unsupported.push({ reason: `re-roll: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
135
137
|
const roll = modifier.roll;
|
|
136
|
-
|
|
138
|
+
// A `value: 1` on a re-roll modifier unambiguously means "re-roll rolls of 1".
|
|
139
|
+
// A historical migration (2026-weapon-keywords) mis-defaulted such nodes to
|
|
140
|
+
// `subset: "all-failures"`; honor the value as the source of truth so any
|
|
141
|
+
// stray data of that shape can't silently over-apply the reroll.
|
|
142
|
+
const subset = modifier.value === 1 ? "ones" : modifier.subset;
|
|
137
143
|
// Under target perspective, only "save" rerolls fire on the buffed unit.
|
|
138
144
|
if (opts.perspective === "target" && roll !== "save")
|
|
139
145
|
return;
|
|
@@ -156,6 +162,11 @@ function translateRollModifier(node, source, opts, out) {
|
|
|
156
162
|
});
|
|
157
163
|
return;
|
|
158
164
|
}
|
|
165
|
+
const narrowed = unhonorableNarrowing(modifier);
|
|
166
|
+
if (narrowed) {
|
|
167
|
+
out.unsupported.push({ reason: `roll-modifier: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
159
170
|
const value = signedValue(modifier);
|
|
160
171
|
if (value === null) {
|
|
161
172
|
out.unsupported.push({
|
|
@@ -210,6 +221,27 @@ function translateStatModifier(node, source, opts, out) {
|
|
|
210
221
|
});
|
|
211
222
|
return;
|
|
212
223
|
}
|
|
224
|
+
const narrowed = unhonorableNarrowing(modifier);
|
|
225
|
+
if (narrowed) {
|
|
226
|
+
out.unsupported.push({ reason: `stat-modifier: narrows by "${narrowed}" which the cruncher can't resolve here`, effectFragment: node });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const stat = modifier.stat;
|
|
230
|
+
const isOnBuffedUnit = appliesToBuffedUnit(node, opts.perspective);
|
|
231
|
+
// `attack_type: melee|ranged` scopes the mod to that attack — express it as a
|
|
232
|
+
// phase gate so e.g. a melee +1 Attack doesn't fire in the shooting phase.
|
|
233
|
+
const applicability = attackTypeApplicability(modifier);
|
|
234
|
+
const emit = (contribution) => {
|
|
235
|
+
const buff = { source, contribution };
|
|
236
|
+
out.applied.push(applicability ? { ...buff, applicableWhen: applicability } : buff);
|
|
237
|
+
};
|
|
238
|
+
// AP has an inverted sign convention (stored negative; more negative = more
|
|
239
|
+
// piercing) and offensive/defensive variants, so it computes its own delta
|
|
240
|
+
// and routes by attacker/defender rather than going through `signedValue`.
|
|
241
|
+
if (stat === "AP") {
|
|
242
|
+
translateApModifier(node, modifier, opts, out, emit);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
213
245
|
const value = signedValue(modifier);
|
|
214
246
|
if (value === null) {
|
|
215
247
|
out.unsupported.push({
|
|
@@ -218,18 +250,16 @@ function translateStatModifier(node, source, opts, out) {
|
|
|
218
250
|
});
|
|
219
251
|
return;
|
|
220
252
|
}
|
|
221
|
-
const stat = modifier.stat;
|
|
222
|
-
const isOnBuffedUnit = appliesToBuffedUnit(node, opts.perspective);
|
|
223
253
|
switch (stat) {
|
|
224
254
|
case "A":
|
|
225
255
|
if (opts.perspective !== "attacker" || !isOnBuffedUnit)
|
|
226
256
|
return;
|
|
227
|
-
|
|
257
|
+
emit({ type: "attacks-mod", value });
|
|
228
258
|
return;
|
|
229
259
|
case "S":
|
|
230
260
|
if (opts.perspective !== "attacker" || !isOnBuffedUnit)
|
|
231
261
|
return;
|
|
232
|
-
|
|
262
|
+
emit({ type: "strength-mod", value });
|
|
233
263
|
return;
|
|
234
264
|
case "T":
|
|
235
265
|
// Defender stat. Only relevant under target perspective.
|
|
@@ -242,7 +272,7 @@ function translateStatModifier(node, source, opts, out) {
|
|
|
242
272
|
}
|
|
243
273
|
if (!isOnBuffedUnit)
|
|
244
274
|
return;
|
|
245
|
-
|
|
275
|
+
emit({ type: "toughness-mod", value });
|
|
246
276
|
return;
|
|
247
277
|
case "Sv":
|
|
248
278
|
// Saves improve when the *defender* gets +Sv. A +1 to Sv in printed
|
|
@@ -259,16 +289,7 @@ function translateStatModifier(node, source, opts, out) {
|
|
|
259
289
|
}
|
|
260
290
|
if (!isOnBuffedUnit)
|
|
261
291
|
return;
|
|
262
|
-
|
|
263
|
-
return;
|
|
264
|
-
case "AP":
|
|
265
|
-
// AP rides on the attacker's weapon profile and is stored as a negative
|
|
266
|
-
// number in the data (e.g. AP -1). The data's `{operation:"add", value:-1}`
|
|
267
|
-
// form means "AP becomes one more negative" → more piercing. `signedValue`
|
|
268
|
-
// already returns that negative number directly, so pass it through.
|
|
269
|
-
if (opts.perspective !== "attacker" || !isOnBuffedUnit)
|
|
270
|
-
return;
|
|
271
|
-
out.applied.push({ source, contribution: { type: "ap-mod", value } });
|
|
292
|
+
emit({ type: "save-mod", value: -value });
|
|
272
293
|
return;
|
|
273
294
|
default:
|
|
274
295
|
out.unsupported.push({
|
|
@@ -277,6 +298,40 @@ function translateStatModifier(node, source, opts, out) {
|
|
|
277
298
|
});
|
|
278
299
|
}
|
|
279
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Translate an `AP` stat-modifier. AP rides on the attacker's weapon profile and
|
|
303
|
+
* is stored as a negative number (e.g. AP -1); more negative = more piercing.
|
|
304
|
+
*
|
|
305
|
+
* Two variants exist in the data:
|
|
306
|
+
* - **offensive** (`target` self/unit): the buffed unit's own weapons gain AP —
|
|
307
|
+
* an attacker-side `ap-mod`. `improve N` → `-N` (more piercing), `worsen N` →
|
|
308
|
+
* `+N`, and the legacy `add`/`subtract` forms (which already pass a signed,
|
|
309
|
+
* usually negative, value) flow through `apDelta` unchanged.
|
|
310
|
+
* - **defensive** (`target: "attacker"`): "enemy weapons targeting this unit
|
|
311
|
+
* have AP worsened". This applies when the buffed unit is the *target*; we do
|
|
312
|
+
* not model it as an attacker-side buff (that would wrongly weaken the buffed
|
|
313
|
+
* unit's own attacks), so it is surfaced as `unsupported`.
|
|
314
|
+
*/
|
|
315
|
+
function translateApModifier(node, modifier, opts, out, emit) {
|
|
316
|
+
if (classifyTarget(node) === "attacker") {
|
|
317
|
+
out.unsupported.push({
|
|
318
|
+
reason: "stat-modifier AP on the attacker: defender-side AP reduction is not modelled by the buff layer",
|
|
319
|
+
effectFragment: node,
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (opts.perspective !== "attacker" || !appliesToBuffedUnit(node, "attacker"))
|
|
324
|
+
return;
|
|
325
|
+
const delta = apDelta(modifier);
|
|
326
|
+
if (delta === null) {
|
|
327
|
+
out.unsupported.push({
|
|
328
|
+
reason: `stat-modifier AP: operation "${String(modifier.operation)}" not supported`,
|
|
329
|
+
effectFragment: node,
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
emit({ type: "ap-mod", value: delta });
|
|
334
|
+
}
|
|
280
335
|
function translateFeelNoPain(node, source, opts, out) {
|
|
281
336
|
// FNP applies when the buffed unit is the *target* — it ablates incoming
|
|
282
337
|
// damage. Under attacker perspective the FNP is irrelevant (the unit is
|
|
@@ -315,12 +370,15 @@ function translateKeywordGrant(node, source, opts, out) {
|
|
|
315
370
|
const modifier = node.modifier;
|
|
316
371
|
if (!isObject(modifier))
|
|
317
372
|
return;
|
|
318
|
-
|
|
319
|
-
|
|
373
|
+
// The DSL grants keywords in two shapes: a singular `keyword` string (often
|
|
374
|
+
// with a `weapon_type`) or a `keywords` array. Accept both.
|
|
375
|
+
const raws = keywordGrantList(modifier);
|
|
376
|
+
if (raws.length === 0)
|
|
320
377
|
return;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
378
|
+
// `weapon_type: melee|ranged` scopes the grant to that attack — a melee-only
|
|
379
|
+
// keyword shouldn't fire in the shooting phase. Express it as a phase gate.
|
|
380
|
+
const applicability = weaponTypeApplicability(modifier);
|
|
381
|
+
for (const raw of raws) {
|
|
324
382
|
const ref = parseKeywordGrant(raw);
|
|
325
383
|
if (!ref) {
|
|
326
384
|
out.unsupported.push({
|
|
@@ -329,8 +387,55 @@ function translateKeywordGrant(node, source, opts, out) {
|
|
|
329
387
|
});
|
|
330
388
|
continue;
|
|
331
389
|
}
|
|
332
|
-
|
|
390
|
+
const buff = { source, contribution: { type: "extra-keyword", keywordRef: ref } };
|
|
391
|
+
out.applied.push(applicability ? { ...buff, applicableWhen: applicability } : buff);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** Normalise a keyword-grant modifier's singular `keyword` and/or `keywords` array. */
|
|
395
|
+
function keywordGrantList(modifier) {
|
|
396
|
+
const out = [];
|
|
397
|
+
if (typeof modifier.keyword === "string")
|
|
398
|
+
out.push(modifier.keyword);
|
|
399
|
+
if (Array.isArray(modifier.keywords)) {
|
|
400
|
+
for (const k of modifier.keywords)
|
|
401
|
+
if (typeof k === "string")
|
|
402
|
+
out.push(k);
|
|
333
403
|
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
/** Map a keyword-grant's `weapon_type` to the phase its weapons fire in. */
|
|
407
|
+
function weaponTypeApplicability(modifier) {
|
|
408
|
+
if (modifier.weapon_type === "melee")
|
|
409
|
+
return { phases: ["fight"] };
|
|
410
|
+
if (modifier.weapon_type === "ranged")
|
|
411
|
+
return { phases: ["shooting"] };
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Map a stat-modifier's `attack_type` (or the equivalent `weapon_type`) to the
|
|
416
|
+
* phase that attack happens in. Both spellings carry the same melee/ranged
|
|
417
|
+
* intent; honoring `weapon_type` lets a "+1 A to melee weapons" mod phase-gate
|
|
418
|
+
* correctly instead of leaking into the shooting phase.
|
|
419
|
+
*/
|
|
420
|
+
function attackTypeApplicability(modifier) {
|
|
421
|
+
const kind = modifier.attack_type ?? modifier.weapon_type;
|
|
422
|
+
if (kind === "melee")
|
|
423
|
+
return { phases: ["fight"] };
|
|
424
|
+
if (kind === "ranged")
|
|
425
|
+
return { phases: ["shooting"] };
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Narrowing keys that scope a buff to a named weapon or a model subset the
|
|
430
|
+
* cruncher can't resolve at translation time (it has no weapon/model context
|
|
431
|
+
* here). When present on a damage-path leaf, applying the buff unfiltered would
|
|
432
|
+
* silently OVER-APPLY it, so we surface it as `unsupported` instead — the data
|
|
433
|
+
* stays faithful for other consumers; the optimizer just doesn't assume it.
|
|
434
|
+
* `weapon_type`/`attack_type` are NOT here — those map cleanly to a phase gate.
|
|
435
|
+
*/
|
|
436
|
+
const UNHONORABLE_NARROWING = ["weapon_name", "weapon_profile", "weapon_keyword", "weapon_filter", "model_filter", "model_scope"];
|
|
437
|
+
function unhonorableNarrowing(modifier) {
|
|
438
|
+
return UNHONORABLE_NARROWING.find((k) => modifier[k] != null);
|
|
334
439
|
}
|
|
335
440
|
function translateBsModifier(node, source, opts, out) {
|
|
336
441
|
// A bs-modifier on `target: "attacker"` is a defender-side rule: it
|
|
@@ -358,10 +463,18 @@ function translateConditional(node, source, opts, out) {
|
|
|
358
463
|
const negated = condition.negated === true;
|
|
359
464
|
const verdict = evaluateCondition(condition, opts.context);
|
|
360
465
|
if (verdict === "unknown") {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
466
|
+
// A timing the player controls (e.g. "start of phase") isn't a wall — it's
|
|
467
|
+
// an activation the player can opt into. Surface it as a lever rather than
|
|
468
|
+
// dropping it. Other unevaluatable conditions stay unsupported.
|
|
469
|
+
if (conditionMentionsTiming(condition)) {
|
|
470
|
+
enumerateTimingGate(node, source, opts, out);
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
out.unsupported.push({
|
|
474
|
+
reason: `conditional: cannot evaluate condition "${String(condition.type)}" against current context`,
|
|
475
|
+
effectFragment: node,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
365
478
|
return;
|
|
366
479
|
}
|
|
367
480
|
const active = negated ? !verdict : verdict;
|
|
@@ -370,6 +483,294 @@ function translateConditional(node, source, opts, out) {
|
|
|
370
483
|
walk(effect, source, opts, out);
|
|
371
484
|
}
|
|
372
485
|
// ---------------------------------------------------------------------------
|
|
486
|
+
// Activatable-lever enumeration
|
|
487
|
+
//
|
|
488
|
+
// Player-controlled gates — a `timing-is` the context can't pin down, each
|
|
489
|
+
// `dice-pool-allocation` option, each `choice` branch — aren't walls for a
|
|
490
|
+
// damage optimizer; they're the search space. Instead of dropping them to
|
|
491
|
+
// `unsupported`, we descend through them and surface every buff-bearing branch
|
|
492
|
+
// as an opt-in {@link ActivatableBuff}. The descent reuses the normal leaf
|
|
493
|
+
// translators (so a lever applies exactly what it advertises) and turns the
|
|
494
|
+
// conditions a branch still carries (target keyword, phase) into declarative
|
|
495
|
+
// `applicableWhen` so the resolver gates them per-target.
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
/** Emit one lever per `choice` branch that yields a buff (pick exactly one). */
|
|
498
|
+
function enumerateChoice(node, source, opts, out) {
|
|
499
|
+
const options = Array.isArray(node.options) ? node.options : [];
|
|
500
|
+
options.forEach((opt, i) => {
|
|
501
|
+
const buffs = [];
|
|
502
|
+
collectGatedBuffs(opt, source, opts, {}, buffs);
|
|
503
|
+
if (buffs.length === 0)
|
|
504
|
+
return;
|
|
505
|
+
out.activatable.push({
|
|
506
|
+
id: `${opts.abilityId}?${i}`,
|
|
507
|
+
label: labelForBuffs(buffs),
|
|
508
|
+
buffs,
|
|
509
|
+
group: { id: `${opts.abilityId}?choice`, maxActivations: 1 },
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
/** Emit one lever per buff-bearing dice-pool option, capped by `max_activations`. */
|
|
514
|
+
function enumerateDicePool(node, source, opts, out) {
|
|
515
|
+
const options = Array.isArray(node.options) ? node.options : [];
|
|
516
|
+
const maxActivations = typeof node.max_activations === "number" ? node.max_activations : options.length;
|
|
517
|
+
for (const opt of options) {
|
|
518
|
+
if (!isObject(opt))
|
|
519
|
+
continue;
|
|
520
|
+
const buffs = [];
|
|
521
|
+
collectGatedBuffs(opt.effect, source, opts, {}, buffs);
|
|
522
|
+
if (buffs.length === 0)
|
|
523
|
+
continue;
|
|
524
|
+
const name = typeof opt.name === "string" && opt.name ? opt.name : labelForBuffs(buffs);
|
|
525
|
+
out.activatable.push({
|
|
526
|
+
id: `${opts.abilityId}#${name}`,
|
|
527
|
+
label: name,
|
|
528
|
+
buffs,
|
|
529
|
+
group: { id: opts.abilityId, maxActivations },
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Surface a timing-gated activation. The timing itself is just "when" — opting
|
|
535
|
+
* in satisfies it — so we descend into the body: an inner `dice-pool-allocation`
|
|
536
|
+
* or `choice` surfaces its *own* option levers (e.g. Blessings of Khorne's
|
|
537
|
+
* three keyword grants), while inner always-on buffs bundle into a single
|
|
538
|
+
* timing lever. A body with no modelable combat buff (a `resurrection` or
|
|
539
|
+
* `dice-gated`, like Berzerker Frenzy) yields nothing.
|
|
540
|
+
*/
|
|
541
|
+
function enumerateTimingGate(node, source, opts, out) {
|
|
542
|
+
const condition = node.condition;
|
|
543
|
+
if (!isObject(condition))
|
|
544
|
+
return;
|
|
545
|
+
const sub = { applied: [], unsupported: [], activatable: [] };
|
|
546
|
+
walk(node.effect, source, opts, sub);
|
|
547
|
+
// Inner independent decisions (dice-pool options, choice branches) pass
|
|
548
|
+
// straight through as their own levers.
|
|
549
|
+
out.activatable.push(...sub.activatable);
|
|
550
|
+
// Inner unconditional buffs become one lever gated only on the timing.
|
|
551
|
+
if (sub.applied.length > 0) {
|
|
552
|
+
const timing = extractTiming(condition) ?? "timing";
|
|
553
|
+
out.activatable.push({
|
|
554
|
+
id: `${opts.abilityId}@${timing}`,
|
|
555
|
+
label: labelForBuffs(sub.applied),
|
|
556
|
+
buffs: sub.applied,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Walk the body of a player gate, collecting the buffs it would contribute.
|
|
562
|
+
* Conditions are deferred to `applicableWhen` where expressible; nested
|
|
563
|
+
* decisions and stochastic rolls inside an activation are not modelled.
|
|
564
|
+
*/
|
|
565
|
+
function collectGatedBuffs(node, source, opts, applicability, outBuffs) {
|
|
566
|
+
if (!isObject(node))
|
|
567
|
+
return;
|
|
568
|
+
switch (node.type) {
|
|
569
|
+
case "conditional": {
|
|
570
|
+
const condition = node.condition;
|
|
571
|
+
if (!isObject(condition))
|
|
572
|
+
return;
|
|
573
|
+
const app = conditionToApplicability(condition);
|
|
574
|
+
if (app === "gate") {
|
|
575
|
+
// A nested timing gate: opting into the activation satisfies it, so
|
|
576
|
+
// keep descending without adding a constraint.
|
|
577
|
+
collectGatedBuffs(node.effect, source, opts, applicability, outBuffs);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (app === "context") {
|
|
581
|
+
// Can't express as a buff gate — fall back to the current context and
|
|
582
|
+
// only descend when the condition is definitely active.
|
|
583
|
+
if (evaluateCondition(condition, opts.context) === true) {
|
|
584
|
+
collectGatedBuffs(node.effect, source, opts, applicability, outBuffs);
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
collectGatedBuffs(node.effect, source, opts, combineApplicability(applicability, app), outBuffs);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
case "sequence":
|
|
592
|
+
for (const step of node.steps ?? []) {
|
|
593
|
+
collectGatedBuffs(step, source, opts, applicability, outBuffs);
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
case "choice":
|
|
597
|
+
case "dice-pool-allocation":
|
|
598
|
+
case "dice-gated":
|
|
599
|
+
// A decision (or stochastic roll) nested inside an activation. The outer
|
|
600
|
+
// lever already stands for a player choice; we don't model the inner one.
|
|
601
|
+
return;
|
|
602
|
+
default: {
|
|
603
|
+
// Leaf effect — run the normal leaf translators into a throwaway sink,
|
|
604
|
+
// then attach the accumulated applicability so target/phase gating
|
|
605
|
+
// defers to the resolver instead of vanishing the lever.
|
|
606
|
+
const tmp = { applied: [], unsupported: [], activatable: [] };
|
|
607
|
+
walk(node, source, opts, tmp);
|
|
608
|
+
for (const b of tmp.applied)
|
|
609
|
+
outBuffs.push(applyApplicability(b, applicability));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/** Does this condition (or any operand) gate on a player-controlled timing? */
|
|
615
|
+
function conditionMentionsTiming(condition) {
|
|
616
|
+
if (condition.type === "timing-is")
|
|
617
|
+
return true;
|
|
618
|
+
if (typeof condition.operator === "string" && Array.isArray(condition.operands)) {
|
|
619
|
+
return condition.operands.some((o) => isObject(o) && conditionMentionsTiming(o));
|
|
620
|
+
}
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
/** Pull the first `timing-is` timing value out of a (possibly compound) condition. */
|
|
624
|
+
function extractTiming(condition) {
|
|
625
|
+
if (condition.type === "timing-is") {
|
|
626
|
+
const t = condition.parameters?.timing;
|
|
627
|
+
return typeof t === "string" ? t : undefined;
|
|
628
|
+
}
|
|
629
|
+
if (Array.isArray(condition.operands)) {
|
|
630
|
+
for (const o of condition.operands) {
|
|
631
|
+
if (isObject(o)) {
|
|
632
|
+
const t = extractTiming(o);
|
|
633
|
+
if (t)
|
|
634
|
+
return t;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Translate a condition into a {@link BuffApplicability} the resolver can gate
|
|
642
|
+
* on. Returns `"gate"` for a player-controlled timing (satisfied by opting in),
|
|
643
|
+
* or `"context"` when the condition has no declarative buff representation and
|
|
644
|
+
* must fall back to context evaluation.
|
|
645
|
+
*/
|
|
646
|
+
function conditionToApplicability(condition) {
|
|
647
|
+
if (condition.negated === true)
|
|
648
|
+
return "context";
|
|
649
|
+
if (typeof condition.operator === "string" && Array.isArray(condition.operands)) {
|
|
650
|
+
if (condition.operator !== "and")
|
|
651
|
+
return "context";
|
|
652
|
+
let merged = {};
|
|
653
|
+
for (const operand of condition.operands) {
|
|
654
|
+
if (!isObject(operand))
|
|
655
|
+
return "context";
|
|
656
|
+
const a = conditionToApplicability(operand);
|
|
657
|
+
if (a === "gate")
|
|
658
|
+
continue; // timing operand: satisfied by opting in.
|
|
659
|
+
if (a === "context")
|
|
660
|
+
return "context";
|
|
661
|
+
merged = combineApplicability(merged, a);
|
|
662
|
+
}
|
|
663
|
+
return merged;
|
|
664
|
+
}
|
|
665
|
+
const params = condition.parameters;
|
|
666
|
+
switch (condition.type) {
|
|
667
|
+
case "timing-is":
|
|
668
|
+
return "gate";
|
|
669
|
+
case "phase-is": {
|
|
670
|
+
const phase = params?.phase;
|
|
671
|
+
return typeof phase === "string" ? { phases: [phase] } : "context";
|
|
672
|
+
}
|
|
673
|
+
case "target-has-keyword": {
|
|
674
|
+
const kw = params?.keyword;
|
|
675
|
+
return typeof kw === "string" ? { requiresTargetKeyword: kw } : "context";
|
|
676
|
+
}
|
|
677
|
+
case "unit-has-keyword": {
|
|
678
|
+
const kw = params?.keyword;
|
|
679
|
+
return typeof kw === "string" ? { requiresAttackerKeyword: kw } : "context";
|
|
680
|
+
}
|
|
681
|
+
case "attack-is-type": {
|
|
682
|
+
const t = params?.attack_type;
|
|
683
|
+
if (t === "melee")
|
|
684
|
+
return { phases: ["fight"] };
|
|
685
|
+
if (t === "ranged")
|
|
686
|
+
return { phases: ["shooting"] };
|
|
687
|
+
return "context";
|
|
688
|
+
}
|
|
689
|
+
default:
|
|
690
|
+
return "context";
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** Merge two applicabilities; `phases` intersect, the rest narrow. */
|
|
694
|
+
function combineApplicability(a, b) {
|
|
695
|
+
const out = { ...a };
|
|
696
|
+
if (b.phases) {
|
|
697
|
+
out.phases = a.phases ? a.phases.filter((p) => b.phases.includes(p)) : b.phases;
|
|
698
|
+
}
|
|
699
|
+
if (b.rollType)
|
|
700
|
+
out.rollType = b.rollType;
|
|
701
|
+
if (b.requiresTargetKeyword)
|
|
702
|
+
out.requiresTargetKeyword = b.requiresTargetKeyword;
|
|
703
|
+
if (b.requiresAttackerKeyword)
|
|
704
|
+
out.requiresAttackerKeyword = b.requiresAttackerKeyword;
|
|
705
|
+
return out;
|
|
706
|
+
}
|
|
707
|
+
/** Attach an accumulated applicability to a buff (no-op when empty). */
|
|
708
|
+
function applyApplicability(buff, applicability) {
|
|
709
|
+
if (Object.keys(applicability).length === 0)
|
|
710
|
+
return buff;
|
|
711
|
+
const merged = buff.applicableWhen
|
|
712
|
+
? combineApplicability(buff.applicableWhen, applicability)
|
|
713
|
+
: applicability;
|
|
714
|
+
return { ...buff, applicableWhen: merged };
|
|
715
|
+
}
|
|
716
|
+
/** A short, deduped human label summarising a lever's contributions. */
|
|
717
|
+
function labelForBuffs(buffs) {
|
|
718
|
+
const seen = new Set();
|
|
719
|
+
const parts = [];
|
|
720
|
+
for (const b of buffs) {
|
|
721
|
+
const p = describeContribution(b.contribution);
|
|
722
|
+
if (!seen.has(p)) {
|
|
723
|
+
seen.add(p);
|
|
724
|
+
parts.push(p);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return parts.join(", ") || "buff";
|
|
728
|
+
}
|
|
729
|
+
function describeContribution(c) {
|
|
730
|
+
switch (c.type) {
|
|
731
|
+
case "extra-keyword":
|
|
732
|
+
return keywordLabel(c.keywordRef);
|
|
733
|
+
case "hit-mod":
|
|
734
|
+
return `${signed(c.value)} to hit`;
|
|
735
|
+
case "wound-mod":
|
|
736
|
+
return `${signed(c.value)} to wound`;
|
|
737
|
+
case "save-mod":
|
|
738
|
+
return `${signed(c.value)} to save`;
|
|
739
|
+
case "damage-mod":
|
|
740
|
+
return `${signed(c.value)} damage`;
|
|
741
|
+
case "attacks-mod":
|
|
742
|
+
return `${signed(c.value)} attacks`;
|
|
743
|
+
case "strength-mod":
|
|
744
|
+
return `${signed(c.value)} strength`;
|
|
745
|
+
case "toughness-mod":
|
|
746
|
+
return `${signed(c.value)} toughness`;
|
|
747
|
+
case "ap-mod":
|
|
748
|
+
return `AP ${c.value}`;
|
|
749
|
+
case "reroll":
|
|
750
|
+
return `re-roll ${c.roll}${c.subset === "ones" ? " 1s" : ""}`;
|
|
751
|
+
case "feel-no-pain":
|
|
752
|
+
return `feel no pain ${c.threshold}+`;
|
|
753
|
+
case "cover":
|
|
754
|
+
return "cover";
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function signed(n) {
|
|
758
|
+
return n >= 0 ? `+${n}` : `${n}`;
|
|
759
|
+
}
|
|
760
|
+
/** Render a weapon-keyword ref back to its printed form (best-effort). */
|
|
761
|
+
function keywordLabel(ref) {
|
|
762
|
+
const params = ref.parameters ?? {};
|
|
763
|
+
if (ref.keyword_id === "anti" && typeof params.target_keyword === "string") {
|
|
764
|
+
const th = params.threshold;
|
|
765
|
+
return `Anti-${params.target_keyword}${typeof th === "number" ? ` ${th}+` : ""}`;
|
|
766
|
+
}
|
|
767
|
+
const base = ref.keyword_id
|
|
768
|
+
.split("-")
|
|
769
|
+
.map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w))
|
|
770
|
+
.join(" ");
|
|
771
|
+
return typeof params.value === "number" ? `${base} ${params.value}` : base;
|
|
772
|
+
}
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
373
774
|
// Condition evaluator
|
|
374
775
|
// ---------------------------------------------------------------------------
|
|
375
776
|
function evaluateCondition(condition, ctx) {
|
|
@@ -398,6 +799,13 @@ function evaluateCondition(condition, ctx) {
|
|
|
398
799
|
}
|
|
399
800
|
case "remained-stationary":
|
|
400
801
|
return ctx.attackerStationary === true;
|
|
802
|
+
case "charged-this-turn":
|
|
803
|
+
// A player-controlled context flag (did the buffed unit charge this turn?),
|
|
804
|
+
// mirroring `remained-stationary`. Undefined → the caller couldn't pin it
|
|
805
|
+
// down, so stay "unknown" and let the SPA surface the gap.
|
|
806
|
+
if (ctx.attackerCharged === undefined)
|
|
807
|
+
return "unknown";
|
|
808
|
+
return ctx.attackerCharged;
|
|
401
809
|
case "target-has-keyword": {
|
|
402
810
|
const kw = condition.parameters?.keyword;
|
|
403
811
|
if (typeof kw !== "string")
|
|
@@ -411,9 +819,14 @@ function evaluateCondition(condition, ctx) {
|
|
|
411
819
|
return (ctx.attackerKeywords ?? []).includes(kw.toLowerCase());
|
|
412
820
|
}
|
|
413
821
|
case "is-attached":
|
|
414
|
-
|
|
415
|
-
//
|
|
416
|
-
|
|
822
|
+
case "model-is-leader":
|
|
823
|
+
// True whenever the buffed unit is a combined ("attached") unit. We do
|
|
824
|
+
// not thread per-member leader identity — "attachment present" is the
|
|
825
|
+
// signal both conditions gate on. Undefined flag (caller couldn't
|
|
826
|
+
// determine attachment) stays "unknown" so the SPA surfaces the gap.
|
|
827
|
+
if (ctx.attackerAttached === undefined)
|
|
828
|
+
return "unknown";
|
|
829
|
+
return ctx.attackerAttached;
|
|
417
830
|
default:
|
|
418
831
|
return "unknown";
|
|
419
832
|
}
|
|
@@ -471,6 +884,38 @@ function signedValue(modifier) {
|
|
|
471
884
|
if (!Number.isFinite(value))
|
|
472
885
|
return null;
|
|
473
886
|
switch (modifier.operation) {
|
|
887
|
+
case "add":
|
|
888
|
+
return value;
|
|
889
|
+
case "subtract":
|
|
890
|
+
return -value;
|
|
891
|
+
// For the symmetric stats (A/S/T) and roll-/bs-modifiers, "improve" moves
|
|
892
|
+
// the number up (beneficial) and "worsen" down. AP is handled separately by
|
|
893
|
+
// `apDelta`, which inverts this because AP's beneficial direction is more
|
|
894
|
+
// negative.
|
|
895
|
+
case "improve":
|
|
896
|
+
return value;
|
|
897
|
+
case "worsen":
|
|
898
|
+
return -value;
|
|
899
|
+
default:
|
|
900
|
+
// set / halve / multiply: not a single signed delta — left unsupported.
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Read the AP delta out of a stat-modifier `{operation, value}` pair. AP is
|
|
906
|
+
* stored negative (more negative = more piercing), so "improve" makes it more
|
|
907
|
+
* negative and "worsen" less. The legacy `add`/`subtract` forms pass a signed
|
|
908
|
+
* value through directly (the data already encodes the sign).
|
|
909
|
+
*/
|
|
910
|
+
function apDelta(modifier) {
|
|
911
|
+
const value = Number(modifier.value);
|
|
912
|
+
if (!Number.isFinite(value))
|
|
913
|
+
return null;
|
|
914
|
+
switch (modifier.operation) {
|
|
915
|
+
case "improve":
|
|
916
|
+
return -Math.abs(value);
|
|
917
|
+
case "worsen":
|
|
918
|
+
return Math.abs(value);
|
|
474
919
|
case "add":
|
|
475
920
|
return value;
|
|
476
921
|
case "subtract":
|