@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.
- package/CHANGELOG.md +92 -3
- package/README.md +7 -7
- package/dist/actors/Shop.d.ts.map +1 -1
- package/dist/actors/Shop.js +4 -1
- package/dist/actors/Shop.js.map +1 -1
- package/dist/ai/Heuristics.d.ts +8 -0
- package/dist/ai/Heuristics.d.ts.map +1 -1
- package/dist/ai/Heuristics.js.map +1 -1
- package/dist/ai/index.d.ts +2 -2
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +1 -1
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/lua.d.ts +21 -0
- package/dist/ai/lua.d.ts.map +1 -1
- package/dist/ai/lua.js +21 -0
- package/dist/ai/lua.js.map +1 -1
- package/dist/ai/score.d.ts +36 -0
- package/dist/ai/score.d.ts.map +1 -1
- package/dist/ai/score.js +26 -0
- package/dist/ai/score.js.map +1 -1
- package/dist/battle/BattleStats.d.ts.map +1 -1
- package/dist/battle/BattleStats.js.map +1 -1
- package/dist/board/Classics.d.ts.map +1 -1
- package/dist/board/Classics.js +51 -39
- package/dist/board/Classics.js.map +1 -1
- package/dist/board/HexSkirmish.d.ts.map +1 -1
- package/dist/board/HexSkirmish.js +69 -20
- package/dist/board/HexSkirmish.js.map +1 -1
- package/dist/board/Tactics.d.ts +7 -0
- package/dist/board/Tactics.d.ts.map +1 -1
- package/dist/board/Tactics.js +2 -2
- package/dist/board/Tactics.js.map +1 -1
- package/dist/core/Achievements.d.ts.map +1 -1
- package/dist/core/Achievements.js +3 -1
- package/dist/core/Achievements.js.map +1 -1
- package/dist/mw_games.global.js +20 -20
- package/dist/mwl/actors.d.ts +46 -2
- package/dist/mwl/actors.d.ts.map +1 -1
- package/dist/mwl/actors.js +46 -2
- package/dist/mwl/actors.js.map +1 -1
- package/dist/mwl/carryover.d.ts +7 -0
- package/dist/mwl/carryover.d.ts.map +1 -1
- package/dist/mwl/carryover.js +7 -0
- package/dist/mwl/carryover.js.map +1 -1
- package/dist/mwl/catalog.d.ts +10 -1
- package/dist/mwl/catalog.d.ts.map +1 -1
- package/dist/mwl/catalog.js +10 -1
- package/dist/mwl/catalog.js.map +1 -1
- package/dist/mwl/compiler.d.ts +70 -3
- package/dist/mwl/compiler.d.ts.map +1 -1
- package/dist/mwl/compiler.js +70 -3
- package/dist/mwl/compiler.js.map +1 -1
- package/dist/mwl/conditions.d.ts +10 -1
- package/dist/mwl/conditions.d.ts.map +1 -1
- package/dist/mwl/conditions.js +10 -1
- package/dist/mwl/conditions.js.map +1 -1
- package/dist/mwl/content.d.ts +11 -0
- package/dist/mwl/content.d.ts.map +1 -1
- package/dist/mwl/content.js +11 -0
- package/dist/mwl/content.js.map +1 -1
- package/dist/mwl/expression.d.ts +21 -0
- package/dist/mwl/expression.d.ts.map +1 -1
- package/dist/mwl/expression.js +21 -0
- package/dist/mwl/expression.js.map +1 -1
- package/dist/mwl/fengari.d.ts +12 -1
- package/dist/mwl/fengari.d.ts.map +1 -1
- package/dist/mwl/fengari.js +12 -1
- package/dist/mwl/fengari.js.map +1 -1
- package/dist/mwl/grammar.d.ts +45 -1
- package/dist/mwl/grammar.d.ts.map +1 -1
- package/dist/mwl/grammar.js +45 -1
- package/dist/mwl/grammar.js.map +1 -1
- package/dist/mwl/hooks.d.ts +45 -1
- package/dist/mwl/hooks.d.ts.map +1 -1
- package/dist/mwl/hooks.js +45 -1
- package/dist/mwl/hooks.js.map +1 -1
- package/dist/mwl/index.d.ts +1 -1
- package/dist/mwl/index.d.ts.map +1 -1
- package/dist/mwl/index.js +1 -1
- package/dist/mwl/index.js.map +1 -1
- package/dist/mwl/persistence.d.ts +32 -0
- package/dist/mwl/persistence.d.ts.map +1 -1
- package/dist/mwl/persistence.js +32 -0
- package/dist/mwl/persistence.js.map +1 -1
- package/dist/mwl/readers.d.ts +24 -2
- package/dist/mwl/readers.d.ts.map +1 -1
- package/dist/mwl/readers.js +27 -4
- package/dist/mwl/readers.js.map +1 -1
- package/dist/mwl/report.d.ts +21 -2
- package/dist/mwl/report.d.ts.map +1 -1
- package/dist/mwl/report.js +21 -2
- package/dist/mwl/report.js.map +1 -1
- package/dist/mwl/runtime.d.ts +72 -0
- package/dist/mwl/runtime.d.ts.map +1 -1
- package/dist/mwl/runtime.js +266 -68
- package/dist/mwl/runtime.js.map +1 -1
- package/dist/mwl/schema.d.ts +57 -1
- package/dist/mwl/schema.d.ts.map +1 -1
- package/dist/mwl/schema.js +85 -4
- package/dist/mwl/schema.js.map +1 -1
- package/dist/mwl/scripts.d.ts +12 -1
- package/dist/mwl/scripts.d.ts.map +1 -1
- package/dist/mwl/scripts.js +12 -1
- package/dist/mwl/scripts.js.map +1 -1
- package/dist/mwl/utils.d.ts +2 -0
- package/dist/mwl/utils.d.ts.map +1 -1
- package/dist/mwl/utils.js +4 -1
- package/dist/mwl/utils.js.map +1 -1
- package/dist/rpg/GameState.d.ts +2 -2
- package/dist/rpg/GameState.js +2 -2
- package/dist/two-d/render/ColorTransformBatcher.d.ts +7 -1
- package/dist/two-d/render/ColorTransformBatcher.d.ts.map +1 -1
- package/dist/two-d/render/ColorTransformBatcher.js +10 -1
- package/dist/two-d/render/ColorTransformBatcher.js.map +1 -1
- package/dist/two-d/render/ImageModifiers.d.ts.map +1 -1
- package/dist/two-d/render/ImageModifiers.js +20 -4
- package/dist/two-d/render/ImageModifiers.js.map +1 -1
- package/dist/two-d/render/TerrainGraphics.d.ts.map +1 -1
- package/dist/two-d/render/TerrainGraphics.js.map +1 -1
- package/dist/two-d/render/TerrainGraphicsLayer.d.ts +55 -0
- package/dist/two-d/render/TerrainGraphicsLayer.d.ts.map +1 -0
- package/dist/two-d/render/TerrainGraphicsLayer.js +54 -0
- package/dist/two-d/render/TerrainGraphicsLayer.js.map +1 -0
- package/dist/two-d/render/TintedSprite.js +2 -2
- package/dist/two-d/render/TintedSprite.js.map +1 -1
- package/dist/two-d/render/index.d.ts +3 -1
- package/dist/two-d/render/index.d.ts.map +1 -1
- package/dist/two-d/render/index.js +1 -0
- package/dist/two-d/render/index.js.map +1 -1
- package/dist/two-d/ui/MarkupText.d.ts +53 -0
- package/dist/two-d/ui/MarkupText.d.ts.map +1 -0
- package/dist/two-d/ui/MarkupText.js +122 -0
- package/dist/two-d/ui/MarkupText.js.map +1 -0
- package/dist/two-d/ui/index.d.ts +4 -2
- package/dist/two-d/ui/index.d.ts.map +1 -1
- package/dist/two-d/ui/index.js +2 -1
- package/dist/two-d/ui/index.js.map +1 -1
- package/dist/two-d/ui/markup.d.ts +54 -0
- package/dist/two-d/ui/markup.d.ts.map +1 -1
- package/dist/two-d/ui/markup.js +49 -1
- package/dist/two-d/ui/markup.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/mwl/runtime.js
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 (
|
|
164
|
-
this.showDialogue(
|
|
203
|
+
if (child.tag === 'dialogue') {
|
|
204
|
+
this.showDialogue(child);
|
|
165
205
|
continue;
|
|
166
206
|
}
|
|
167
|
-
this.executeNode(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
688
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
1252
|
-
|
|
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,
|
|
1274
|
-
const
|
|
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,
|
|
1391
|
+
return sameValue(value, expected(attributes.equals));
|
|
1277
1392
|
if (attributes.not_equals !== undefined)
|
|
1278
|
-
return !sameValue(value,
|
|
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),
|
|
1310
|
-
*
|
|
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();
|