@alpaca-software/40kdc-data 0.5.14 → 0.5.15
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/abilities-resolver/resolver.d.ts.map +1 -1
- package/dist/abilities-resolver/resolver.js +6 -2
- package/dist/abilities-resolver/resolver.js.map +1 -1
- package/dist/audit-coverage.d.ts.map +1 -1
- package/dist/audit-coverage.js +90 -1
- package/dist/audit-coverage.js.map +1 -1
- package/dist/audit-phrasing.d.ts +64 -0
- package/dist/audit-phrasing.d.ts.map +1 -0
- package/dist/audit-phrasing.js +208 -0
- package/dist/audit-phrasing.js.map +1 -0
- package/dist/build-share-registry.d.ts +2 -0
- package/dist/build-share-registry.d.ts.map +1 -0
- package/dist/build-share-registry.js +115 -0
- package/dist/build-share-registry.js.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/codegen-data.js +23 -0
- package/dist/codegen-data.js.map +1 -1
- package/dist/commands/translate.d.ts.map +1 -1
- package/dist/commands/translate.js +2 -5
- package/dist/commands/translate.js.map +1 -1
- package/dist/commands/validate-all.d.ts.map +1 -1
- package/dist/commands/validate-all.js +8 -3
- package/dist/commands/validate-all.js.map +1 -1
- package/dist/data/bundle.generated.js +1 -1
- package/dist/data/bundle.generated.js.map +1 -1
- package/dist/data/dataset.d.ts +33 -1
- package/dist/data/dataset.d.ts.map +1 -1
- package/dist/data/dataset.js +107 -1
- package/dist/data/dataset.js.map +1 -1
- package/dist/data/index.d.ts +4 -0
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +4 -0
- package/dist/data/index.js.map +1 -1
- package/dist/data/types.d.ts +3 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/types.js +1 -0
- package/dist/data/types.js.map +1 -1
- package/dist/gen-conformance.js +162 -0
- package/dist/gen-conformance.js.map +1 -1
- package/dist/generated.d.ts +125 -0
- package/dist/generated.d.ts.map +1 -1
- package/dist/generated.js.map +1 -1
- package/dist/integrity.d.ts +32 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +120 -0
- package/dist/integrity.js.map +1 -0
- package/dist/report.d.ts +1 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +2 -2
- package/dist/report.js.map +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +42 -0
- package/dist/runner.js.map +1 -1
- package/dist/share/codec.d.ts +83 -0
- package/dist/share/codec.d.ts.map +1 -0
- package/dist/share/codec.js +230 -0
- package/dist/share/codec.js.map +1 -0
- package/dist/share/index.d.ts +30 -0
- package/dist/share/index.d.ts.map +1 -0
- package/dist/share/index.js +36 -0
- package/dist/share/index.js.map +1 -0
- package/dist/share/registry.d.ts +42 -0
- package/dist/share/registry.d.ts.map +1 -0
- package/dist/share/registry.generated.d.ts +4 -0
- package/dist/share/registry.generated.d.ts.map +1 -0
- package/dist/share/registry.generated.js +4 -0
- package/dist/share/registry.generated.js.map +1 -0
- package/dist/share/registry.js +68 -0
- package/dist/share/registry.js.map +1 -0
- package/dist/translate/condition.d.ts +1 -0
- package/dist/translate/condition.d.ts.map +1 -1
- package/dist/translate/condition.js +101 -7
- package/dist/translate/condition.js.map +1 -1
- package/dist/translate/effect.d.ts +28 -20
- package/dist/translate/effect.d.ts.map +1 -1
- package/dist/translate/effect.js +483 -114
- package/dist/translate/effect.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +1 -0
- package/dist/validate.js.map +1 -1
- package/package.json +3 -1
- package/schemas/core/allied-rule.schema.json +115 -0
- package/schemas/core/detachment.schema.json +38 -1
package/dist/translate/effect.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Humanize an Ability-DSL `effect` tree into
|
|
2
|
+
* Humanize an Ability-DSL `effect` tree into natural English — the
|
|
3
3
|
* `ability.print()` of the dataset. Output is an *approximation* generated
|
|
4
|
-
* purely from the structured data (no external rules text)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* purely from the structured data (no external rules text): subject-first,
|
|
5
|
+
* GW-datasheet voice, with scope range + duration woven into the sentence and
|
|
6
|
+
* single-leaf conditionals inlined. ASCII-only. It is pinned byte-for-byte
|
|
7
|
+
* across the TS / Rust / Python ports by the `conformance/effect-translation`
|
|
8
|
+
* corpus, so any phrasing change here is a semantic corpus change (bump
|
|
9
|
+
* `conformance/SPEC_VERSION`).
|
|
8
10
|
*
|
|
9
|
-
* Container nodes (`sequence`, `
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (`[the-type]`) rather than failing — coverage improves as authoring does.
|
|
11
|
+
* Container nodes (`sequence`, `choice`, `dice-gated`, `dice-pool-allocation`,
|
|
12
|
+
* and a `conditional` wrapping a container) render block-style with two-space
|
|
13
|
+
* indentation; a `conditional` wrapping a single leaf inlines to one sentence.
|
|
14
|
+
* Unknown leaf types degrade to a deterministic bracketed form (`[the-type]`).
|
|
14
15
|
*/
|
|
15
|
-
import { describeCondition, dekebab } from "./condition.js";
|
|
16
|
+
import { describeCondition, describeTiming, dekebab } from "./condition.js";
|
|
17
|
+
const CONTAINER_TYPES = new Set([
|
|
18
|
+
"sequence",
|
|
19
|
+
"choice",
|
|
20
|
+
"dice-gated",
|
|
21
|
+
"dice-pool-allocation",
|
|
22
|
+
]);
|
|
16
23
|
/** JS-template stringification (numbers print without trailing `.0`). */
|
|
17
24
|
function jstr(v) {
|
|
18
25
|
if (v == null)
|
|
@@ -21,20 +28,119 @@ function jstr(v) {
|
|
|
21
28
|
return v.map(jstr).join(", ");
|
|
22
29
|
return String(v);
|
|
23
30
|
}
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
/** Uppercase the first character (idempotent; leaves the rest untouched). */
|
|
32
|
+
function capitalize(s) {
|
|
33
|
+
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
|
|
26
34
|
}
|
|
35
|
+
/** Small words kept lowercase mid-phrase in Title Case (`Benefit of Cover`, not `Benefit Of Cover`). */
|
|
36
|
+
const TITLE_SMALL = new Set(["of", "or", "and", "the", "a", "an", "to", "in", "on", "for", "with"]);
|
|
37
|
+
/** kebab/space token → Title Case (`deep-strike` → `Deep Strike`, `shoot-and-scoot` → `Shoot and Scoot`). */
|
|
38
|
+
function titleCase(s) {
|
|
39
|
+
return dekebab(s)
|
|
40
|
+
.split(" ")
|
|
41
|
+
.map((w, i) => {
|
|
42
|
+
if (w.length === 0)
|
|
43
|
+
return w;
|
|
44
|
+
if (i > 0 && TITLE_SMALL.has(w.toLowerCase()))
|
|
45
|
+
return w.toLowerCase();
|
|
46
|
+
return w[0].toUpperCase() + w.slice(1);
|
|
47
|
+
})
|
|
48
|
+
.join(" ");
|
|
49
|
+
}
|
|
50
|
+
/** A GW weapon keyword token → bracketed caps (`lethal-hits` → `[LETHAL HITS]`). */
|
|
51
|
+
function bracketKeyword(k) {
|
|
52
|
+
return `[${dekebab(jstr(k)).toUpperCase()}]`;
|
|
53
|
+
}
|
|
54
|
+
/** Dice tokens print with a capital `D` (`d3` → `D3`, `2d6` → `2D6`). */
|
|
55
|
+
function diceCase(v) {
|
|
56
|
+
return jstr(v).replace(/d/gi, "D");
|
|
57
|
+
}
|
|
58
|
+
/** A leadership/escape test token → GW name (`battle-shock` → `Battle-shock`). */
|
|
59
|
+
const TEST_NAMES = {
|
|
60
|
+
"battle-shock": "Battle-shock",
|
|
61
|
+
"desperate-escape": "Desperate Escape",
|
|
62
|
+
};
|
|
63
|
+
function testName(test) {
|
|
64
|
+
const t = jstr(test);
|
|
65
|
+
return TEST_NAMES[t] ?? titleCase(t);
|
|
66
|
+
}
|
|
67
|
+
/** Does a subject noun phrase take a plural verb? (`enemy units within 6"`, `all friendly units`). */
|
|
68
|
+
function isPlural(subj) {
|
|
69
|
+
return / units\b/.test(subj) || /^all /.test(subj) || /^(enemy|friendly) units/.test(subj);
|
|
70
|
+
}
|
|
71
|
+
/** Subject-verb agreement: pick the plural form of a present-tense verb when the subject is plural. */
|
|
72
|
+
const PLURAL_VERBS = {
|
|
73
|
+
has: "have",
|
|
74
|
+
is: "are",
|
|
75
|
+
gets: "get",
|
|
76
|
+
gains: "gain",
|
|
77
|
+
suffers: "suffer",
|
|
78
|
+
retains: "retain",
|
|
79
|
+
makes: "make",
|
|
80
|
+
};
|
|
81
|
+
function v(subj, singular) {
|
|
82
|
+
if (!isPlural(subj))
|
|
83
|
+
return singular;
|
|
84
|
+
return PLURAL_VERBS[singular] ?? singular.replace(/s$/, "");
|
|
85
|
+
}
|
|
86
|
+
/** Full characteristic name for a stat token (`Sv` → `Save`). */
|
|
87
|
+
const STAT_NAMES = {
|
|
88
|
+
M: "Move",
|
|
89
|
+
T: "Toughness",
|
|
90
|
+
Sv: "Save",
|
|
91
|
+
W: "Wounds",
|
|
92
|
+
A: "Attacks",
|
|
93
|
+
Ld: "Leadership",
|
|
94
|
+
OC: "Objective Control",
|
|
95
|
+
S: "Strength",
|
|
96
|
+
WS: "Weapon Skill",
|
|
97
|
+
BS: "Ballistic Skill",
|
|
98
|
+
AP: "Armour Penetration",
|
|
99
|
+
D: "Damage",
|
|
100
|
+
Range: "Range",
|
|
101
|
+
};
|
|
102
|
+
function statName(stat) {
|
|
103
|
+
const s = jstr(stat);
|
|
104
|
+
return STAT_NAMES[s] ?? titleCase(s);
|
|
105
|
+
}
|
|
106
|
+
/** Resource-pool token → display name (`cp` → `CP`, otherwise Title Case). */
|
|
107
|
+
function poolName(pool) {
|
|
108
|
+
const p = jstr(pool);
|
|
109
|
+
return p.toLowerCase() === "cp" ? "CP" : titleCase(p);
|
|
110
|
+
}
|
|
111
|
+
/** Roll noun for a roll token (`hit` → `Hit`, `attacks-characteristic` → `Attacks characteristic`). */
|
|
112
|
+
const ROLL_NAMES = {
|
|
113
|
+
hit: "Hit",
|
|
114
|
+
wound: "Wound",
|
|
115
|
+
charge: "Charge",
|
|
116
|
+
damage: "Damage",
|
|
117
|
+
advance: "Advance",
|
|
118
|
+
save: "Saving throw",
|
|
119
|
+
leadership: "Leadership",
|
|
120
|
+
};
|
|
121
|
+
function rollName(roll) {
|
|
122
|
+
const r = jstr(roll);
|
|
123
|
+
return ROLL_NAMES[r] ?? titleCase(r);
|
|
124
|
+
}
|
|
125
|
+
/** `+1` / `-1` from an operation + value (a negative value flips the sign, so never `+-1`). */
|
|
27
126
|
function signed(operation, value) {
|
|
28
|
-
const
|
|
29
|
-
|
|
127
|
+
const positive = operation === "add" || operation === "improve";
|
|
128
|
+
let sign = positive ? 1 : -1;
|
|
129
|
+
const n = Number(value);
|
|
130
|
+
if (!Number.isNaN(n) && n < 0) {
|
|
131
|
+
sign = -sign;
|
|
132
|
+
value = Math.abs(n);
|
|
133
|
+
}
|
|
134
|
+
return `${sign > 0 ? "+" : "-"}${jstr(value)}`;
|
|
30
135
|
}
|
|
136
|
+
/** Dice comparison → "a 4+", "a 3 or less", etc. (for dice-gated thresholds). */
|
|
31
137
|
function formatComparison(comp, threshold) {
|
|
32
138
|
const th = jstr(threshold);
|
|
33
139
|
switch (comp) {
|
|
34
140
|
case "gte":
|
|
35
|
-
return
|
|
141
|
+
return `a ${th}+`;
|
|
36
142
|
case "lte":
|
|
37
|
-
return
|
|
143
|
+
return `a ${th} or less`;
|
|
38
144
|
case "gt":
|
|
39
145
|
return `greater than ${th}`;
|
|
40
146
|
case "lt":
|
|
@@ -42,141 +148,338 @@ function formatComparison(comp, threshold) {
|
|
|
42
148
|
case "eq":
|
|
43
149
|
return `exactly ${th}`;
|
|
44
150
|
default:
|
|
45
|
-
return
|
|
151
|
+
return `a ${th}+`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Humanized subject for an effect `target`. Aura targets resolve their radius
|
|
156
|
+
* from the ability scope (threaded via {@link Ctx}); everything else is a fixed
|
|
157
|
+
* noun phrase in GW datasheet voice.
|
|
158
|
+
*/
|
|
159
|
+
function subject(target, ctx) {
|
|
160
|
+
const within = ctx.rangeInches != null ? ` within ${jstr(ctx.rangeInches)}"` : " nearby";
|
|
161
|
+
switch (target) {
|
|
162
|
+
case "self":
|
|
163
|
+
case "bearer":
|
|
164
|
+
return "this model";
|
|
165
|
+
case "unit":
|
|
166
|
+
return "the unit";
|
|
167
|
+
case "attached-unit":
|
|
168
|
+
return "the unit this model leads";
|
|
169
|
+
case "target":
|
|
170
|
+
return "the target";
|
|
171
|
+
case "attacker":
|
|
172
|
+
return "the attacking unit";
|
|
173
|
+
case "defender":
|
|
174
|
+
return "your unit";
|
|
175
|
+
case "all-friendly":
|
|
176
|
+
return "all friendly units";
|
|
177
|
+
case "all-enemy":
|
|
178
|
+
return "all enemy units";
|
|
179
|
+
case "friendly-within-aura":
|
|
180
|
+
return `friendly units${within}`;
|
|
181
|
+
case "enemy-within-aura":
|
|
182
|
+
return `enemy units${within}`;
|
|
183
|
+
default:
|
|
184
|
+
return "the unit";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Possessive form of a subject noun phrase (`the unit` → `the unit's`). */
|
|
188
|
+
function possessive(s) {
|
|
189
|
+
return s.endsWith("s") ? `${s}'` : `${s}'s`;
|
|
190
|
+
}
|
|
191
|
+
/** Possessive pronoun agreeing with the subject (`its` / `their`). */
|
|
192
|
+
function pronoun(subj) {
|
|
193
|
+
return isPlural(subj) ? "their" : "its";
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Duration → woven clause. `lead` sits at the very front of the sentence
|
|
197
|
+
* ("Once per battle, …"); `trail` sits after the trigger/condition and before
|
|
198
|
+
* the effect ("…, until the end of the phase, …"). `permanent` adds nothing.
|
|
199
|
+
*/
|
|
200
|
+
function durationClauses(duration) {
|
|
201
|
+
switch (duration) {
|
|
202
|
+
case "phase":
|
|
203
|
+
return { lead: "", trail: "until the end of the phase" };
|
|
204
|
+
case "turn":
|
|
205
|
+
return { lead: "", trail: "until the end of the turn" };
|
|
206
|
+
case "battle":
|
|
207
|
+
return { lead: "", trail: "for the rest of the battle" };
|
|
208
|
+
case "battle-round":
|
|
209
|
+
return { lead: "", trail: "until the end of the battle round" };
|
|
210
|
+
case "until-next-command-phase":
|
|
211
|
+
return { lead: "", trail: "until your next Command phase" };
|
|
212
|
+
case "one-use":
|
|
213
|
+
return { lead: "once per battle", trail: "" };
|
|
214
|
+
default: // permanent / absent
|
|
215
|
+
return { lead: "", trail: "" };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A condition rendered as a natural lead-in clause (lowercase-initial — the
|
|
220
|
+
* caller capitalizes at the sentence boundary). Falls back to `if <condition>`
|
|
221
|
+
* for shapes without a dedicated framing.
|
|
222
|
+
*/
|
|
223
|
+
function conditionLeadIn(c) {
|
|
224
|
+
// Compound nodes recurse so each part reads in its natural framing.
|
|
225
|
+
if (c.operator === "and" && c.operands)
|
|
226
|
+
return c.operands.map(conditionLeadIn).join(", ");
|
|
227
|
+
if (c.operator === "or" && c.operands)
|
|
228
|
+
return c.operands.map(conditionLeadIn).join(" or ");
|
|
229
|
+
if (c.operator === "not" && c.operands)
|
|
230
|
+
return `unless ${c.operands.map((o) => conditionLeadIn(o).replace(/^if /, "")).join(" or ")}`;
|
|
231
|
+
if (c.negated)
|
|
232
|
+
return `if ${describeCondition(c)}`;
|
|
233
|
+
const p = c.parameters ?? {};
|
|
234
|
+
switch (c.type) {
|
|
235
|
+
case "phase-is":
|
|
236
|
+
return `during the ${titleCase(jstr(p.phase))} phase`;
|
|
237
|
+
case "is-attached":
|
|
238
|
+
return `after being attached to a ${p.keyword ? `${jstr(p.keyword)} ` : ""}unit`;
|
|
239
|
+
case "timing-is":
|
|
240
|
+
return describeTiming(p.timing);
|
|
241
|
+
case "player-turn-is":
|
|
242
|
+
return p.turn === "your-turn"
|
|
243
|
+
? "in your turn"
|
|
244
|
+
: p.turn === "opponent-turn"
|
|
245
|
+
? "in the opponent's turn"
|
|
246
|
+
: "in either player's turn";
|
|
247
|
+
case "model-is-leader":
|
|
248
|
+
return "while this model leads a unit";
|
|
249
|
+
case "charged-this-turn":
|
|
250
|
+
return "if the unit charged this turn";
|
|
251
|
+
case "advanced-this-turn":
|
|
252
|
+
return "if the unit Advanced this turn";
|
|
253
|
+
case "remained-stationary":
|
|
254
|
+
return "if the unit Remained Stationary";
|
|
255
|
+
case "target-has-keyword":
|
|
256
|
+
return `against ${jstr(p.keyword)} targets`;
|
|
257
|
+
case "unit-has-keyword":
|
|
258
|
+
return `if the unit has the ${jstr(p.keyword)} keyword`;
|
|
259
|
+
case "is-battle-shocked":
|
|
260
|
+
return "while the unit is Battle-shocked";
|
|
261
|
+
case "unit-below-half-strength":
|
|
262
|
+
return "while the unit is below half strength";
|
|
263
|
+
case "unit-below-starting-strength":
|
|
264
|
+
return "while the unit is below its starting strength";
|
|
265
|
+
case "has-lost-wounds":
|
|
266
|
+
return "while the model has lost wounds";
|
|
267
|
+
case "attack-is-type":
|
|
268
|
+
if (p.comparison === "strength-greater-than-toughness")
|
|
269
|
+
return "when this attack's Strength is greater than the target's Toughness";
|
|
270
|
+
if (p.comparison != null)
|
|
271
|
+
return `when ${dekebab(jstr(p.comparison))}`;
|
|
272
|
+
return `with ${jstr(p.attack_type)} attacks`;
|
|
273
|
+
case "destroyed-by-attack-type":
|
|
274
|
+
return `when destroyed by a ${jstr(p.attack_type)} attack`;
|
|
275
|
+
case "opponent-unit-within-range": {
|
|
276
|
+
let where;
|
|
277
|
+
if (p.weapon_name != null)
|
|
278
|
+
where = `range of ${dekebab(jstr(p.weapon_name))}`;
|
|
279
|
+
else if (p.range_multiplier != null)
|
|
280
|
+
where = "half range of its ranged weapons";
|
|
281
|
+
else
|
|
282
|
+
where = p.range === "engagement" ? "engagement range" : `${jstr(p.range)}"`;
|
|
283
|
+
return `while an enemy unit is within ${where}`;
|
|
284
|
+
}
|
|
285
|
+
default:
|
|
286
|
+
return `if ${describeCondition(c)}`;
|
|
46
287
|
}
|
|
47
288
|
}
|
|
48
|
-
/** Single-clause translation for leaf effects (
|
|
49
|
-
export function describeEffectInline(e) {
|
|
289
|
+
/** Single-clause translation for leaf effects (lowercase-initial, no period). */
|
|
290
|
+
export function describeEffectInline(e, ctx = {}) {
|
|
50
291
|
const m = e.modifier ?? {};
|
|
51
|
-
const
|
|
292
|
+
const subj = subject(e.target, ctx);
|
|
52
293
|
switch (e.type) {
|
|
53
294
|
case "stat-modifier": {
|
|
54
295
|
const scope = m.attack_type ? ` (${jstr(m.attack_type)})` : "";
|
|
55
296
|
if (m.stat == null)
|
|
56
|
-
return `modify
|
|
297
|
+
return `modify ${possessive(subj)} characteristics${scope}`;
|
|
57
298
|
if (m.operation === "set")
|
|
58
|
-
return `
|
|
59
|
-
|
|
299
|
+
return `modify ${possessive(subj)} ${statName(m.stat)} characteristic to ${jstr(m.value)}${scope}`;
|
|
300
|
+
let val = m.value;
|
|
301
|
+
let verb = m.operation === "subtract" || m.operation === "worsen" ? "subtract" : "add";
|
|
302
|
+
const n = Number(val); // a negative value flips the verb so we never say "add -1"
|
|
303
|
+
if (!Number.isNaN(n) && n < 0) {
|
|
304
|
+
verb = verb === "add" ? "subtract" : "add";
|
|
305
|
+
val = Math.abs(n);
|
|
306
|
+
}
|
|
307
|
+
const prep = verb === "add" ? "to" : "from";
|
|
308
|
+
return `${verb} ${jstr(val)} ${prep} ${possessive(subj)} ${statName(m.stat)} characteristic${scope}`;
|
|
60
309
|
}
|
|
61
310
|
case "roll-modifier": {
|
|
62
|
-
const
|
|
311
|
+
const ctxNote = m.context ? ` (${jstr(m.context)})` : "";
|
|
312
|
+
if (m.critical_on != null) {
|
|
313
|
+
const crit = m.roll === "wound" ? "Critical Wounds" : "Critical Hits";
|
|
314
|
+
return `${subj} ${v(subj, "scores")} ${crit} on ${rollName(m.roll)} rolls of ${jstr(m.critical_on)}+`;
|
|
315
|
+
}
|
|
63
316
|
if (m.value == null)
|
|
64
|
-
return `${dekebab(jstr(m.operation))} ${
|
|
65
|
-
return `${signed(m.operation, m.value)} to ${
|
|
317
|
+
return `${dekebab(jstr(m.operation))} ${possessive(subj)} ${rollName(m.roll)} rolls${ctxNote}`;
|
|
318
|
+
return `${subj} ${v(subj, "gets")} ${signed(m.operation, m.value)} to ${rollName(m.roll)} rolls${ctxNote}`;
|
|
66
319
|
}
|
|
67
320
|
case "re-roll": {
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
return `re-roll ${
|
|
321
|
+
const noun = rollName(m.roll);
|
|
322
|
+
const which = m.subset === "ones" ? `a ${noun} roll of 1` : `the ${noun} roll`;
|
|
323
|
+
return `you can re-roll ${which}`;
|
|
71
324
|
}
|
|
72
325
|
case "mortal-wounds": {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
326
|
+
const range = m.range ?? m.range_inches ?? ctx.rangeInches;
|
|
327
|
+
const subjMW = e.target === "enemy-within-aura" && range != null
|
|
328
|
+
? `each enemy unit within ${jstr(range)}"`
|
|
329
|
+
: subj;
|
|
330
|
+
const verb = subjMW.startsWith("each ") ? "suffers" : v(subjMW, "suffers");
|
|
331
|
+
const a = m.count != null
|
|
332
|
+
? jstr(m.count)
|
|
333
|
+
: m.amount != null
|
|
334
|
+
? jstr(m.amount)
|
|
335
|
+
: m.dice != null
|
|
336
|
+
? diceCase(m.dice)
|
|
337
|
+
: m.table || m.amount_table
|
|
338
|
+
? "a number of"
|
|
339
|
+
: null;
|
|
340
|
+
// Deadly-Demise-style triggers carry no count here — the amount is the
|
|
341
|
+
// model's Deadly Demise rating, so describe the trigger instead of "?".
|
|
342
|
+
if (a == null && m.trigger != null)
|
|
343
|
+
return `when this model is destroyed, ${subjMW} ${verb} mortal wounds (${titleCase(jstr(m.trigger))})`;
|
|
344
|
+
const amt = a ?? "?";
|
|
345
|
+
const noun = amt === "1" ? "mortal wound" : "mortal wounds";
|
|
346
|
+
return `${subjMW} ${verb} ${amt} ${noun}`;
|
|
347
|
+
}
|
|
348
|
+
case "feel-no-pain": {
|
|
349
|
+
const vs = m.scope === "mortal" ? " against mortal wounds" : "";
|
|
350
|
+
return `${subj} ${v(subj, "has")} the Feel No Pain ${jstr(m.threshold)}+ ability${vs}`;
|
|
77
351
|
}
|
|
78
|
-
case "feel-no-pain":
|
|
79
|
-
return `${target} gains Feel No Pain ${jstr(m.threshold)}+`;
|
|
80
352
|
case "ward":
|
|
81
|
-
return `${
|
|
353
|
+
return `${subj} ${v(subj, "has")} the Ward ${jstr(m.threshold ?? m.value)}+ ability`;
|
|
82
354
|
case "invulnerable-save":
|
|
83
|
-
return `${
|
|
355
|
+
return `${subj} ${v(subj, "has")} a ${jstr(m.invuln_sv ?? m.value ?? m.threshold)}+ invulnerable save`;
|
|
84
356
|
case "keyword-grant": {
|
|
85
|
-
const kw = Array.isArray(m.keywords)
|
|
357
|
+
const kw = Array.isArray(m.keywords)
|
|
358
|
+
? m.keywords.map(bracketKeyword).join(" and ")
|
|
359
|
+
: bracketKeyword(m.keyword ?? "keywords");
|
|
86
360
|
if (m.weapon_name != null)
|
|
87
|
-
return `${
|
|
361
|
+
return `${possessive(subj)} ${jstr(m.weapon_name)} gains ${kw}`;
|
|
88
362
|
if (m.weapon_type != null)
|
|
89
|
-
return `${
|
|
90
|
-
return `${
|
|
363
|
+
return `${possessive(subj)} ${jstr(m.weapon_type)} weapons gain ${kw}`;
|
|
364
|
+
return `${possessive(subj)} weapons gain ${kw}`;
|
|
91
365
|
}
|
|
92
366
|
case "ability-grant": {
|
|
93
367
|
const grant = m.grant_type ?? m.ability_id;
|
|
94
368
|
const cap = m.capacity != null ? ` (${jstr(m.capacity)})` : "";
|
|
95
|
-
return
|
|
369
|
+
return grant != null
|
|
370
|
+
? `${subj} ${v(subj, "gains")} the ${titleCase(jstr(grant))} ability${cap}`
|
|
371
|
+
: `${subj} ${v(subj, "gains")} an ability${cap}`;
|
|
96
372
|
}
|
|
97
373
|
case "movement-modifier": {
|
|
98
374
|
const kind = m.move_type ?? m.type;
|
|
375
|
+
if (jstr(kind) === "move-through")
|
|
376
|
+
return `${subj} can move through enemy models and terrain`;
|
|
99
377
|
const dist = m.distance ?? m.value;
|
|
100
|
-
const inches = dist != null ? ` ${jstr(dist)}"` : "";
|
|
101
|
-
return
|
|
378
|
+
const inches = dist != null && jstr(dist) !== "0" ? ` ${jstr(dist)}"` : "";
|
|
379
|
+
return kind != null
|
|
380
|
+
? `${subj} ${v(subj, "has")} the ${titleCase(jstr(kind))}${inches} ability`
|
|
381
|
+
: `${subj} ${v(subj, "gains")} a movement ability`;
|
|
382
|
+
}
|
|
383
|
+
case "damage-reduction": {
|
|
384
|
+
const r = jstr(m.reduction ?? m.amount ?? m.value);
|
|
385
|
+
const how = r === "half"
|
|
386
|
+
? "halve the Damage of that attack"
|
|
387
|
+
: r === "to-zero"
|
|
388
|
+
? "reduce the Damage of that attack to 0"
|
|
389
|
+
: `reduce the Damage of that attack by ${r}`;
|
|
390
|
+
return `each time an attack targets ${subj}, ${how}`;
|
|
391
|
+
}
|
|
392
|
+
case "resurrection": {
|
|
393
|
+
const count = m.count != null ? diceCase(m.count) : "1";
|
|
394
|
+
const noun = count === "1" ? "destroyed model" : "destroyed models";
|
|
395
|
+
const wounds = m.wounds_remaining ?? "full";
|
|
396
|
+
return `return ${count} ${noun} to ${subj} with ${jstr(wounds)} wounds`;
|
|
397
|
+
}
|
|
398
|
+
case "model-destruction": {
|
|
399
|
+
const count = m.count != null ? diceCase(m.count) : "1";
|
|
400
|
+
const noun = count === "1" ? "model" : "models";
|
|
401
|
+
return `destroy ${count} ${noun} in ${subj}`;
|
|
102
402
|
}
|
|
103
|
-
case "damage-reduction":
|
|
104
|
-
return `reduce incoming damage to ${target} by ${jstr(m.amount ?? m.value)}`;
|
|
105
|
-
case "resurrection":
|
|
106
|
-
return `return ${jstr(m.count ?? 1)} model(s) to ${target} with ${jstr(m.wounds_remaining ?? "full")} wounds`;
|
|
107
|
-
case "model-destruction":
|
|
108
|
-
return `destroy ${jstr(m.count)} non-leader model(s) from ${target}`;
|
|
109
403
|
case "cp-gain":
|
|
110
|
-
return `gain ${jstr(m.amount)}
|
|
111
|
-
case "cp-refund":
|
|
112
|
-
|
|
404
|
+
return `you gain ${jstr(m.amount ?? 1)}CP`;
|
|
405
|
+
case "cp-refund": {
|
|
406
|
+
const strat = m.stratagem != null ? `the ${titleCase(jstr(m.stratagem))} Stratagem` : "one Stratagem";
|
|
407
|
+
return `you can use ${strat} on ${subj} for 0CP`;
|
|
408
|
+
}
|
|
113
409
|
case "resource-gain":
|
|
114
|
-
return `gain ${jstr(m.amount)}
|
|
410
|
+
return `you gain ${jstr(m.amount ?? m.value)} ${poolName(m.pool_id ?? m.resource)}`;
|
|
115
411
|
case "resource-spend":
|
|
116
|
-
return `spend ${jstr(m.amount)}
|
|
412
|
+
return `spend ${jstr(m.amount ?? m.value)} ${poolName(m.pool_id ?? m.resource)}`;
|
|
117
413
|
case "leadership-modifier": {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
414
|
+
const test = m.test != null ? `${testName(m.test)} test` : null;
|
|
415
|
+
if (test != null && m.operation == null)
|
|
416
|
+
return `${subj} must take a ${test}`;
|
|
417
|
+
if (test != null && m.operation === "re-roll")
|
|
418
|
+
return `${subj} can re-roll ${testName(m.test)} tests`;
|
|
419
|
+
if (test != null && m.value != null)
|
|
420
|
+
return `${m.operation === "add" ? "add" : "subtract"} ${jstr(m.value)} ${m.operation === "add" ? "to" : "from"} the ${testName(m.test)} test of ${subj}`;
|
|
421
|
+
if (m.operation != null && m.value != null)
|
|
422
|
+
return `${m.operation === "add" || m.operation === "improve" ? "add" : "subtract"} ${jstr(m.value)} ${m.operation === "add" || m.operation === "improve" ? "to" : "from"} the Leadership characteristic of ${subj}`;
|
|
423
|
+
return `modify ${possessive(subj)} Leadership characteristic`;
|
|
125
424
|
}
|
|
126
425
|
case "fight-first":
|
|
127
|
-
return `${
|
|
426
|
+
return `${subj} ${v(subj, "has")} the Fights First ability`;
|
|
128
427
|
case "fight-last":
|
|
129
|
-
return `${
|
|
428
|
+
return `${subj} ${v(subj, "has")} the Fights Last ability`;
|
|
130
429
|
case "fight-on-death":
|
|
131
|
-
return
|
|
430
|
+
return subj === "this model"
|
|
431
|
+
? `each time this model is destroyed, it can fight before being removed from play`
|
|
432
|
+
: `each time a model in ${subj} is destroyed, it can fight before being removed from play`;
|
|
132
433
|
case "shoot-on-death":
|
|
133
|
-
return
|
|
434
|
+
return subj === "this model"
|
|
435
|
+
? `each time this model is destroyed, it can shoot before being removed from play`
|
|
436
|
+
: `each time a model in ${subj} is destroyed, it can shoot before being removed from play`;
|
|
134
437
|
case "deep-strike":
|
|
135
|
-
return `${
|
|
438
|
+
return `${subj} has the Deep Strike ability`;
|
|
136
439
|
case "fallback-and-act":
|
|
137
|
-
return `${
|
|
138
|
-
case "
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return `${target}: ${what != null ? dekebab(jstr(what)) : "attack restriction"}${range}${max}`;
|
|
143
|
-
}
|
|
440
|
+
return `${subj} is eligible to shoot and declare a charge in a turn in which it Fell Back`;
|
|
441
|
+
case "engagement-passthrough":
|
|
442
|
+
return `${subj} can move through enemy models`;
|
|
443
|
+
case "attack-restriction":
|
|
444
|
+
return describeAttackRestriction(m, subj);
|
|
144
445
|
case "objective-control-modifier": {
|
|
446
|
+
if (m.sticky)
|
|
447
|
+
return `${subj} ${v(subj, "retains")} control of objective markers even after no models remain in range, until the enemy retakes them (sticky objectives)`;
|
|
448
|
+
if (m.operation === "halve")
|
|
449
|
+
return `halve the Objective Control characteristic of ${subj}`;
|
|
145
450
|
if (m.operation != null)
|
|
146
|
-
return `${signed(m.operation, m.value)}
|
|
147
|
-
return `modify
|
|
451
|
+
return `${subj} ${v(subj, "gets")} ${signed(m.operation, m.value)} to ${pronoun(subj)} Objective Control characteristic`;
|
|
452
|
+
return `modify ${possessive(subj)} Objective Control characteristic`;
|
|
148
453
|
}
|
|
149
454
|
case "bs-modifier":
|
|
150
|
-
return `${signed(m.operation, m.value)}
|
|
455
|
+
return `${subj} ${v(subj, "gets")} ${signed(m.operation, m.value)} to Ballistic Skill`;
|
|
151
456
|
case "charge-roll-modifier":
|
|
152
|
-
return `${signed(m.operation, m.value)} to
|
|
153
|
-
case "engagement-passthrough":
|
|
154
|
-
return `${target} can move through engagement range`;
|
|
457
|
+
return `${subj} ${v(subj, "gets")} ${signed(m.operation, m.value)} to Charge rolls`;
|
|
155
458
|
case "terrain-area-tag":
|
|
156
|
-
return `
|
|
459
|
+
return `the terrain area is marked as ${dekebab(jstr(m.tag))}`;
|
|
157
460
|
case "objective-tag":
|
|
158
|
-
return `
|
|
461
|
+
return `the objective is marked as ${dekebab(jstr(m.tag))}`;
|
|
159
462
|
case "unit-tag":
|
|
160
|
-
return
|
|
463
|
+
return `${subj} ${v(subj, "is")} marked as ${dekebab(jstr(m.tag))}`;
|
|
161
464
|
// Container types — inline forms.
|
|
162
465
|
case "conditional":
|
|
163
|
-
return
|
|
466
|
+
return `${conditionLeadIn(e.condition ?? {})}, ${describeEffectInline(e.effect ?? {}, ctx)}`;
|
|
164
467
|
case "sequence":
|
|
165
|
-
return (e.steps ?? []).map(describeEffectInline).join("; ");
|
|
468
|
+
return (e.steps ?? []).map((s) => describeEffectInline(s, ctx)).join("; ");
|
|
166
469
|
case "choice": {
|
|
167
|
-
const label = e.choice_label ? ` (${e.choice_label})` : "";
|
|
168
|
-
return `
|
|
470
|
+
const label = e.choice_label ? ` (${titleCase(e.choice_label)})` : "";
|
|
471
|
+
return `select one of the following${label}: ${(e.options ?? []).map((o) => describeEffectInline(o, ctx)).join(" / ")}`;
|
|
169
472
|
}
|
|
170
473
|
case "dice-gated": {
|
|
171
474
|
const comp = formatComparison(e.comparison ?? "gte", e.threshold);
|
|
172
|
-
const success = e.on_success ? describeEffectInline(e.on_success) : "nothing";
|
|
173
|
-
const fail = e.on_fail ?
|
|
174
|
-
return `roll ${
|
|
475
|
+
const success = e.on_success ? describeEffectInline(e.on_success, ctx) : "nothing happens";
|
|
476
|
+
const fail = e.on_fail ? `; otherwise, ${describeEffectInline(e.on_fail, ctx)}` : "";
|
|
477
|
+
return `roll one ${diceCase(e.dice)}: on ${comp}, ${success}${fail}`;
|
|
175
478
|
}
|
|
176
479
|
case "dice-pool-allocation": {
|
|
177
480
|
const pool = e.pool ? `${jstr(e.pool.count)}${jstr(e.pool.die)}` : "?";
|
|
178
481
|
const opts = (e.options ?? [])
|
|
179
|
-
.map((o) => `${jstr(o.name)} (${jstr(o.requirement?.min_value)}+): ${describeEffectInline(o.effect ?? {})}`)
|
|
482
|
+
.map((o) => `${jstr(o.name)} (${jstr(o.requirement?.min_value)}+): ${describeEffectInline(o.effect ?? {}, ctx)}`)
|
|
180
483
|
.join(" / ");
|
|
181
484
|
return `roll ${pool}: ${opts}`;
|
|
182
485
|
}
|
|
@@ -184,42 +487,78 @@ export function describeEffectInline(e) {
|
|
|
184
487
|
return `[${e.type ?? "unknown"}]`;
|
|
185
488
|
}
|
|
186
489
|
}
|
|
490
|
+
/** Per-slug GW-prose for `attack-restriction` (reads `restriction` or `restriction_type`). */
|
|
491
|
+
function describeAttackRestriction(m, subj) {
|
|
492
|
+
// Some entries express the restriction as a forbidden action (`attack_type: charge`).
|
|
493
|
+
if (m.restriction == null && m.restriction_type == null && m.attack_type != null)
|
|
494
|
+
return `${subj} cannot ${jstr(m.attack_type)}`;
|
|
495
|
+
const slug = jstr(m.restriction ?? m.restriction_type);
|
|
496
|
+
const range = m.range != null ? jstr(m.range) : null;
|
|
497
|
+
switch (slug) {
|
|
498
|
+
case "worsen-incoming-ap":
|
|
499
|
+
return `each time an attack targets ${subj}, worsen the Armour Penetration of that attack by ${jstr(m.value ?? 1)}`;
|
|
500
|
+
case "cannot-be-targeted-unless-closest-or-within-12":
|
|
501
|
+
return `${subj} can only be targeted if it is the closest eligible target or within 12"`;
|
|
502
|
+
case "targeting-range-limit":
|
|
503
|
+
return `${subj} can only target enemy units within ${range ?? "?"}"`;
|
|
504
|
+
case "reinforcement-denial":
|
|
505
|
+
return `enemy units cannot be set up from Reserves within ${range ?? "?"}" of ${subj}`;
|
|
506
|
+
case "must-be-warlord":
|
|
507
|
+
return "this model must be your Warlord";
|
|
508
|
+
case "cannot-be-warlord":
|
|
509
|
+
return "this model cannot be your Warlord";
|
|
510
|
+
case "unique-unit-limit":
|
|
511
|
+
return "you can include only one of this unit in your army";
|
|
512
|
+
case "no-charge":
|
|
513
|
+
return `${subj} cannot charge`;
|
|
514
|
+
default:
|
|
515
|
+
return `${subj}: ${dekebab(slug)}${range != null ? ` (within ${range}")` : ""}`;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
187
518
|
/**
|
|
188
|
-
* Block translation of
|
|
189
|
-
*
|
|
519
|
+
* Block translation of a *container* effect tree (multi-line, two-space
|
|
520
|
+
* indentation). Leaves and conditionals are handled inline by the caller.
|
|
190
521
|
*/
|
|
191
|
-
export function describeEffect(e, depth = 0) {
|
|
522
|
+
export function describeEffect(e, depth = 0, ctx = {}) {
|
|
192
523
|
const indent = " ".repeat(depth);
|
|
193
524
|
const arrow = depth > 0 ? "-> " : "";
|
|
194
525
|
switch (e.type) {
|
|
195
|
-
case "conditional":
|
|
196
|
-
|
|
526
|
+
case "conditional": {
|
|
527
|
+
const inner = e.effect ?? {};
|
|
528
|
+
if (CONTAINER_TYPES.has(inner.type ?? "")) {
|
|
529
|
+
return `${indent}${capitalize(conditionLeadIn(e.condition ?? {}))}:\n` + describeEffect(inner, depth + 1, ctx);
|
|
530
|
+
}
|
|
531
|
+
return `${indent}${arrow}${capitalize(conditionLeadIn(e.condition ?? {}))}, ${describeEffectInline(inner, ctx)}.`;
|
|
532
|
+
}
|
|
197
533
|
case "sequence":
|
|
198
|
-
return (e.steps ?? [])
|
|
534
|
+
return (e.steps ?? [])
|
|
535
|
+
.map((s) => describeEffect(s, depth, ctx))
|
|
536
|
+
.join("\n");
|
|
199
537
|
case "choice": {
|
|
200
|
-
const label = e.choice_label ? ` (${e.choice_label})` : "";
|
|
201
|
-
return (`${indent}
|
|
202
|
-
(e.options ?? []).map((o
|
|
538
|
+
const label = e.choice_label ? ` (${titleCase(e.choice_label)})` : "";
|
|
539
|
+
return (`${indent}Select one of the following${label}:\n` +
|
|
540
|
+
(e.options ?? []).map((o) => `${indent} - ${capitalize(describeEffectInline(o, ctx))}.`).join("\n"));
|
|
203
541
|
}
|
|
204
542
|
case "dice-gated": {
|
|
205
543
|
const comp = formatComparison(e.comparison ?? "gte", e.threshold);
|
|
206
|
-
const success = e.on_success ? describeEffectInline(e.on_success) : "nothing";
|
|
207
|
-
const fail = e.on_fail ?
|
|
208
|
-
return `${indent}${arrow}Roll ${
|
|
544
|
+
const success = e.on_success ? describeEffectInline(e.on_success, ctx) : "nothing happens";
|
|
545
|
+
const fail = e.on_fail ? `; otherwise, ${describeEffectInline(e.on_fail, ctx)}` : "";
|
|
546
|
+
return `${indent}${arrow}Roll one ${diceCase(e.dice)}: on ${comp}, ${success}${fail}.`;
|
|
209
547
|
}
|
|
210
548
|
case "dice-pool-allocation": {
|
|
211
549
|
const pool = e.pool ? `${jstr(e.pool.count)}${jstr(e.pool.die)}` : "?";
|
|
212
550
|
const lines = [`${indent}${arrow}Roll ${pool} (max ${jstr(e.max_activations)} activations):`];
|
|
213
551
|
for (const opt of e.options ?? []) {
|
|
214
|
-
lines.push(`${indent} - ${jstr(opt.name)}: need ${jstr(opt.requirement?.type)} of ${jstr(opt.requirement?.min_value)}+ -> ${describeEffectInline(opt.effect ?? {})}`);
|
|
552
|
+
lines.push(`${indent} - ${jstr(opt.name)}: need ${jstr(opt.requirement?.type)} of ${jstr(opt.requirement?.min_value)}+ -> ${describeEffectInline(opt.effect ?? {}, ctx)}`);
|
|
215
553
|
}
|
|
216
554
|
return lines.join("\n");
|
|
217
555
|
}
|
|
218
556
|
default:
|
|
219
|
-
|
|
557
|
+
// Leaf at block position — render as a single capitalized sentence.
|
|
558
|
+
return `${indent}${arrow}${capitalize(describeEffectInline(e, ctx))}.`;
|
|
220
559
|
}
|
|
221
560
|
}
|
|
222
|
-
/** `Scope: aura (6"). Duration: phase.` —
|
|
561
|
+
/** `Scope: aura (6"). Duration: phase.` — retained for the legacy translate CLI footer. */
|
|
223
562
|
export function describeScope(s) {
|
|
224
563
|
if (!s || (!s.range && !s.duration))
|
|
225
564
|
return "";
|
|
@@ -245,16 +584,46 @@ export function describeAppliesTo(a) {
|
|
|
245
584
|
const exc = excluded.length ? ` (excluding ${excluded.join(", ")})` : "";
|
|
246
585
|
return `Applies to: ${base}${exc}.`;
|
|
247
586
|
}
|
|
587
|
+
/** Join non-empty clauses with ", ", capitalize the sentence, and end with a period. */
|
|
588
|
+
function assembleSentence(parts) {
|
|
589
|
+
const body = parts.filter((p) => p.length > 0).join(", ");
|
|
590
|
+
if (body.length === 0)
|
|
591
|
+
return "";
|
|
592
|
+
const period = body.endsWith(".") || body.endsWith(":") ? "" : ".";
|
|
593
|
+
return capitalize(body) + period;
|
|
594
|
+
}
|
|
248
595
|
/**
|
|
249
|
-
* Full generated text for an ability:
|
|
250
|
-
*
|
|
251
|
-
* `
|
|
252
|
-
*
|
|
596
|
+
* Full generated text for an ability: a natural-English sentence (effect with
|
|
597
|
+
* scope range + duration woven in, single-leaf conditionals inlined) plus a
|
|
598
|
+
* trailing `Applies to:` line when the ability carries a curated `applies_to`
|
|
599
|
+
* filter. This is the `ability.print()` consumers render when the dataset
|
|
600
|
+
* carries no rules prose.
|
|
253
601
|
*/
|
|
254
602
|
export function describeAbility(a) {
|
|
255
|
-
const
|
|
256
|
-
const scope = describeScope(a.scope);
|
|
603
|
+
const core = a.effect ? renderTopLevel(a.effect, a.scope) : "";
|
|
257
604
|
const applies = describeAppliesTo(a.applies_to);
|
|
258
|
-
return [
|
|
605
|
+
return [core, applies].filter(Boolean).join("\n");
|
|
606
|
+
}
|
|
607
|
+
/** Assemble the top-level sentence/block, weaving scope duration + range. */
|
|
608
|
+
function renderTopLevel(e, scope) {
|
|
609
|
+
const ctx = { rangeInches: scope?.range_inches };
|
|
610
|
+
const { lead, trail } = durationClauses(scope?.duration);
|
|
611
|
+
if (e.type === "conditional") {
|
|
612
|
+
const inner = e.effect ?? {};
|
|
613
|
+
const leadIn = conditionLeadIn(e.condition ?? {});
|
|
614
|
+
if (CONTAINER_TYPES.has(inner.type ?? "")) {
|
|
615
|
+
// Block: "<lead-in>[, <duration>]:" then the indented container.
|
|
616
|
+
const header = [lead, leadIn, trail].filter((p) => p.length > 0).join(", ");
|
|
617
|
+
return capitalize(header) + ":\n" + describeEffect(inner, 1, ctx);
|
|
618
|
+
}
|
|
619
|
+
return assembleSentence([lead, leadIn, trail, describeEffectInline(inner, ctx)]);
|
|
620
|
+
}
|
|
621
|
+
if (CONTAINER_TYPES.has(e.type ?? "")) {
|
|
622
|
+
// Containers render block; a duration lead-in prefixes the block when present.
|
|
623
|
+
const block = describeEffect(e, 0, ctx);
|
|
624
|
+
const dur = lead || trail;
|
|
625
|
+
return dur ? capitalize(dur) + ":\n" + block : block;
|
|
626
|
+
}
|
|
627
|
+
return assembleSentence([lead, trail, describeEffectInline(e, ctx)]);
|
|
259
628
|
}
|
|
260
629
|
//# sourceMappingURL=effect.js.map
|