@datamoc/mw_games 0.7.7 → 0.7.8

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 (144) hide show
  1. package/CHANGELOG.md +92 -3
  2. package/README.md +7 -7
  3. package/dist/actors/Shop.d.ts.map +1 -1
  4. package/dist/actors/Shop.js +4 -1
  5. package/dist/actors/Shop.js.map +1 -1
  6. package/dist/ai/Heuristics.d.ts +8 -0
  7. package/dist/ai/Heuristics.d.ts.map +1 -1
  8. package/dist/ai/Heuristics.js.map +1 -1
  9. package/dist/ai/index.d.ts +2 -2
  10. package/dist/ai/index.d.ts.map +1 -1
  11. package/dist/ai/index.js +1 -1
  12. package/dist/ai/index.js.map +1 -1
  13. package/dist/ai/lua.d.ts +21 -0
  14. package/dist/ai/lua.d.ts.map +1 -1
  15. package/dist/ai/lua.js +21 -0
  16. package/dist/ai/lua.js.map +1 -1
  17. package/dist/ai/score.d.ts +36 -0
  18. package/dist/ai/score.d.ts.map +1 -1
  19. package/dist/ai/score.js +26 -0
  20. package/dist/ai/score.js.map +1 -1
  21. package/dist/battle/BattleStats.d.ts.map +1 -1
  22. package/dist/battle/BattleStats.js.map +1 -1
  23. package/dist/board/Classics.d.ts.map +1 -1
  24. package/dist/board/Classics.js +51 -39
  25. package/dist/board/Classics.js.map +1 -1
  26. package/dist/board/HexSkirmish.d.ts.map +1 -1
  27. package/dist/board/HexSkirmish.js +69 -20
  28. package/dist/board/HexSkirmish.js.map +1 -1
  29. package/dist/board/Tactics.d.ts +7 -0
  30. package/dist/board/Tactics.d.ts.map +1 -1
  31. package/dist/board/Tactics.js +2 -2
  32. package/dist/board/Tactics.js.map +1 -1
  33. package/dist/core/Achievements.d.ts.map +1 -1
  34. package/dist/core/Achievements.js +3 -1
  35. package/dist/core/Achievements.js.map +1 -1
  36. package/dist/mw_games.global.js +20 -20
  37. package/dist/mwl/actors.d.ts +46 -2
  38. package/dist/mwl/actors.d.ts.map +1 -1
  39. package/dist/mwl/actors.js +46 -2
  40. package/dist/mwl/actors.js.map +1 -1
  41. package/dist/mwl/carryover.d.ts +7 -0
  42. package/dist/mwl/carryover.d.ts.map +1 -1
  43. package/dist/mwl/carryover.js +7 -0
  44. package/dist/mwl/carryover.js.map +1 -1
  45. package/dist/mwl/catalog.d.ts +10 -1
  46. package/dist/mwl/catalog.d.ts.map +1 -1
  47. package/dist/mwl/catalog.js +10 -1
  48. package/dist/mwl/catalog.js.map +1 -1
  49. package/dist/mwl/compiler.d.ts +70 -3
  50. package/dist/mwl/compiler.d.ts.map +1 -1
  51. package/dist/mwl/compiler.js +70 -3
  52. package/dist/mwl/compiler.js.map +1 -1
  53. package/dist/mwl/conditions.d.ts +10 -1
  54. package/dist/mwl/conditions.d.ts.map +1 -1
  55. package/dist/mwl/conditions.js +10 -1
  56. package/dist/mwl/conditions.js.map +1 -1
  57. package/dist/mwl/content.d.ts +11 -0
  58. package/dist/mwl/content.d.ts.map +1 -1
  59. package/dist/mwl/content.js +11 -0
  60. package/dist/mwl/content.js.map +1 -1
  61. package/dist/mwl/expression.d.ts +21 -0
  62. package/dist/mwl/expression.d.ts.map +1 -1
  63. package/dist/mwl/expression.js +21 -0
  64. package/dist/mwl/expression.js.map +1 -1
  65. package/dist/mwl/fengari.d.ts +12 -1
  66. package/dist/mwl/fengari.d.ts.map +1 -1
  67. package/dist/mwl/fengari.js +12 -1
  68. package/dist/mwl/fengari.js.map +1 -1
  69. package/dist/mwl/grammar.d.ts +45 -1
  70. package/dist/mwl/grammar.d.ts.map +1 -1
  71. package/dist/mwl/grammar.js +45 -1
  72. package/dist/mwl/grammar.js.map +1 -1
  73. package/dist/mwl/hooks.d.ts +45 -1
  74. package/dist/mwl/hooks.d.ts.map +1 -1
  75. package/dist/mwl/hooks.js +45 -1
  76. package/dist/mwl/hooks.js.map +1 -1
  77. package/dist/mwl/index.d.ts +1 -1
  78. package/dist/mwl/index.d.ts.map +1 -1
  79. package/dist/mwl/index.js +1 -1
  80. package/dist/mwl/index.js.map +1 -1
  81. package/dist/mwl/persistence.d.ts +32 -0
  82. package/dist/mwl/persistence.d.ts.map +1 -1
  83. package/dist/mwl/persistence.js +32 -0
  84. package/dist/mwl/persistence.js.map +1 -1
  85. package/dist/mwl/readers.d.ts +24 -2
  86. package/dist/mwl/readers.d.ts.map +1 -1
  87. package/dist/mwl/readers.js +27 -4
  88. package/dist/mwl/readers.js.map +1 -1
  89. package/dist/mwl/report.d.ts +21 -2
  90. package/dist/mwl/report.d.ts.map +1 -1
  91. package/dist/mwl/report.js +21 -2
  92. package/dist/mwl/report.js.map +1 -1
  93. package/dist/mwl/runtime.d.ts +72 -0
  94. package/dist/mwl/runtime.d.ts.map +1 -1
  95. package/dist/mwl/runtime.js +266 -68
  96. package/dist/mwl/runtime.js.map +1 -1
  97. package/dist/mwl/schema.d.ts +57 -1
  98. package/dist/mwl/schema.d.ts.map +1 -1
  99. package/dist/mwl/schema.js +85 -4
  100. package/dist/mwl/schema.js.map +1 -1
  101. package/dist/mwl/scripts.d.ts +12 -1
  102. package/dist/mwl/scripts.d.ts.map +1 -1
  103. package/dist/mwl/scripts.js +12 -1
  104. package/dist/mwl/scripts.js.map +1 -1
  105. package/dist/mwl/utils.d.ts +2 -0
  106. package/dist/mwl/utils.d.ts.map +1 -1
  107. package/dist/mwl/utils.js +4 -1
  108. package/dist/mwl/utils.js.map +1 -1
  109. package/dist/rpg/GameState.d.ts +2 -2
  110. package/dist/rpg/GameState.js +2 -2
  111. package/dist/two-d/render/ColorTransformBatcher.d.ts +7 -1
  112. package/dist/two-d/render/ColorTransformBatcher.d.ts.map +1 -1
  113. package/dist/two-d/render/ColorTransformBatcher.js +10 -1
  114. package/dist/two-d/render/ColorTransformBatcher.js.map +1 -1
  115. package/dist/two-d/render/ImageModifiers.d.ts.map +1 -1
  116. package/dist/two-d/render/ImageModifiers.js +20 -4
  117. package/dist/two-d/render/ImageModifiers.js.map +1 -1
  118. package/dist/two-d/render/TerrainGraphics.d.ts.map +1 -1
  119. package/dist/two-d/render/TerrainGraphics.js.map +1 -1
  120. package/dist/two-d/render/TerrainGraphicsLayer.d.ts +55 -0
  121. package/dist/two-d/render/TerrainGraphicsLayer.d.ts.map +1 -0
  122. package/dist/two-d/render/TerrainGraphicsLayer.js +54 -0
  123. package/dist/two-d/render/TerrainGraphicsLayer.js.map +1 -0
  124. package/dist/two-d/render/TintedSprite.js +2 -2
  125. package/dist/two-d/render/TintedSprite.js.map +1 -1
  126. package/dist/two-d/render/index.d.ts +3 -1
  127. package/dist/two-d/render/index.d.ts.map +1 -1
  128. package/dist/two-d/render/index.js +1 -0
  129. package/dist/two-d/render/index.js.map +1 -1
  130. package/dist/two-d/ui/MarkupText.d.ts +53 -0
  131. package/dist/two-d/ui/MarkupText.d.ts.map +1 -0
  132. package/dist/two-d/ui/MarkupText.js +122 -0
  133. package/dist/two-d/ui/MarkupText.js.map +1 -0
  134. package/dist/two-d/ui/index.d.ts +4 -2
  135. package/dist/two-d/ui/index.d.ts.map +1 -1
  136. package/dist/two-d/ui/index.js +2 -1
  137. package/dist/two-d/ui/index.js.map +1 -1
  138. package/dist/two-d/ui/markup.d.ts +54 -0
  139. package/dist/two-d/ui/markup.d.ts.map +1 -1
  140. package/dist/two-d/ui/markup.js +49 -1
  141. package/dist/two-d/ui/markup.js.map +1 -1
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/package.json +1 -1
@@ -1,10 +1,21 @@
1
1
  import { decodeSave, encodeSave } from './persistence.js';
