@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.
Files changed (84) hide show
  1. package/dist/abilities-resolver/resolver.d.ts.map +1 -1
  2. package/dist/abilities-resolver/resolver.js +6 -2
  3. package/dist/abilities-resolver/resolver.js.map +1 -1
  4. package/dist/audit-coverage.d.ts.map +1 -1
  5. package/dist/audit-coverage.js +90 -1
  6. package/dist/audit-coverage.js.map +1 -1
  7. package/dist/audit-phrasing.d.ts +64 -0
  8. package/dist/audit-phrasing.d.ts.map +1 -0
  9. package/dist/audit-phrasing.js +208 -0
  10. package/dist/audit-phrasing.js.map +1 -0
  11. package/dist/build-share-registry.d.ts +2 -0
  12. package/dist/build-share-registry.d.ts.map +1 -0
  13. package/dist/build-share-registry.js +115 -0
  14. package/dist/build-share-registry.js.map +1 -0
  15. package/dist/cli.js +7 -0
  16. package/dist/cli.js.map +1 -1
  17. package/dist/codegen-data.js +23 -0
  18. package/dist/codegen-data.js.map +1 -1
  19. package/dist/commands/translate.d.ts.map +1 -1
  20. package/dist/commands/translate.js +2 -5
  21. package/dist/commands/translate.js.map +1 -1
  22. package/dist/commands/validate-all.d.ts.map +1 -1
  23. package/dist/commands/validate-all.js +8 -3
  24. package/dist/commands/validate-all.js.map +1 -1
  25. package/dist/data/bundle.generated.js +1 -1
  26. package/dist/data/bundle.generated.js.map +1 -1
  27. package/dist/data/dataset.d.ts +33 -1
  28. package/dist/data/dataset.d.ts.map +1 -1
  29. package/dist/data/dataset.js +107 -1
  30. package/dist/data/dataset.js.map +1 -1
  31. package/dist/data/index.d.ts +4 -0
  32. package/dist/data/index.d.ts.map +1 -1
  33. package/dist/data/index.js +4 -0
  34. package/dist/data/index.js.map +1 -1
  35. package/dist/data/types.d.ts +3 -1
  36. package/dist/data/types.d.ts.map +1 -1
  37. package/dist/data/types.js +1 -0
  38. package/dist/data/types.js.map +1 -1
  39. package/dist/gen-conformance.js +162 -0
  40. package/dist/gen-conformance.js.map +1 -1
  41. package/dist/generated.d.ts +125 -0
  42. package/dist/generated.d.ts.map +1 -1
  43. package/dist/generated.js.map +1 -1
  44. package/dist/integrity.d.ts +32 -0
  45. package/dist/integrity.d.ts.map +1 -0
  46. package/dist/integrity.js +120 -0
  47. package/dist/integrity.js.map +1 -0
  48. package/dist/report.d.ts +1 -1
  49. package/dist/report.d.ts.map +1 -1
  50. package/dist/report.js +2 -2
  51. package/dist/report.js.map +1 -1
  52. package/dist/runner.d.ts.map +1 -1
  53. package/dist/runner.js +42 -0
  54. package/dist/runner.js.map +1 -1
  55. package/dist/share/codec.d.ts +83 -0
  56. package/dist/share/codec.d.ts.map +1 -0
  57. package/dist/share/codec.js +230 -0
  58. package/dist/share/codec.js.map +1 -0
  59. package/dist/share/index.d.ts +30 -0
  60. package/dist/share/index.d.ts.map +1 -0
  61. package/dist/share/index.js +36 -0
  62. package/dist/share/index.js.map +1 -0
  63. package/dist/share/registry.d.ts +42 -0
  64. package/dist/share/registry.d.ts.map +1 -0
  65. package/dist/share/registry.generated.d.ts +4 -0
  66. package/dist/share/registry.generated.d.ts.map +1 -0
  67. package/dist/share/registry.generated.js +4 -0
  68. package/dist/share/registry.generated.js.map +1 -0
  69. package/dist/share/registry.js +68 -0
  70. package/dist/share/registry.js.map +1 -0
  71. package/dist/translate/condition.d.ts +1 -0
  72. package/dist/translate/condition.d.ts.map +1 -1
  73. package/dist/translate/condition.js +101 -7
  74. package/dist/translate/condition.js.map +1 -1
  75. package/dist/translate/effect.d.ts +28 -20
  76. package/dist/translate/effect.d.ts.map +1 -1
  77. package/dist/translate/effect.js +483 -114
  78. package/dist/translate/effect.js.map +1 -1
  79. package/dist/validate.d.ts.map +1 -1
  80. package/dist/validate.js +1 -0
  81. package/dist/validate.js.map +1 -1
  82. package/package.json +3 -1
  83. package/schemas/core/allied-rule.schema.json +115 -0
  84. package/schemas/core/detachment.schema.json +38 -1