2
2
  import { evaluateCondition } from './conditions.js';
3
3
  import { evaluateExpression } from './expression.js';
4
- import { booleanAttribute, integerAttribute, numberAttribute, requiredAttribute } from './utils.js';
4
+ import { booleanAttribute, booleanValue, integerAttribute, numberAttribute, requiredAttribute } from './utils.js';
5
5
  import { endLevelCarryover } from './carryover.js';
6
6
  const required = requiredAttribute;
7
7
  const integer = (node, attribute, fallback) => integerAttribute(node, attribute, fallback) ?? fallback;
8
+ /**
9
+ * A fresh, empty world: no sides, units, maps or variables, at turn 1 and `playing`.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { createWorld } from '@datamoc/mw_games/mwl';
14
+ *
15
+ * const world = createWorld();
16
+ * console.log(world.turn, world.status); // 1 'playing'
17
+ * ```
18
+ */
8
19
  export function createWorld() {
9
20
  return {
10
21
  variables: {},
@@ -24,6 +35,14 @@ export function createWorld() {
24
35
  * Parse map text: one row per line, comma-separated codes. A token may be
25
36
  * `<side> <code>` to mark that side's keep/start, which is how a leader knows
26
37
  * where to appear.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { parseTerrain } from '@datamoc/mw_games/mwl';
42
+ *
43
+ * const map = parseTerrain('Gg,Gg\nGg,1 Kh');
44
+ * console.log(map.width, map.starts['1']); // 2 [{ x: 1, y: 1 }]
45
+ * ```
27
46
  */
28
47
  export function parseTerrain(text) {
29
48
  const rows = text
@@ -57,6 +76,16 @@ export function parseTerrain(text) {
57
76
  * real data (pathfinding costs, combat formulas) stay in the engine adapter;
58
77
  * this runtime's `move` is a bounds and occupancy check, and `attack` applies
59
78
  * the amount the content asks for.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * import { compile, MwlRuntime } from '@datamoc/mw_games/mwl';
83
+ *
84
+ * const runtime = new MwlRuntime(compile('[game]\nschema=0.1\n[/game]'), {
85
+ * resolveMap: () => 'Gg,Gg\nGg,Gg',
86
+ * });
87
+ * console.log(runtime.world.turn); // 1
88
+ * ```
60
89
  */
61
90
  export class MwlRuntime {
62
91
  constructor(game, options = {}) {
@@ -155,18 +184,39 @@ export class MwlRuntime {
155
184
  return !filter || this.filterMatches(filter);
156
185
  }
157
186
  executeEvent(event) {
158
- for (const command of event.children.filter((child) => child.tag !== 'condition' && child.tag !== 'filter' && child.tag !== 'filter_condition')) {
159
- if (command.tag === 'say') {
160
- this.showSay(command);
187
+ this.runBlock(this.commandChildren(event));
188
+ }
189
+ /**
190
+ * Runs a sequence of commands, with `say` and `dialogue` taking the message path rather
191
+ * than `executeNode`. Shared by an event's top level, an `[if]`/`[else]` body and a
192
+ * dialogue branch, so all three treat a nested pair the same way.
193
+ */
194
+ runBlock(children) {
195
+ for (let index = 0; index < children.length; index++) {
196
+ const child = children[index];
197
+ if (this.isConsumedElse(children, index))
198
+ continue;
199
+ if (child.tag === 'say') {
200
+ this.showSay(child);
161
201
  continue;
162
202
  }
163
- if (command.tag === 'dialogue') {
164
- this.showDialogue(command);
203
+ if (child.tag === 'dialogue') {
204
+ this.showDialogue(child);
165
205
  continue;
166
206
  }
167
- this.executeNode(command);
207
+ this.executeNode(child);
168
208
  }
169
209
  }
210
+ /**
211
+ * Whether an `[else]` at `index` is the branch its immediately preceding `[if]` already ran.
212
+ * A taken `[if]` runs its own body and owns the `[else]`, so the loop skips that `[else]`;
213
+ * a false `[if]` ran nothing, so the `[else]` is left for the loop to run as its fallback.
214
+ * A free-standing `[else]` with no `[if]` before it is never consumed.
215
+ */
216
+ isConsumedElse(children, index) {
217
+ const previous = children[index - 1];
218
+ return children[index]?.tag === 'else' && previous?.tag === 'if' && this.nodeConditionMatches(previous);
219
+ }
170
220
  executeTracedEvent(event, trigger) {
171
221
  const id = event.attributes.id ??
172
222
  `${event.attributes.on ?? event.attributes.trigger ?? 'event'}@${event.location?.line ?? 0}`;
@@ -212,7 +262,7 @@ export class MwlRuntime {
212
262
  if (child.tag !== 'choice' || !child.attributes.text)
213
263
  continue;
214
264
  if (child.attributes.variable !== undefined &&
215
- !sameValue(this.world.variables[child.attributes.variable], child.attributes.equals))
265
+ !sameValue(this.variableAt(child.attributes.variable), child.attributes.equals))
216
266
  continue;
217
267
  const branches = child.children.filter((branch) => branch.tag === 'branch');
218
268
  if (branches.length) {
@@ -270,8 +320,7 @@ export class MwlRuntime {
270
320
  this.executeEvent(event);
271
321
  }
272
322
  else {
273
- for (const command of choice.branch ?? [])
274
- this.executeNode(command);
323
+ this.runBlock(choice.branch ?? []);
275
324
  }
276
325
  this.checkObjectives();
277
326
  return true;
@@ -419,9 +468,15 @@ export class MwlRuntime {
419
468
  const name = role.attributes.role ?? role.attributes.name;
420
469
  if (name === undefined)
421
470
  continue;
422
- (this.world.roles ??= {})[name] = Object.entries(this.world.units)
423
- .filter(([id, unit]) => unit.alive && unitMatchesFilter(unit, id, role.attributes))
424
- .map(([id]) => id);
471
+ //`role`/`name` on this tag name the role being assigned, not a unit to match, so the
472
+ //filter must not read either back as one - the same double duty `[store_unit]`'s
473
+ //`variable`/`name` has
474
+ const filter = withoutAttributes(role.attributes, 'role', 'name');
475
+ const matched = Object.entries(this.world.units).filter(([id, unit]) => unit.alive && unitMatchesFilter(unit, id, filter));
476
+ //a unit learns the role it was assigned, so a later `[filter] role=courier` finds it
477
+ for (const [, unit] of matched)
478
+ unit.role = name;
479
+ (this.world.roles ??= {})[name] = matched.map(([id]) => id);
425
480
  }
426
481
  }
427
482
  buildMap(id, node) {
@@ -459,6 +514,13 @@ export class MwlRuntime {
459
514
  }
460
515
  if (unit.attributes.side !== undefined)
461
516
  entry.side = unit.attributes.side;
517
+ if (unit.attributes.name !== undefined)
518
+ entry.name = unit.attributes.name;
519
+ if (unit.attributes.role !== undefined)
520
+ entry.role = unit.attributes.role;
521
+ const can_recruit = booleanAttribute(unit, 'can_recruit');
522
+ if (can_recruit !== undefined)
523
+ entry.can_recruit = can_recruit;
462
524
  this.world.units[id] = entry;
463
525
  }
464
526
  }
@@ -486,6 +548,8 @@ export class MwlRuntime {
486
548
  type: leader,
487
549
  side: sideId,
488
550
  moves: stats?.movement ?? 0,
551
+ can_recruit: true,
552
+ leader: true,
489
553
  };
490
554
  }
491
555
  }
@@ -528,7 +592,7 @@ export class MwlRuntime {
528
592
  }
529
593
  case 'store_unit': {
530
594
  const variable = node.attributes.variable ?? node.attributes.name ?? required(node, 'variable');
531
- this.setVariableAt(variable, this.matchingUnits(node).map(([id, unit]) => ({ id, ...unitSnapshot(unit) })));
595
+ this.setVariableAt(variable, this.matchingUnits(node, ['variable', 'name']).map(([id, unit]) => ({ id, ...unitSnapshot(unit) })));
532
596
  break;
533
597
  }
534
598
  case 'unstore_unit': {
@@ -598,7 +662,12 @@ export class MwlRuntime {
598
662
  const name = node.attributes.role ?? node.attributes.name;
599
663
  if (!name)
600
664
  throw new Error('[role] requires role');
601
- (this.world.roles ??= {})[name] = this.matchingUnits(node).map(([id]) => id);
665
+ //`role`/`name` here name the role being assigned, not a unit to match, so the filter
666
+ //must not read either back as one - the same exclusion the scenario-level role uses
667
+ const matched = this.matchingUnits(node, ['role', 'name']);
668
+ for (const [, unit] of matched)
669
+ unit.role = name;
670
+ (this.world.roles ??= {})[name] = matched.map(([id]) => id);
602
671
  break;
603
672
  }
604
673
  case 'attack':
@@ -618,8 +687,7 @@ export class MwlRuntime {
618
687
  if (limit < 1 || limit > 100_000)
619
688
  throw new Error('MWL while max_iterations must be between 1 and 100000');
620
689
  for (let iteration = 0; iteration < limit && this.nodeConditionMatches(node); iteration++)
621
- for (const child of node.children)
622
- this.executeNode(child);
690
+ this.runBlock(this.commandChildren(node));
623
691
  break;
624
692
  }
625
693
  case 'foreach': {
@@ -639,8 +707,7 @@ export class MwlRuntime {
639
707
  for (let index = 0; index < entries.length; index++) {
640
708
  this.setVariableAt(item, entries[index][1]);
641
709
  this.setVariableAt(indexName, index);
642
- for (const child of node.children)
643
- this.executeNode(child);
710
+ this.runBlock(this.commandChildren(node));
644
711
  }
645
712
  break;
646
713
  }
@@ -650,8 +717,7 @@ export class MwlRuntime {
650
717
  child.attributes.equals !== undefined &&
651
718
  sameValue(value, child.attributes.equals));
652
719
  const fallback = node.children.find((child) => child.tag === 'default');
653
- for (const child of (selected ?? fallback)?.children ?? [])
654
- this.executeNode(child);
720
+ this.runBlock((selected ?? fallback)?.children ?? []);
655
721
  break;
656
722
  }
657
723
  case 'end_turn':
@@ -677,15 +743,17 @@ export class MwlRuntime {
677
743
  });
678
744
  break;
679
745
  }
680
- case 'if':
681
- if (!this.nodeConditionMatches(node))
682
- break;
683
- for (const child of node.children)
684
- this.executeNode(child);
746
+ case 'if': {
747
+ //an immediately following [else] is this branch's other half, not a separate
748
+ //command: the condition picks one body, and runBlock skips the [else] itself
749
+ const fallback = node.children.find((child) => child.tag === 'else');
750
+ const taken = this.nodeConditionMatches(node) ? node : fallback;
751
+ this.runBlock(taken ? this.commandChildren(taken) : []);
685
752
  break;
753
+ }
686
754
  case 'else':
687
- for (const child of node.children)
688
- this.executeNode(child);
755
+ //reachable at top level for a free-standing [else]; a paired one is run by its [if]
756
+ this.runBlock(this.commandChildren(node));
689
757
  break;
690
758
  case 'hook':
691
759
  this.runHook(node);
@@ -694,6 +762,15 @@ export class MwlRuntime {
694
762
  throw new Error(`unknown MWL command: ${name}`);
695
763
  }
696
764
  }
765
+ /**
766
+ * A node's children that are commands to run, dropping the condition children the schema
767
+ * allows a branch to carry. A `[condition]` is read by the branch itself, never executed:
768
+ * `[if]`/`[while]` evaluate one through `nodeConditionMatches`, where running it as a command
769
+ * would be an unknown tag.
770
+ */
771
+ commandChildren(node) {
772
+ return node.children.filter((child) => child.tag !== 'condition' && child.tag !== 'filter' && child.tag !== 'filter_condition');
773
+ }
697
774
  runHook(node) {
698
775
  const reference = node.attributes.name ?? '';
699
776
  const hook = this.hooks?.command?.[reference];
@@ -741,8 +818,7 @@ export class MwlRuntime {
741
818
  * same interpolation WML authors expect in story and dialogue strings.
742
819
  */
743
820
  interpolate(text) {
744
- const variables = this.world.variables;
745
- const numeric = Object.fromEntries(Object.entries(variables).filter((entry) => typeof entry[1] === 'number'));
821
+ const numeric = Object.fromEntries(Object.entries(this.world.variables).filter((entry) => typeof entry[1] === 'number'));
746
822
  const expanded = text.replace(/\$\(([^)]*)\)/g, (_whole, expression) => {
747
823
  try {
748
824
  return String(evaluateExpression(expression, numeric));
@@ -752,7 +828,7 @@ export class MwlRuntime {
752
828
  }
753
829
  });
754
830
  return expanded.replace(/\$([A-Za-z_][A-Za-z0-9_.]*)/g, (_whole, name) => {
755
- const value = variables[name];
831
+ const value = this.variableAt(name);
756
832
  return value === undefined || typeof value === 'boolean' ? '' : String(value);
757
833
  });
758
834
  }
@@ -766,7 +842,7 @@ export class MwlRuntime {
766
842
  // so content can alias changing values without arithmetic.
767
843
  const reference = /^\$([A-Za-z_][A-Za-z0-9_.]*)$/.exec(raw.trim());
768
844
  if (reference) {
769
- const value = this.world.variables[reference[1]];
845
+ const value = this.variableAt(reference[1]);
770
846
  this.setVariableAt(name, value === undefined ? '' : value);
771
847
  return;
772
848
  }
@@ -778,30 +854,11 @@ export class MwlRuntime {
778
854
  this.setVariableAt(name, raw);
779
855
  }
780
856
  variableAt(path) {
781
- let value = this.world.variables;
782
- for (const part of path.split('.')) {
783
- if (!value || typeof value !== 'object' || Array.isArray(value))
784
- return undefined;
785
- value = value[part];
786
- }
787
- return value;
857
+ return variableAtPath(this.world.variables, path);
788
858
  }
789
859
  setVariableAt(path, value) {
790
860
  const previous = this.variableAt(path);
791
- const parts = path.split('.');
792
- if (parts.length === 1) {
793
- this.world.variables[path] = value;
794
- this.onTrace?.({ type: 'variable', name: path, ...(previous === undefined ? {} : { previous }), value });
795
- return;
796
- }
797
- let current = this.world.variables;
798
- for (const part of parts.slice(0, -1)) {
799
- const child = current[part];
800
- if (!child || typeof child !== 'object' || Array.isArray(child))
801
- current[part] = {};
802
- current = current[part];
803
- }
804
- current[parts.at(-1)] = value;
861
+ setVariableAtPath(this.world.variables, path, value);
805
862
  this.onTrace?.({ type: 'variable', name: path, ...(previous === undefined ? {} : { previous }), value });
806
863
  }
807
864
  spawnUnit(type, side, x, y, id, hp) {
@@ -828,14 +885,16 @@ export class MwlRuntime {
828
885
  * own attributes, read by the same `unitMatchesFilter` an event filter and `[kill]` use. An
829
886
  * empty filter matches every live unit, the rule `[kill]` already documents.
830
887
  */
831
- matchingUnits(node) {
888
+ matchingUnits(node, exclude = []) {
832
889
  const filter = node.children.find((child) => child.tag === 'filter');
833
- const attributes = filter?.attributes ?? node.attributes;
890
+ //a `[filter]` child is a real unit filter and keeps every attribute; the node's own
891
+ //attributes are the fallback, minus whatever they mean to this command instead of a unit
892
+ const attributes = filter ? filter.attributes : withoutAttributes(node.attributes, ...exclude);
834
893
  return Object.entries(this.world.units).filter(([id, unit]) => unit.alive && unitMatchesFilter(unit, id, attributes));
835
894
  }
836
895
  /** The unit snapshots `[store_unit]` wrote into a world variable, or a named content error. */
837
896
  storedUnits(variable) {
838
- const stored = this.world.variables[variable];
897
+ const stored = this.variableAt(variable);
839
898
  if (!Array.isArray(stored))
840
899
  throw new Error(`MWL variable ${variable} is not a stored unit list; store one first`);
841
900
  return stored.filter((entry) => !!entry && typeof entry === 'object' && !Array.isArray(entry));
@@ -858,6 +917,14 @@ export class MwlRuntime {
858
917
  unit.side = side;
859
918
  if (typeof entry.moves === 'number')
860
919
  unit.moves = entry.moves;
920
+ if (typeof entry.name === 'string')
921
+ unit.name = entry.name;
922
+ if (typeof entry.role === 'string')
923
+ unit.role = entry.role;
924
+ if (typeof entry.can_recruit === 'boolean')
925
+ unit.can_recruit = entry.can_recruit;
926
+ if (typeof entry.leader === 'boolean')
927
+ unit.leader = entry.leader;
861
928
  this.world.units[id] = unit;
862
929
  }
863
930
  /** Changes one cell of the primary map, keeping the parsed grid and its size in step. */
@@ -925,7 +992,20 @@ export class MwlRuntime {
925
992
  conditionMatches(node) {
926
993
  return node.children
927
994
  .filter((child) => child.tag === 'condition')
928
- .every((condition) => variableMatches(this.world.variables[condition.attributes.variable], condition.attributes, primitiveVariables(this.world.variables)));
995
+ .every((condition) => {
996
+ if (condition.attributes.condition === 'hook') {
997
+ const hookName = condition.attributes.hook;
998
+ const predicateName = hookName?.startsWith('predicate:')
999
+ ? hookName.slice('predicate:'.length)
1000
+ : undefined;
1001
+ const predicate = predicateName ? this.hooks?.predicate?.[predicateName] : undefined;
1002
+ if (!predicate)
1003
+ throw new Error(`MWL predicate ${predicateName ?? '<missing>'} is not implemented`);
1004
+ const { condition: _condition, hook: _hook, ...context } = condition.attributes;
1005
+ return Boolean(predicate(this.worldView(), context));
1006
+ }
1007
+ return variableMatches(this.variableAt(condition.attributes.variable), condition.attributes, resolveVariable(this.world.variables));
1008
+ });
929
1009
  }
930
1010
  nodeConditionMatches(node) {
931
1011
  if (node.attributes.test !== undefined)
@@ -947,7 +1027,7 @@ export class MwlRuntime {
947
1027
  const name = child.attributes.name;
948
1028
  if (name === undefined)
949
1029
  return false;
950
- return variableMatches(this.world.variables[name], child.attributes, primitiveVariables(this.world.variables));
1030
+ return variableMatches(this.variableAt(name), child.attributes, resolveVariable(this.world.variables));
951
1031
  }
952
1032
  if (child.tag === 'predicate') {
953
1033
  const name = child.attributes.name;
@@ -1087,7 +1167,7 @@ export class MwlRuntime {
1087
1167
  kill: (unit) => this.killUnit(unit),
1088
1168
  gold: (side, delta) => this.addGold(side, delta),
1089
1169
  setVariable: (name, value) => {
1090
- this.world.variables[name] = value;
1170
+ setVariableAtPath(this.world.variables, name, value);
1091
1171
  },
1092
1172
  message: (speaker, text) => this.onMessage?.({ text, ...(speaker ? { speaker } : {}) }),
1093
1173
  endTurn: () => this.endTurn(),
@@ -1120,12 +1200,24 @@ export class MwlRuntime {
1120
1200
  return result;
1121
1201
  }
1122
1202
  }
1203
+ /**
1204
+ * Applies one already-validated `MwlCommand` to a world, without a runtime around it.
1205
+ *
1206
+ * @example
1207
+ * ```ts
1208
+ * import { createWorld, execute } from '@datamoc/mw_games/mwl';
1209
+ *
1210
+ * const world = createWorld();
1211
+ * execute(world, { name: 'set_variable', target: 'gold', value: 50 });
1212
+ * console.log(world.variables.gold); // 50
1213
+ * ```
1214
+ */
1123
1215
  export function execute(world, command) {
1124
1216
  if (world.status !== 'playing' && command.name !== 'win' && command.name !== 'lose')
1125
1217
  return;
1126
1218
  switch (command.name) {
1127
1219
  case 'set_variable':
1128
- world.variables[command.target] = command.value;
1220
+ setVariableAtPath(world.variables, command.target, command.value);
1129
1221
  break;
1130
1222
  case 'modify_gold':
1131
1223
  world.gold[command.target] = (world.gold[command.target] ?? 0) + command.amount;
@@ -1234,6 +1326,14 @@ function unitSnapshot(unit) {
1234
1326
  snapshot.side = unit.side;
1235
1327
  if (unit.moves !== undefined)
1236
1328
  snapshot.moves = unit.moves;
1329
+ if (unit.name !== undefined)
1330
+ snapshot.name = unit.name;
1331
+ if (unit.role !== undefined)
1332
+ snapshot.role = unit.role;
1333
+ if (unit.can_recruit !== undefined)
1334
+ snapshot.can_recruit = unit.can_recruit;
1335
+ if (unit.leader !== undefined)
1336
+ snapshot.leader = unit.leader;
1237
1337
  return snapshot;
1238
1338
  }
1239
1339
  /** Applies `[modify_unit]`'s chosen attributes to one unit, ignoring anything it did not name. */
@@ -1248,8 +1348,16 @@ function applyUnitChanges(unit, changes) {
1248
1348
  unit.type = changes.type;
1249
1349
  if (changes.side !== undefined)
1250
1350
  unit.side = changes.side;
1251
- if (changes.alive !== undefined)
1252
- unit.alive = changes.alive === 'yes' || changes.alive === 'true';
1351
+ const alive = booleanValue(changes.alive);
1352
+ if (alive !== undefined)
1353
+ unit.alive = alive;
1354
+ if (changes.name !== undefined)
1355
+ unit.name = changes.name;
1356
+ if (changes.role !== undefined)
1357
+ unit.role = changes.role;
1358
+ const canRecruit = booleanValue(changes.can_recruit);
1359
+ if (canRecruit !== undefined)
1360
+ unit.can_recruit = canRecruit;
1253
1361
  }
1254
1362
  function finiteNumber(value) {
1255
1363
  if (value === undefined || value === '')
@@ -1270,12 +1378,19 @@ function optionalInteger(node, attribute) {
1270
1378
  * lists split on commas, comparisons coerce both sides with `Number` and
1271
1379
  * fail on anything non-numeric.
1272
1380
  */
1273
- function variableMatches(value, attributes, variables = {}) {
1274
- const resolve = (expected) => expected.startsWith('$') ? variables[expected.slice(1)] : expected;
1381
+ function variableMatches(value, attributes, resolve) {
1382
+ const expected = (text) => {
1383
+ if (!text.startsWith('$'))
1384
+ return text;
1385
+ const resolved = resolve(text.slice(1));
1386
+ return typeof resolved === 'string' || typeof resolved === 'number' || typeof resolved === 'boolean'
1387
+ ? resolved
1388
+ : undefined;
1389
+ };
1275
1390
  if (attributes.equals !== undefined)
1276
- return sameValue(value, resolve(attributes.equals));
1391
+ return sameValue(value, expected(attributes.equals));
1277
1392
  if (attributes.not_equals !== undefined)
1278
- return !sameValue(value, resolve(attributes.not_equals));
1393
+ return !sameValue(value, expected(attributes.not_equals));
1279
1394
  if (attributes.in !== undefined) {
1280
1395
  const options = attributes.in.split(',').map((entry) => entry.trim());
1281
1396
  return options.some((option) => sameValue(value, option));
@@ -1306,22 +1421,38 @@ function variableMatches(value, attributes, variables = {}) {
1306
1421
  }
1307
1422
  /**
1308
1423
  * One alive unit against `filter`/`have_unit` attributes: side, type (with
1309
- * comma-list `not_type` exclusion), world key (`unit`, or `id` on
1310
- * `have_unit`), and coordinates all have to match when present.
1424
+ * comma-list `not_type` exclusion), the unit's own name and role, whether it
1425
+ * can recruit, whether it is its side's leader, world key (`unit`, or `id`
1426
+ * on `have_unit`), and coordinates all have to match when present.
1311
1427
  */
1312
1428
  function unitMatchesFilter(unit, id, attributes) {
1313
1429
  const excluded = (attributes.not_type ?? '')
1314
1430
  .split(',')
1315
1431
  .map((entry) => entry.trim())
1316
1432
  .filter(Boolean);
1433
+ const wantedCanRecruit = booleanValue(attributes.can_recruit);
1434
+ const wantedLeader = booleanValue(attributes.leader);
1317
1435
  const key = attributes.unit ?? attributes.id;
1318
1436
  return ((key === undefined || id === key) &&
1319
1437
  (attributes.side === undefined || unit.side === attributes.side) &&
1320
1438
  (attributes.type === undefined || unit.type === attributes.type) &&
1321
1439
  (excluded.length === 0 || !excluded.includes(unit.type ?? '')) &&
1440
+ (attributes.name === undefined || unit.name === attributes.name) &&
1441
+ (attributes.role === undefined || unit.role === attributes.role) &&
1442
+ (wantedCanRecruit === undefined || (unit.can_recruit ?? false) === wantedCanRecruit) &&
1443
+ (wantedLeader === undefined || (unit.leader ?? false) === wantedLeader) &&
1322
1444
  (attributes.x === undefined || unit.x === Number(attributes.x)) &&
1323
1445
  (attributes.y === undefined || unit.y === Number(attributes.y)));
1324
1446
  }
1447
+ /** a copy of `attributes` without the given keys, for a command whose own attribute names are not a unit filter */
1448
+ function withoutAttributes(attributes, ...excluded) {
1449
+ if (excluded.length === 0)
1450
+ return { ...attributes };
1451
+ const copy = { ...attributes };
1452
+ for (const key of excluded)
1453
+ delete copy[key];
1454
+ return copy;
1455
+ }
1325
1456
  function sameValue(value, expected) {
1326
1457
  if (value === undefined || expected === undefined)
1327
1458
  return value === expected;
@@ -1335,6 +1466,73 @@ function sameValue(value, expected) {
1335
1466
  function primitiveVariables(variables) {
1336
1467
  return Object.fromEntries(Object.entries(variables).filter((entry) => typeof entry[1] === 'string' || typeof entry[1] === 'number' || typeof entry[1] === 'boolean'));
1337
1468
  }
1469
+ /** `a.b[0].c` -> `['a', 'b', { index: 0 }, 'c']`. */
1470
+ function variablePathParts(path) {
1471
+ return path
1472
+ .split('.')
1473
+ .flatMap((segment) => {
1474
+ const name = /^([^[\]]*)((?:\[[^\]]*\])*)$/.exec(segment);
1475
+ if (!name)
1476
+ return [segment];
1477
+ const parts = name[1] === '' ? [] : [name[1]];
1478
+ for (const bracket of name[2].matchAll(/\[([^\]]*)\]/g)) {
1479
+ const index = Number(bracket[1]);
1480
+ parts.push({ index: /^\d+$/.test(bracket[1].trim()) ? index : undefined });
1481
+ }
1482
+ return parts;
1483
+ })
1484
+ .filter((part) => typeof part !== 'string' || part !== '');
1485
+ }
1486
+ /** The path parts of a write, with any bracketed index that is not a whole number refused by name. */
1487
+ function validVariablePathParts(path) {
1488
+ const parts = variablePathParts(path);
1489
+ if (parts.some((part) => typeof part !== 'string' && part.index === undefined))
1490
+ throw new Error(`invalid variable path: ${path}`);
1491
+ return parts;
1492
+ }
1493
+ function isIndexedStep(step) {
1494
+ return typeof step !== 'string';
1495
+ }
1496
+ /**
1497
+ * The one write of a variable path, so `execute()`, the hook host and the runtime's own
1498
+ * `set_variable` all place a value at the same node a read walks to. A step after an index
1499
+ * grows an array, so `a[0]`/`a[1]` build a real list rather than an object with numbered keys.
1500
+ */
1501
+ function setVariableAtPath(variables, path, value) {
1502
+ const parts = validVariablePathParts(path);
1503
+ const key = (step) => (typeof step === 'string' ? step : step.index);
1504
+ let current = variables;
1505
+ for (let index = 0; index < parts.length - 1; index++) {
1506
+ const step = key(parts[index]);
1507
+ const child = current[step];
1508
+ if (!child || typeof child !== 'object') {
1509
+ current[step] = isIndexedStep(parts[index + 1]) ? [] : {};
1510
+ }
1511
+ current = current[step];
1512
+ }
1513
+ current[key(parts[parts.length - 1])] = value;
1514
+ }
1515
+ /** The one read of a variable path, used by every reader that takes a name. */
1516
+ function variableAtPath(variables, path) {
1517
+ let value = variables;
1518
+ for (const part of variablePathParts(path)) {
1519
+ if (typeof part === 'string') {
1520
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1521
+ return undefined;
1522
+ value = value[part];
1523
+ }
1524
+ else {
1525
+ if (!Array.isArray(value) || part.index === undefined)
1526
+ return undefined;
1527
+ value = value[part.index];
1528
+ }
1529
+ }
1530
+ return value;
1531
+ }
1532
+ /** Resolves a `$name` reference (a path, not only a top-level key) from a variable tree. */
1533
+ function resolveVariable(variables) {
1534
+ return (reference) => variableAtPath(variables, reference);
1535
+ }
1338
1536
  function coordinateMatches(specification, coordinate) {
1339
1537
  return specification.split(',').some((part) => {
1340
1538
  const value = part.trim();