@@ -1,18 +1,25 @@
1
1
  /**
2
- * Humanize an Ability-DSL `effect` tree into plain English — the
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), ASCII-only, with
5
- * a fixed clause order: it is pinned byte-for-byte across the TS and Rust
6
- * ports by the `conformance/effect-translation` corpus, so any phrasing
7
- * change here is a semantic corpus change (bump `conformance/SPEC_VERSION`).
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`, `conditional`, `choice`, `dice-gated`,
10
- * `dice-pool-allocation`) render block-style with two-space indentation and
11
- * an ASCII `-> ` arrow; leaves render as single clauses. Unknown leaf types
12
- * and unrecognized modifier shapes degrade to a deterministic bracketed form
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
- function formatTarget(t) {
25
- return t ? dekebab(t) : "target";
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 op = operation === "add" || operation === "improve" ? "+" : "-";
29
- return `${op}${jstr(value)}`;
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 `${th}+`;
141
+ return `a ${th}+`;
36
142
  case "lte":
37
- return `${th} or less`;
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 `${th}+`;
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 (and inline container forms). */
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 target = formatTarget(e.target);
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 stats for ${target}`;
297
+ return `modify ${possessive(subj)} characteristics${scope}`;
57
298
  if (m.operation === "set")
58
- return `set ${jstr(m.stat)} to ${jstr(m.value)}${scope} for ${target}`;
59
- return `${signed(m.operation, m.value)} ${jstr(m.stat)}${scope} for ${target}`;
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 ctx = m.context ? ` (${jstr(m.context)})` : "";
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))} ${jstr(m.roll)} rolls${ctx} for ${target}`;
65
- return `${signed(m.operation, m.value)} to ${jstr(m.roll)} rolls${ctx} for ${target}`;
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 subset = m.subset ? ` (${dekebab(jstr(m.subset))})` : "";
69
- const atk = m.attack_type ? ` (${jstr(m.attack_type)})` : "";
70
- return `re-roll ${jstr(m.roll)} rolls${subset}${atk} for ${target}`;
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 amount = m.count ?? m.amount ?? (m.amount_table ? "variable" : "?");
74
- const range = m.range ?? m.range_inches;
75
- const within = range != null ? ` (within ${jstr(range)}")` : "";
76
- return `deal ${jstr(amount)} mortal wounds to ${target}${within}`;
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 `${target} gains Ward ${jstr(m.threshold ?? m.value)}+`;
353
+ return `${subj} ${v(subj, "has")} the Ward ${jstr(m.threshold ?? m.value)}+ ability`;
82
354
  case "invulnerable-save":
83
- return `${target} gains a ${jstr(m.value)}+ invulnerable save`;
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) ? m.keywords.map(jstr).join(", ") : jstr(m.keyword ?? "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 `${target}'s ${jstr(m.weapon_name)} gains ${kw}`;
361
+ return `${possessive(subj)} ${jstr(m.weapon_name)} gains ${kw}`;
88
362
  if (m.weapon_type != null)
89
- return `${target}'s ${jstr(m.weapon_type)} weapons gain ${kw}`;
90
- return `${target}'s weapons gain ${kw}`;
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 `${target} gains ${grant != null ? dekebab(jstr(grant)) : "an ability"}${cap}`;
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 `${target} gains ${kind != null ? dekebab(jstr(kind)) : "a movement effect"}${inches}`;
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)} CP`;
111
- case "cp-refund":
112
- return `refund ${jstr(m.amount)} CP`;
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)} to ${jstr(m.pool_id)}`;
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)} from ${jstr(m.pool_id)}`;
412
+ return `spend ${jstr(m.amount ?? m.value)} ${poolName(m.pool_id ?? m.resource)}`;
117
413
  case "leadership-modifier": {
118
- if (m.test != null && m.operation == null)
119
- return `force a ${dekebab(jstr(m.test))} test on ${target}`;
120
- if (m.test != null)
121
- return `${dekebab(jstr(m.operation))} ${dekebab(jstr(m.test))} tests for ${target}`;
122
- if (m.operation != null)
123
- return `${signed(m.operation, m.value)} Leadership for ${target}`;
124
- return `modify Leadership for ${target}`;
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 `${target} fights first`;
426
+ return `${subj} ${v(subj, "has")} the Fights First ability`;
128
427
  case "fight-last":
129
- return `${target} fights last`;
428
+ return `${subj} ${v(subj, "has")} the Fights Last ability`;
130
429
  case "fight-on-death":
131
- return `${target} fights on death`;
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 `${target} shoots on death`;
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 `${target} can deep strike`;
438
+ return `${subj} has the Deep Strike ability`;
136
439
  case "fallback-and-act":
137
- return `${target} can fall back and act`;
138
- case "attack-restriction": {
139
- const what = m.restriction ?? m.restriction_type;
140
- const range = m.range != null ? ` (within ${jstr(m.range)}")` : "";
141
- const max = m.max_models != null ? ` (max ${jstr(m.max_models)} models)` : "";
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)} OC for ${target}`;
147
- return `modify OC of ${target} by ${jstr(m.value)}`;
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)} BS for ${target}`;
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 charge rolls for ${target}`;
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 `tag the terrain area as ${dekebab(jstr(m.tag))}`;
459
+ return `the terrain area is marked as ${dekebab(jstr(m.tag))}`;
157
460
  case "objective-tag":
158
- return `tag the objective as ${dekebab(jstr(m.tag))}`;
461
+ return `the objective is marked as ${dekebab(jstr(m.tag))}`;
159
462
  case "unit-tag":
160
- return `tag ${target} as ${dekebab(jstr(m.tag))}`;
463
+ return `${subj} ${v(subj, "is")} marked as ${dekebab(jstr(m.tag))}`;
161
464
  // Container types — inline forms.
162
465
  case "conditional":
163
- return `if ${describeCondition(e.condition ?? {})}: ${describeEffectInline(e.effect ?? {})}`;
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 `choose one${label}: ${(e.options ?? []).map(describeEffectInline).join(" / ")}`;
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 ? `, otherwise ${describeEffectInline(e.on_fail)}` : "";
174
- return `roll ${jstr(e.dice)}: on ${comp}, ${success}${fail}`;
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 an effect tree. Containers expand over multiple lines
189
- * with two-space indentation; leaves delegate to `describeEffectInline`.
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
- return `${indent}If ${describeCondition(e.condition ?? {})}:\n` + describeEffect(e.effect ?? {}, depth + 1);
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 ?? []).map((s) => describeEffect(s, depth)).join("\n");
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}${arrow}Choose one${label}:\n` +
202
- (e.options ?? []).map((o, i) => `${indent} ${i + 1}. ${describeEffectInline(o)}`).join("\n"));
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 ? `, otherwise ${describeEffectInline(e.on_fail)}` : "";
208
- return `${indent}${arrow}Roll ${jstr(e.dice)}: on ${comp}, ${success}${fail}`;
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
- return `${indent}${arrow}${describeEffectInline(e)}`;
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.` — empty string when absent. */
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: the effect tree, a trailing scope line,
250
- * and a trailing `Applies to:` line when the ability carries a curated
251
- * `applies_to` filter. This is the `ability.print()` consumers render when the
252
- * dataset carries no rules prose.
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 effect = a.effect ? describeEffect(a.effect) : "";
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 [effect, scope, applies].filter(Boolean).join("\n");
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