@datamoc/mw_games 0.7.6 → 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 +359 -0
- package/README.md +7 -7
- package/dist/actors/Inventory.d.ts +7 -1
- package/dist/actors/Inventory.d.ts.map +1 -1
- package/dist/actors/Inventory.js.map +1 -1
- 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/actors/craft.d.ts +35 -14
- package/dist/actors/craft.d.ts.map +1 -1
- package/dist/actors/craft.js +56 -24
- package/dist/actors/craft.js.map +1 -1
- package/dist/actors/index.d.ts +1 -1
- package/dist/actors/index.d.ts.map +1 -1
- package/dist/ai/Heuristics.d.ts +240 -0
- package/dist/ai/Heuristics.d.ts.map +1 -0
- package/dist/ai/Heuristics.js +298 -0
- package/dist/ai/Heuristics.js.map +1 -0
- package/dist/ai/index.d.ts +4 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +2 -0
- 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 +162 -0
- package/dist/ai/score.d.ts.map +1 -0
- package/dist/ai/score.js +147 -0
- package/dist/ai/score.js.map +1 -0
- package/dist/assets/loader.d.ts +18 -4
- package/dist/assets/loader.d.ts.map +1 -1
- package/dist/assets/loader.js +40 -11
- package/dist/assets/loader.js.map +1 -1
- package/dist/audio/Positional.d.ts +125 -0
- package/dist/audio/Positional.d.ts.map +1 -0
- package/dist/audio/Positional.js +135 -0
- package/dist/audio/Positional.js.map +1 -0
- package/dist/audio/Sound.d.ts +7 -1
- package/dist/audio/Sound.d.ts.map +1 -1
- package/dist/audio/Sound.js +8 -2
- package/dist/audio/Sound.js.map +1 -1
- package/dist/audio/index.d.ts +2 -0
- package/dist/audio/index.d.ts.map +1 -1
- package/dist/audio/index.js +1 -0
- package/dist/audio/index.js.map +1 -1
- package/dist/battle/AttackDialog.d.ts +75 -0
- package/dist/battle/AttackDialog.d.ts.map +1 -0
- package/dist/battle/AttackDialog.js +115 -0
- package/dist/battle/AttackDialog.js.map +1 -0
- package/dist/battle/AttackPreview.d.ts +70 -0
- package/dist/battle/AttackPreview.d.ts.map +1 -0
- package/dist/battle/AttackPreview.js +78 -0
- package/dist/battle/AttackPreview.js.map +1 -0
- package/dist/battle/BattleStats.d.ts +45 -0
- package/dist/battle/BattleStats.d.ts.map +1 -0
- package/dist/battle/BattleStats.js +63 -0
- package/dist/battle/BattleStats.js.map +1 -0
- package/dist/battle/UnitSelector.d.ts +72 -0
- package/dist/battle/UnitSelector.d.ts.map +1 -0
- package/dist/battle/UnitSelector.js +117 -0
- package/dist/battle/UnitSelector.js.map +1 -0
- package/dist/battle/Whiteboard.d.ts +50 -0
- package/dist/battle/Whiteboard.d.ts.map +1 -0
- package/dist/battle/Whiteboard.js +94 -0
- package/dist/battle/Whiteboard.js.map +1 -0
- package/dist/battle/index.d.ts +10 -0
- package/dist/battle/index.d.ts.map +1 -1
- package/dist/battle/index.js +5 -0
- package/dist/battle/index.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/FogOfWar.d.ts +32 -2
- package/dist/board/FogOfWar.d.ts.map +1 -1
- package/dist/board/FogOfWar.js +70 -6
- package/dist/board/FogOfWar.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 +40 -7
- package/dist/core/Achievements.d.ts.map +1 -1
- package/dist/core/Achievements.js +53 -7
- package/dist/core/Achievements.js.map +1 -1
- package/dist/core/Hex.d.ts +26 -6
- package/dist/core/Hex.d.ts.map +1 -1
- package/dist/core/Hex.js +40 -13
- package/dist/core/Hex.js.map +1 -1
- package/dist/core/Input.d.ts +36 -0
- package/dist/core/Input.d.ts.map +1 -1
- package/dist/core/Input.js +63 -1
- package/dist/core/Input.js.map +1 -1
- package/dist/core/Random.d.ts +93 -0
- package/dist/core/Random.d.ts.map +1 -1
- package/dist/core/Random.js +177 -0
- package/dist/core/Random.js.map +1 -1
- package/dist/core/SyncGuard.d.ts +57 -0
- package/dist/core/SyncGuard.d.ts.map +1 -0
- package/dist/core/SyncGuard.js +116 -0
- package/dist/core/SyncGuard.js.map +1 -0
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -1
- package/dist/core/index.js.map +1 -1
- package/dist/i18n/Po.d.ts +51 -0
- package/dist/i18n/Po.d.ts.map +1 -0
- package/dist/i18n/Po.js +176 -0
- package/dist/i18n/Po.js.map +1 -0
- package/dist/i18n/index.d.ts +2 -0
- package/dist/i18n/index.d.ts.map +1 -1
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/index.js.map +1 -1
- package/dist/mw_games.global.js +104 -98
- package/dist/mwl/MapFile.d.ts +41 -0
- package/dist/mwl/MapFile.d.ts.map +1 -0
- package/dist/mwl/MapFile.js +43 -0
- package/dist/mwl/MapFile.js.map +1 -0
- 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/campaign.d.ts +51 -0
- package/dist/mwl/campaign.d.ts.map +1 -0
- package/dist/mwl/campaign.js +58 -0
- package/dist/mwl/campaign.js.map +1 -0
- package/dist/mwl/carryover.d.ts +86 -0
- package/dist/mwl/carryover.d.ts.map +1 -0
- package/dist/mwl/carryover.js +70 -0
- package/dist/mwl/carryover.js.map +1 -0
- 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 +21 -0
- package/dist/mwl/content.d.ts.map +1 -1
- package/dist/mwl/content.js +19 -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 +51 -6
- 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 +9 -3
- package/dist/mwl/index.d.ts.map +1 -1
- package/dist/mwl/index.js +5 -2
- 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 +212 -7
- package/dist/mwl/runtime.d.ts.map +1 -1
- package/dist/mwl/runtime.js +682 -90
- 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 +181 -21
- 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/roguelike/MultiTurnBeam.d.ts +50 -12
- package/dist/roguelike/MultiTurnBeam.d.ts.map +1 -1
- package/dist/roguelike/MultiTurnBeam.js +100 -53
- package/dist/roguelike/MultiTurnBeam.js.map +1 -1
- package/dist/roguelike/Targeting.d.ts +94 -0
- package/dist/roguelike/Targeting.d.ts.map +1 -1
- package/dist/roguelike/Targeting.js +123 -0
- package/dist/roguelike/Targeting.js.map +1 -1
- package/dist/roguelike/index.d.ts +3 -1
- package/dist/roguelike/index.d.ts.map +1 -1
- package/dist/roguelike/index.js +1 -0
- package/dist/roguelike/index.js.map +1 -1
- package/dist/rpg/GameState.d.ts +2 -2
- package/dist/rpg/GameState.js +2 -2
- package/dist/simulation/CampaignSave.d.ts +68 -0
- package/dist/simulation/CampaignSave.d.ts.map +1 -0
- package/dist/simulation/CampaignSave.js +54 -0
- package/dist/simulation/CampaignSave.js.map +1 -0
- package/dist/simulation/EventPresentation.d.ts +111 -0
- package/dist/simulation/EventPresentation.d.ts.map +1 -0
- package/dist/simulation/EventPresentation.js +120 -0
- package/dist/simulation/EventPresentation.js.map +1 -0
- package/dist/simulation/index.d.ts +4 -0
- package/dist/simulation/index.d.ts.map +1 -1
- package/dist/simulation/index.js +2 -0
- package/dist/simulation/index.js.map +1 -1
- package/dist/two-d/pixi-interop.d.ts +18 -6
- package/dist/two-d/pixi-interop.d.ts.map +1 -1
- package/dist/two-d/pixi-interop.js +18 -6
- package/dist/two-d/pixi-interop.js.map +1 -1
- package/dist/two-d/render/AnimatedSprite.d.ts +74 -7
- package/dist/two-d/render/AnimatedSprite.d.ts.map +1 -1
- package/dist/two-d/render/AnimatedSprite.js +88 -26
- package/dist/two-d/render/AnimatedSprite.js.map +1 -1
- package/dist/two-d/render/Camera.d.ts +64 -4
- package/dist/two-d/render/Camera.d.ts.map +1 -1
- package/dist/two-d/render/Camera.js +132 -11
- package/dist/two-d/render/Camera.js.map +1 -1
- 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/Halo.d.ts +58 -0
- package/dist/two-d/render/Halo.d.ts.map +1 -0
- package/dist/two-d/render/Halo.js +48 -0
- package/dist/two-d/render/Halo.js.map +1 -0
- package/dist/two-d/render/ImageModifiers.d.ts +174 -0
- package/dist/two-d/render/ImageModifiers.d.ts.map +1 -1
- package/dist/two-d/render/ImageModifiers.js +454 -1
- package/dist/two-d/render/ImageModifiers.js.map +1 -1
- package/dist/two-d/render/LightningArc.d.ts +57 -0
- package/dist/two-d/render/LightningArc.d.ts.map +1 -0
- package/dist/two-d/render/LightningArc.js +91 -0
- package/dist/two-d/render/LightningArc.js.map +1 -0
- package/dist/two-d/render/PaletteRemap.d.ts +135 -0
- package/dist/two-d/render/PaletteRemap.d.ts.map +1 -0
- package/dist/two-d/render/PaletteRemap.js +181 -0
- package/dist/two-d/render/PaletteRemap.js.map +1 -0
- package/dist/two-d/render/Particles.d.ts +26 -0
- package/dist/two-d/render/Particles.d.ts.map +1 -1
- package/dist/two-d/render/Particles.js +23 -2
- package/dist/two-d/render/Particles.js.map +1 -1
- package/dist/two-d/render/SpriteAttachment.d.ts +55 -0
- package/dist/two-d/render/SpriteAttachment.d.ts.map +1 -0
- package/dist/two-d/render/SpriteAttachment.js +60 -0
- package/dist/two-d/render/SpriteAttachment.js.map +1 -0
- package/dist/two-d/render/TerrainGraphics.d.ts +142 -0
- package/dist/two-d/render/TerrainGraphics.d.ts.map +1 -0
- package/dist/two-d/render/TerrainGraphics.js +161 -0
- package/dist/two-d/render/TerrainGraphics.js.map +1 -0
- 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 +15 -3
- package/dist/two-d/render/index.d.ts.map +1 -1
- package/dist/two-d/render/index.js +7 -1
- package/dist/two-d/render/index.js.map +1 -1
- package/dist/two-d/stage/StoryScreen.d.ts +110 -0
- package/dist/two-d/stage/StoryScreen.d.ts.map +1 -0
- package/dist/two-d/stage/StoryScreen.js +178 -0
- package/dist/two-d/stage/StoryScreen.js.map +1 -0
- package/dist/two-d/stage/index.d.ts +2 -0
- package/dist/two-d/stage/index.d.ts.map +1 -1
- package/dist/two-d/stage/index.js +1 -0
- package/dist/two-d/stage/index.js.map +1 -1
- package/dist/two-d/ui/Checkbox.d.ts +44 -0
- package/dist/two-d/ui/Checkbox.d.ts.map +1 -0
- package/dist/two-d/ui/Checkbox.js +89 -0
- package/dist/two-d/ui/Checkbox.js.map +1 -0
- package/dist/two-d/ui/DataTable.d.ts +79 -0
- package/dist/two-d/ui/DataTable.d.ts.map +1 -0
- package/dist/two-d/ui/DataTable.js +164 -0
- package/dist/two-d/ui/DataTable.js.map +1 -0
- package/dist/two-d/ui/Dropdown.d.ts +72 -0
- package/dist/two-d/ui/Dropdown.d.ts.map +1 -0
- package/dist/two-d/ui/Dropdown.js +126 -0
- package/dist/two-d/ui/Dropdown.js.map +1 -0
- package/dist/two-d/ui/FloatingText.d.ts +43 -4
- package/dist/two-d/ui/FloatingText.d.ts.map +1 -1
- package/dist/two-d/ui/FloatingText.js +52 -5
- package/dist/two-d/ui/FloatingText.js.map +1 -1
- package/dist/two-d/ui/FloatingTextStack.d.ts +86 -13
- package/dist/two-d/ui/FloatingTextStack.d.ts.map +1 -1
- package/dist/two-d/ui/FloatingTextStack.js +89 -18
- package/dist/two-d/ui/FloatingTextStack.js.map +1 -1
- package/dist/two-d/ui/Layout.d.ts +105 -0
- package/dist/two-d/ui/Layout.d.ts.map +1 -0
- package/dist/two-d/ui/Layout.js +174 -0
- package/dist/two-d/ui/Layout.js.map +1 -0
- 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/ScrollBox.d.ts +67 -0
- package/dist/two-d/ui/ScrollBox.d.ts.map +1 -0
- package/dist/two-d/ui/ScrollBox.js +127 -0
- package/dist/two-d/ui/ScrollBox.js.map +1 -0
- package/dist/two-d/ui/Skins.d.ts +59 -0
- package/dist/two-d/ui/Skins.d.ts.map +1 -0
- package/dist/two-d/ui/Skins.js +77 -0
- package/dist/two-d/ui/Skins.js.map +1 -0
- package/dist/two-d/ui/Slider.d.ts +91 -0
- package/dist/two-d/ui/Slider.d.ts.map +1 -0
- package/dist/two-d/ui/Slider.js +170 -0
- package/dist/two-d/ui/Slider.js.map +1 -0
- package/dist/two-d/ui/Spinner.d.ts +67 -0
- package/dist/two-d/ui/Spinner.d.ts.map +1 -0
- package/dist/two-d/ui/Spinner.js +137 -0
- package/dist/two-d/ui/Spinner.js.map +1 -0
- package/dist/two-d/ui/TabbedList.d.ts +118 -0
- package/dist/two-d/ui/TabbedList.d.ts.map +1 -0
- package/dist/two-d/ui/TabbedList.js +221 -0
- package/dist/two-d/ui/TabbedList.js.map +1 -0
- package/dist/two-d/ui/TextModel.d.ts +65 -0
- package/dist/two-d/ui/TextModel.d.ts.map +1 -0
- package/dist/two-d/ui/TextModel.js +126 -0
- package/dist/two-d/ui/TextModel.js.map +1 -0
- package/dist/two-d/ui/TreeView.d.ts +68 -0
- package/dist/two-d/ui/TreeView.d.ts.map +1 -0
- package/dist/two-d/ui/TreeView.js +133 -0
- package/dist/two-d/ui/TreeView.js.map +1 -0
- package/dist/two-d/ui/Window.d.ts +13 -0
- package/dist/two-d/ui/Window.d.ts.map +1 -1
- package/dist/two-d/ui/Window.js +23 -0
- package/dist/two-d/ui/Window.js.map +1 -1
- package/dist/two-d/ui/WindowStack.d.ts.map +1 -1
- package/dist/two-d/ui/WindowStack.js +2 -0
- package/dist/two-d/ui/WindowStack.js.map +1 -1
- package/dist/two-d/ui/index.d.ts +29 -3
- package/dist/two-d/ui/index.d.ts.map +1 -1
- package/dist/two-d/ui/index.js +15 -2
- package/dist/two-d/ui/index.js.map +1 -1
- package/dist/two-d/ui/markup.d.ts +181 -0
- package/dist/two-d/ui/markup.d.ts.map +1 -0
- package/dist/two-d/ui/markup.js +370 -0
- package/dist/two-d/ui/markup.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/world/SideTurns.d.ts +115 -0
- package/dist/world/SideTurns.d.ts.map +1 -0
- package/dist/world/SideTurns.js +138 -0
- package/dist/world/SideTurns.js.map +1 -0
- package/dist/world/index.d.ts +2 -0
- package/dist/world/index.d.ts.map +1 -1
- package/dist/world/index.js +1 -0
- package/dist/world/index.js.map +1 -1
- package/package.json +2 -1
package/dist/mwl/runtime.js
CHANGED
|
@@ -1,9 +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 { integerAttribute, requiredAttribute } from './utils.js';
|
|
4
|
+
import { booleanAttribute, booleanValue, integerAttribute, numberAttribute, requiredAttribute } from './utils.js';
|
|
5
|
+
import { endLevelCarryover } from './carryover.js';
|
|
5
6
|
const required = requiredAttribute;
|
|
6
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
|
+
*/
|
|
7
19
|
export function createWorld() {
|
|
8
20
|
return {
|
|
9
21
|
variables: {},
|
|
@@ -13,6 +25,7 @@ export function createWorld() {
|
|
|
13
25
|
gold: {},
|
|
14
26
|
turn: 1,
|
|
15
27
|
status: 'playing',
|
|
28
|
+
sideStatus: {},
|
|
16
29
|
map: null,
|
|
17
30
|
timeOfDay: '',
|
|
18
31
|
scheduleIndex: 0,
|
|
@@ -22,6 +35,14 @@ export function createWorld() {
|
|
|
22
35
|
* Parse map text: one row per line, comma-separated codes. A token may be
|
|
23
36
|
* `<side> <code>` to mark that side's keep/start, which is how a leader knows
|
|
24
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
|
+
* ```
|
|
25
46
|
*/
|
|
26
47
|
export function parseTerrain(text) {
|
|
27
48
|
const rows = text
|
|
@@ -35,10 +56,11 @@ export function parseTerrain(text) {
|
|
|
35
56
|
for (let y = 0; y < height; y++) {
|
|
36
57
|
for (let x = 0; x < width; x++) {
|
|
37
58
|
const token = rows[y]?.[x] ?? '_off^_usr';
|
|
38
|
-
|
|
59
|
+
//`<side> <code>`: the side is the id `[side]` declares, so a named side works here too
|
|
60
|
+
const keep = /^([A-Za-z0-9_][\w.-]*)\s+(\S.*)$/.exec(token);
|
|
39
61
|
if (keep) {
|
|
40
62
|
codes.push(keep[2]);
|
|
41
|
-
(starts[
|
|
63
|
+
(starts[keep[1]] ??= []).push({ x, y });
|
|
42
64
|
}
|
|
43
65
|
else
|
|
44
66
|
codes.push(token);
|
|
@@ -54,6 +76,16 @@ export function parseTerrain(text) {
|
|
|
54
76
|
* real data (pathfinding costs, combat formulas) stay in the engine adapter;
|
|
55
77
|
* this runtime's `move` is a bounds and occupancy check, and `attack` applies
|
|
56
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
|
+
* ```
|
|
57
89
|
*/
|
|
58
90
|
export class MwlRuntime {
|
|
59
91
|
constructor(game, options = {}) {
|
|
@@ -73,6 +105,8 @@ export class MwlRuntime {
|
|
|
73
105
|
this.loadInitialContent();
|
|
74
106
|
this.loadInitialUnits();
|
|
75
107
|
this.loadLeaders();
|
|
108
|
+
//roles filter over the units, so this runs once they and the leaders are in the world
|
|
109
|
+
this.loadScenarioExtras();
|
|
76
110
|
}
|
|
77
111
|
run(trigger) {
|
|
78
112
|
for (const event of this.nodes('event')) {
|
|
@@ -118,7 +152,7 @@ export class MwlRuntime {
|
|
|
118
152
|
continue;
|
|
119
153
|
if (event.attributes.unit !== undefined && event.attributes.unit !== id)
|
|
120
154
|
continue;
|
|
121
|
-
if (event.attributes.side !== undefined &&
|
|
155
|
+
if (event.attributes.side !== undefined && event.attributes.side !== unit.side)
|
|
122
156
|
continue;
|
|
123
157
|
if (!this.eventFiltersMatch(event))
|
|
124
158
|
continue;
|
|
@@ -150,18 +184,39 @@ export class MwlRuntime {
|
|
|
150
184
|
return !filter || this.filterMatches(filter);
|
|
151
185
|
}
|
|
152
186
|
executeEvent(event) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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);
|
|
156
201
|
continue;
|
|
157
202
|
}
|
|
158
|
-
if (
|
|
159
|
-
this.showDialogue(
|
|
203
|
+
if (child.tag === 'dialogue') {
|
|
204
|
+
this.showDialogue(child);
|
|
160
205
|
continue;
|
|
161
206
|
}
|
|
162
|
-
this.executeNode(
|
|
207
|
+
this.executeNode(child);
|
|
163
208
|
}
|
|
164
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
|
+
}
|
|
165
220
|
executeTracedEvent(event, trigger) {
|
|
166
221
|
const id = event.attributes.id ??
|
|
167
222
|
`${event.attributes.on ?? event.attributes.trigger ?? 'event'}@${event.location?.line ?? 0}`;
|
|
@@ -188,12 +243,13 @@ export class MwlRuntime {
|
|
|
188
243
|
for (const child of node.children) {
|
|
189
244
|
if (child.tag === 'message') {
|
|
190
245
|
this.showMessage(child.attributes);
|
|
191
|
-
const side = Number.parseInt(child.attributes.side ?? '', 10);
|
|
192
246
|
last = {
|
|
193
247
|
text: child.attributes.text ?? child.attributes.value ?? '',
|
|
194
248
|
...(child.attributes.speaker === undefined ? {} : { speaker: child.attributes.speaker }),
|
|
195
249
|
...(child.attributes.portrait === undefined ? {} : { portrait: child.attributes.portrait }),
|
|
196
|
-
...(
|
|
250
|
+
...(child.attributes.side === undefined || child.attributes.side === ''
|
|
251
|
+
? {}
|
|
252
|
+
: { side: child.attributes.side }),
|
|
197
253
|
};
|
|
198
254
|
}
|
|
199
255
|
else if (child.tag === 'say') {
|
|
@@ -206,7 +262,7 @@ export class MwlRuntime {
|
|
|
206
262
|
if (child.tag !== 'choice' || !child.attributes.text)
|
|
207
263
|
continue;
|
|
208
264
|
if (child.attributes.variable !== undefined &&
|
|
209
|
-
!sameValue(this.
|
|
265
|
+
!sameValue(this.variableAt(child.attributes.variable), child.attributes.equals))
|
|
210
266
|
continue;
|
|
211
267
|
const branches = child.children.filter((branch) => branch.tag === 'branch');
|
|
212
268
|
if (branches.length) {
|
|
@@ -264,8 +320,7 @@ export class MwlRuntime {
|
|
|
264
320
|
this.executeEvent(event);
|
|
265
321
|
}
|
|
266
322
|
else {
|
|
267
|
-
|
|
268
|
-
this.executeNode(command);
|
|
323
|
+
this.runBlock(choice.branch ?? []);
|
|
269
324
|
}
|
|
270
325
|
this.checkObjectives();
|
|
271
326
|
return true;
|
|
@@ -294,11 +349,19 @@ export class MwlRuntime {
|
|
|
294
349
|
Object.assign(this.world.gold, restored.gold ?? {});
|
|
295
350
|
this.world.turn = restored.turn;
|
|
296
351
|
this.world.status = restored.status;
|
|
352
|
+
this.world.sideStatus = { ...(restored.sideStatus ?? {}) };
|
|
297
353
|
this.world.map = restored.map ?? null;
|
|
298
354
|
this.world.timeOfDay = restored.timeOfDay ?? this.schedule[0] ?? '';
|
|
299
355
|
this.world.scheduleIndex = restored.scheduleIndex ?? 0;
|
|
300
356
|
// One-shot events stay spent across a load.
|
|
301
357
|
this.world.firedEvents = [...(restored.firedEvents ?? [])];
|
|
358
|
+
// Scenario data a command may have changed, restored wholesale rather than merged: a load
|
|
359
|
+
// is that save's world, not this scenario's with a few fields over it.
|
|
360
|
+
this.world.villages = restored.villages ? structuredClone(restored.villages) : undefined;
|
|
361
|
+
this.world.clearedShroud = restored.clearedShroud ? structuredClone(restored.clearedShroud) : undefined;
|
|
362
|
+
this.world.roles = restored.roles ? structuredClone(restored.roles) : undefined;
|
|
363
|
+
this.world.objects = restored.objects ? structuredClone(restored.objects) : undefined;
|
|
364
|
+
this.world.story = restored.story ? structuredClone(restored.story) : undefined;
|
|
302
365
|
this.world.pendingDialogue = restored.pendingDialogue;
|
|
303
366
|
this.pendingDialogue = restored.pendingDialogue
|
|
304
367
|
? { id: restored.pendingDialogue.id, choices: [...restored.pendingDialogue.choices] }
|
|
@@ -337,6 +400,22 @@ export class MwlRuntime {
|
|
|
337
400
|
entry.controller = side.attributes.controller;
|
|
338
401
|
if (side.attributes.recruit !== undefined)
|
|
339
402
|
entry.recruit = side.attributes.recruit;
|
|
403
|
+
//only what was written, so a world stays as small as the content that made it
|
|
404
|
+
if (side.attributes.team_name !== undefined)
|
|
405
|
+
entry.teamName = side.attributes.team_name;
|
|
406
|
+
if (side.attributes.user_team_name !== undefined)
|
|
407
|
+
entry.userTeamName = side.attributes.user_team_name;
|
|
408
|
+
if (side.attributes.share_vision !== undefined)
|
|
409
|
+
entry.shareVision = side.attributes.share_vision;
|
|
410
|
+
if (side.attributes.flag !== undefined)
|
|
411
|
+
entry.flag = side.attributes.flag;
|
|
412
|
+
if (side.attributes.village_gold !== undefined)
|
|
413
|
+
entry.villageGold = integer(side, 'village_gold', 0);
|
|
414
|
+
for (const key of ['heal', 'fog', 'shroud', 'hidden']) {
|
|
415
|
+
const written = side.attributes[key];
|
|
416
|
+
if (written !== undefined)
|
|
417
|
+
entry[key] = written === 'yes' || written === 'true';
|
|
418
|
+
}
|
|
340
419
|
this.world.sides[required(side, 'id')] = entry;
|
|
341
420
|
}
|
|
342
421
|
for (const map of this.nodes('map')) {
|
|
@@ -346,6 +425,60 @@ export class MwlRuntime {
|
|
|
346
425
|
this.world.map = this.buildMap(id, map);
|
|
347
426
|
}
|
|
348
427
|
}
|
|
428
|
+
/** Direct children of `[game]` with a tag, as opposed to `nodes` which searches the whole tree. */
|
|
429
|
+
topLevelNodes(tag) {
|
|
430
|
+
const game = this.game.roots.find((root) => root.tag === 'game');
|
|
431
|
+
return (game?.children ?? this.game.roots).filter((root) => root.tag === tag);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Scenario-level data that is not a side, a map or a unit: `[object]` placements, the `[story]`
|
|
435
|
+
* beats, and the `[role]` table. Kept as plain data on the world - the framework sequences and
|
|
436
|
+
* serialises it, and the game decides what an object does, what a story beat looks like, and
|
|
437
|
+
* what a role is for.
|
|
438
|
+
*/
|
|
439
|
+
loadScenarioExtras() {
|
|
440
|
+
for (const object of this.topLevelNodes('object')) {
|
|
441
|
+
const x = integer(object, 'x', 0);
|
|
442
|
+
const y = integer(object, 'y', 0);
|
|
443
|
+
const entry = { x, y };
|
|
444
|
+
if (object.attributes.id !== undefined)
|
|
445
|
+
entry.id = object.attributes.id;
|
|
446
|
+
if (object.attributes.name !== undefined)
|
|
447
|
+
entry.name = object.attributes.name;
|
|
448
|
+
if (object.attributes.image !== undefined)
|
|
449
|
+
entry.image = object.attributes.image;
|
|
450
|
+
if (object.attributes.side !== undefined)
|
|
451
|
+
entry.side = object.attributes.side;
|
|
452
|
+
(this.world.objects ??= []).push(entry);
|
|
453
|
+
}
|
|
454
|
+
for (const story of this.topLevelNodes('story')) {
|
|
455
|
+
const text = story.attributes.text ?? story.attributes.value;
|
|
456
|
+
if (text === undefined)
|
|
457
|
+
continue;
|
|
458
|
+
const entry = { text };
|
|
459
|
+
if (story.attributes.title !== undefined)
|
|
460
|
+
entry.title = story.attributes.title;
|
|
461
|
+
if (story.attributes.image !== undefined)
|
|
462
|
+
entry.image = story.attributes.image;
|
|
463
|
+
if (story.attributes.music !== undefined)
|
|
464
|
+
entry.music = story.attributes.music;
|
|
465
|
+
(this.world.story ??= []).push(entry);
|
|
466
|
+
}
|
|
467
|
+
for (const role of this.topLevelNodes('role')) {
|
|
468
|
+
const name = role.attributes.role ?? role.attributes.name;
|
|
469
|
+
if (name === undefined)
|
|
470
|
+
continue;
|
|
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);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
349
482
|
buildMap(id, node) {
|
|
350
483
|
let text = node.attributes.terrain;
|
|
351
484
|
if (!text && node.attributes.file && this.resolveMap)
|
|
@@ -356,7 +489,7 @@ export class MwlRuntime {
|
|
|
356
489
|
for (const start of node.children) {
|
|
357
490
|
if (start.tag !== 'start')
|
|
358
491
|
continue;
|
|
359
|
-
const side =
|
|
492
|
+
const side = start.attributes.side;
|
|
360
493
|
const x = optionalInteger(start, 'x');
|
|
361
494
|
const y = optionalInteger(start, 'y');
|
|
362
495
|
if (side !== undefined && x !== undefined && y !== undefined)
|
|
@@ -380,7 +513,14 @@ export class MwlRuntime {
|
|
|
380
513
|
entry.moves = stats.movement;
|
|
381
514
|
}
|
|
382
515
|
if (unit.attributes.side !== undefined)
|
|
383
|
-
entry.side =
|
|
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;
|
|
384
524
|
this.world.units[id] = entry;
|
|
385
525
|
}
|
|
386
526
|
}
|
|
@@ -389,25 +529,27 @@ export class MwlRuntime {
|
|
|
389
529
|
const leader = side.attributes.leader;
|
|
390
530
|
if (!leader)
|
|
391
531
|
continue;
|
|
392
|
-
const
|
|
393
|
-
if (
|
|
532
|
+
const sideId = side.attributes.id;
|
|
533
|
+
if (!sideId)
|
|
394
534
|
continue;
|
|
395
|
-
const starts = this.world.map?.starts[
|
|
535
|
+
const starts = this.world.map?.starts[sideId];
|
|
396
536
|
if (!starts?.length)
|
|
397
537
|
continue;
|
|
398
538
|
const stats = this.unitTypes.get(leader);
|
|
399
539
|
for (const start of starts) {
|
|
400
540
|
if (this.unitAt(start.x, start.y))
|
|
401
541
|
continue;
|
|
402
|
-
const id = `${leader} ${
|
|
542
|
+
const id = `${leader} ${sideId} (${start.x},${start.y})`;
|
|
403
543
|
this.world.units[id] = {
|
|
404
544
|
hp: stats?.hitpoints ?? 1,
|
|
405
545
|
x: start.x,
|
|
406
546
|
y: start.y,
|
|
407
547
|
alive: true,
|
|
408
548
|
type: leader,
|
|
409
|
-
side:
|
|
549
|
+
side: sideId,
|
|
410
550
|
moves: stats?.movement ?? 0,
|
|
551
|
+
can_recruit: true,
|
|
552
|
+
leader: true,
|
|
411
553
|
};
|
|
412
554
|
}
|
|
413
555
|
}
|
|
@@ -425,14 +567,109 @@ export class MwlRuntime {
|
|
|
425
567
|
break;
|
|
426
568
|
}
|
|
427
569
|
case 'spawn':
|
|
428
|
-
this.spawnUnit(node.attributes.type ?? '', node.attributes.side
|
|
570
|
+
this.spawnUnit(node.attributes.type ?? '', node.attributes.side, integer(node, 'x', 0), integer(node, 'y', 0), node.attributes.id ?? node.attributes.target, node.attributes.hp === undefined ? undefined : integer(node, 'hp', 1));
|
|
429
571
|
break;
|
|
430
572
|
case 'move':
|
|
431
573
|
this.applyMove(node.attributes.unit ?? required(node, 'target'), integer(node, 'x', 0), integer(node, 'y', 0));
|
|
432
574
|
break;
|
|
433
|
-
case 'kill':
|
|
434
|
-
|
|
575
|
+
case 'kill': {
|
|
576
|
+
//a named unit that is not there is a content error; a filter that matches nobody is not,
|
|
577
|
+
//which is what WML's own `[kill]` does. An empty filter matches every unit, there as
|
|
578
|
+
//here, which is worth knowing before writing one.
|
|
579
|
+
const named = node.attributes.unit ?? node.attributes.target;
|
|
580
|
+
if (named === undefined)
|
|
581
|
+
killMatching(this.world, node.attributes);
|
|
582
|
+
else
|
|
583
|
+
this.killUnit(named);
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case 'fire_event': {
|
|
587
|
+
const id = node.attributes.id ?? node.attributes.name;
|
|
588
|
+
if (!id)
|
|
589
|
+
throw new Error('[fire_event] requires id');
|
|
590
|
+
this.fireEvent(id);
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
case 'store_unit': {
|
|
594
|
+
const variable = node.attributes.variable ?? node.attributes.name ?? required(node, 'variable');
|
|
595
|
+
this.setVariableAt(variable, this.matchingUnits(node, ['variable', 'name']).map(([id, unit]) => ({ id, ...unitSnapshot(unit) })));
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
case 'unstore_unit': {
|
|
599
|
+
const variable = required(node, 'variable');
|
|
600
|
+
for (const entry of this.storedUnits(variable))
|
|
601
|
+
this.restoreUnit(entry, {});
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
case 'recall': {
|
|
605
|
+
const stored = this.storedUnits(required(node, 'variable'));
|
|
606
|
+
const wanted = node.attributes.id ?? node.attributes.unit;
|
|
607
|
+
const entry = wanted === undefined ? stored[0] : stored.find((candidate) => candidate.id === wanted);
|
|
608
|
+
if (!entry)
|
|
609
|
+
throw new Error(`[recall] found no stored unit${wanted === undefined ? '' : ` named ${wanted}`}`);
|
|
610
|
+
const placement = {};
|
|
611
|
+
if (node.attributes.x !== undefined)
|
|
612
|
+
placement.x = integer(node, 'x', 0);
|
|
613
|
+
if (node.attributes.y !== undefined)
|
|
614
|
+
placement.y = integer(node, 'y', 0);
|
|
615
|
+
if (node.attributes.side !== undefined)
|
|
616
|
+
placement.side = node.attributes.side;
|
|
617
|
+
this.restoreUnit(entry, placement);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
case 'modify_unit': {
|
|
621
|
+
//WML separates which units (`[filter]`) from what changes (`[set]`); a bare
|
|
622
|
+
//`[modify_unit] hp=5` with no `[set]` changes every unit it matches
|
|
623
|
+
const changes = node.children.find((child) => child.tag === 'set')?.attributes ?? node.attributes;
|
|
624
|
+
for (const [, unit] of this.matchingUnits(node))
|
|
625
|
+
applyUnitChanges(unit, changes);
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
case 'heal_unit': {
|
|
629
|
+
const amount = optionalInteger(node, 'amount');
|
|
630
|
+
const absolute = optionalInteger(node, 'hp');
|
|
631
|
+
if (amount === undefined && absolute === undefined)
|
|
632
|
+
throw new Error('[heal_unit] requires amount or hp');
|
|
633
|
+
for (const [, unit] of this.matchingUnits(node))
|
|
634
|
+
unit.hp = absolute ?? unit.hp + (amount ?? 0);
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
case 'set_terrain':
|
|
638
|
+
this.setTerrain(integer(node, 'x', 0), integer(node, 'y', 0), required(node, 'terrain'));
|
|
639
|
+
break;
|
|
640
|
+
case 'capture_village': {
|
|
641
|
+
const x = integer(node, 'x', 0);
|
|
642
|
+
const y = integer(node, 'y', 0);
|
|
643
|
+
const entry = { x, y, side: required(node, 'side') };
|
|
644
|
+
if (node.attributes.name !== undefined)
|
|
645
|
+
entry.name = node.attributes.name;
|
|
646
|
+
(this.world.villages ??= {})[`${x},${y}`] = entry;
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
case 'clear_shroud': {
|
|
650
|
+
const side = required(node, 'side');
|
|
651
|
+
const x = integer(node, 'x', 0);
|
|
652
|
+
const y = integer(node, 'y', 0);
|
|
653
|
+
const radius = integer(node, 'radius', 1);
|
|
654
|
+
const cleared = new Set(this.world.clearedShroud?.[side] ?? []);
|
|
655
|
+
for (let dy = -radius; dy <= radius; dy++)
|
|
656
|
+
for (let dx = -radius; dx <= radius; dx++)
|
|
657
|
+
cleared.add(`${x + dx},${y + dy}`);
|
|
658
|
+
(this.world.clearedShroud ??= {})[side] = [...cleared];
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
case 'role': {
|
|
662
|
+
const name = node.attributes.role ?? node.attributes.name;
|
|
663
|
+
if (!name)
|
|
664
|
+
throw new Error('[role] requires role');
|
|
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);
|
|
435
671
|
break;
|
|
672
|
+
}
|
|
436
673
|
case 'attack':
|
|
437
674
|
this.attack(node.attributes.defender ?? node.attributes.target ?? required(node, 'target'), integer(node, 'amount', 0));
|
|
438
675
|
break;
|
|
@@ -450,8 +687,7 @@ export class MwlRuntime {
|
|
|
450
687
|
if (limit < 1 || limit > 100_000)
|
|
451
688
|
throw new Error('MWL while max_iterations must be between 1 and 100000');
|
|
452
689
|
for (let iteration = 0; iteration < limit && this.nodeConditionMatches(node); iteration++)
|
|
453
|
-
|
|
454
|
-
this.executeNode(child);
|
|
690
|
+
this.runBlock(this.commandChildren(node));
|
|
455
691
|
break;
|
|
456
692
|
}
|
|
457
693
|
case 'foreach': {
|
|
@@ -471,8 +707,7 @@ export class MwlRuntime {
|
|
|
471
707
|
for (let index = 0; index < entries.length; index++) {
|
|
472
708
|
this.setVariableAt(item, entries[index][1]);
|
|
473
709
|
this.setVariableAt(indexName, index);
|
|
474
|
-
|
|
475
|
-
this.executeNode(child);
|
|
710
|
+
this.runBlock(this.commandChildren(node));
|
|
476
711
|
}
|
|
477
712
|
break;
|
|
478
713
|
}
|
|
@@ -482,28 +717,43 @@ export class MwlRuntime {
|
|
|
482
717
|
child.attributes.equals !== undefined &&
|
|
483
718
|
sameValue(value, child.attributes.equals));
|
|
484
719
|
const fallback = node.children.find((child) => child.tag === 'default');
|
|
485
|
-
|
|
486
|
-
this.executeNode(child);
|
|
720
|
+
this.runBlock((selected ?? fallback)?.children ?? []);
|
|
487
721
|
break;
|
|
488
722
|
}
|
|
489
723
|
case 'end_turn':
|
|
490
724
|
this.endTurn();
|
|
491
725
|
break;
|
|
492
726
|
case 'win':
|
|
727
|
+
this.markSideResult(node.attributes.side, 'won');
|
|
493
728
|
this.world.status = 'won';
|
|
494
729
|
break;
|
|
495
730
|
case 'lose':
|
|
731
|
+
this.markSideResult(node.attributes.side, 'lost');
|
|
496
732
|
this.world.status = 'lost';
|
|
497
733
|
break;
|
|
498
|
-
case '
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
734
|
+
case 'endlevel': {
|
|
735
|
+
const result = node.attributes.result === 'defeat' ? 'defeat' : 'victory';
|
|
736
|
+
this.world.status = result === 'defeat' ? 'lost' : 'won';
|
|
737
|
+
this.world.carryover = endLevelCarryover(this.world, endLevelSide(this.world, node.attributes.side), {
|
|
738
|
+
result,
|
|
739
|
+
bonus: integerAttribute(node, 'bonus'),
|
|
740
|
+
carryoverPercentage: numberAttribute(node, 'carryover_percentage'),
|
|
741
|
+
carryoverAdd: booleanAttribute(node, 'carryover_add'),
|
|
742
|
+
nextScenario: node.attributes.next_scenario ?? null,
|
|
743
|
+
});
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
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) : []);
|
|
503
752
|
break;
|
|
753
|
+
}
|
|
504
754
|
case 'else':
|
|
505
|
-
for
|
|
506
|
-
|
|
755
|
+
//reachable at top level for a free-standing [else]; a paired one is run by its [if]
|
|
756
|
+
this.runBlock(this.commandChildren(node));
|
|
507
757
|
break;
|
|
508
758
|
case 'hook':
|
|
509
759
|
this.runHook(node);
|
|
@@ -512,6 +762,15 @@ export class MwlRuntime {
|
|
|
512
762
|
throw new Error(`unknown MWL command: ${name}`);
|
|
513
763
|
}
|
|
514
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
|
+
}
|
|
515
774
|
runHook(node) {
|
|
516
775
|
const reference = node.attributes.name ?? '';
|
|
517
776
|
const hook = this.hooks?.command?.[reference];
|
|
@@ -545,12 +804,11 @@ export class MwlRuntime {
|
|
|
545
804
|
showMessage(attributes) {
|
|
546
805
|
if (!this.onMessage)
|
|
547
806
|
return;
|
|
548
|
-
const side = Number.parseInt(attributes.side ?? '', 10);
|
|
549
807
|
const message = {
|
|
550
808
|
text: this.interpolate(attributes.text ?? attributes.value ?? ''),
|
|
551
809
|
...(attributes.speaker === undefined ? {} : { speaker: attributes.speaker }),
|
|
552
810
|
...(attributes.portrait === undefined ? {} : { portrait: attributes.portrait }),
|
|
553
|
-
...(
|
|
811
|
+
...(attributes.side === undefined || attributes.side === '' ? {} : { side: attributes.side }),
|
|
554
812
|
};
|
|
555
813
|
this.onMessage(message);
|
|
556
814
|
}
|
|
@@ -560,8 +818,7 @@ export class MwlRuntime {
|
|
|
560
818
|
* same interpolation WML authors expect in story and dialogue strings.
|
|
561
819
|
*/
|
|
562
820
|
interpolate(text) {
|
|
563
|
-
const
|
|
564
|
-
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'));
|
|
565
822
|
const expanded = text.replace(/\$\(([^)]*)\)/g, (_whole, expression) => {
|
|
566
823
|
try {
|
|
567
824
|
return String(evaluateExpression(expression, numeric));
|
|
@@ -571,7 +828,7 @@ export class MwlRuntime {
|
|
|
571
828
|
}
|
|
572
829
|
});
|
|
573
830
|
return expanded.replace(/\$([A-Za-z_][A-Za-z0-9_.]*)/g, (_whole, name) => {
|
|
574
|
-
const value =
|
|
831
|
+
const value = this.variableAt(name);
|
|
575
832
|
return value === undefined || typeof value === 'boolean' ? '' : String(value);
|
|
576
833
|
});
|
|
577
834
|
}
|
|
@@ -585,7 +842,7 @@ export class MwlRuntime {
|
|
|
585
842
|
// so content can alias changing values without arithmetic.
|
|
586
843
|
const reference = /^\$([A-Za-z_][A-Za-z0-9_.]*)$/.exec(raw.trim());
|
|
587
844
|
if (reference) {
|
|
588
|
-
const value = this.
|
|
845
|
+
const value = this.variableAt(reference[1]);
|
|
589
846
|
this.setVariableAt(name, value === undefined ? '' : value);
|
|
590
847
|
return;
|
|
591
848
|
}
|
|
@@ -597,35 +854,16 @@ export class MwlRuntime {
|
|
|
597
854
|
this.setVariableAt(name, raw);
|
|
598
855
|
}
|
|
599
856
|
variableAt(path) {
|
|
600
|
-
|
|
601
|
-
for (const part of path.split('.')) {
|
|
602
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
603
|
-
return undefined;
|
|
604
|
-
value = value[part];
|
|
605
|
-
}
|
|
606
|
-
return value;
|
|
857
|
+
return variableAtPath(this.world.variables, path);
|
|
607
858
|
}
|
|
608
859
|
setVariableAt(path, value) {
|
|
609
860
|
const previous = this.variableAt(path);
|
|
610
|
-
|
|
611
|
-
if (parts.length === 1) {
|
|
612
|
-
this.world.variables[path] = value;
|
|
613
|
-
this.onTrace?.({ type: 'variable', name: path, ...(previous === undefined ? {} : { previous }), value });
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
let current = this.world.variables;
|
|
617
|
-
for (const part of parts.slice(0, -1)) {
|
|
618
|
-
const child = current[part];
|
|
619
|
-
if (!child || typeof child !== 'object' || Array.isArray(child))
|
|
620
|
-
current[part] = {};
|
|
621
|
-
current = current[part];
|
|
622
|
-
}
|
|
623
|
-
current[parts.at(-1)] = value;
|
|
861
|
+
setVariableAtPath(this.world.variables, path, value);
|
|
624
862
|
this.onTrace?.({ type: 'variable', name: path, ...(previous === undefined ? {} : { previous }), value });
|
|
625
863
|
}
|
|
626
864
|
spawnUnit(type, side, x, y, id, hp) {
|
|
627
865
|
const stats = type ? this.unitTypes.get(type) : undefined;
|
|
628
|
-
const key = id ?? `${type || 'unit'}#${side ??
|
|
866
|
+
const key = id ?? `${type || 'unit'}#${side ?? ''}@${x},${y}`;
|
|
629
867
|
this.world.units[key] = {
|
|
630
868
|
hp: hp ?? stats?.hitpoints ?? 1,
|
|
631
869
|
x,
|
|
@@ -642,6 +880,64 @@ export class MwlRuntime {
|
|
|
642
880
|
throw new Error(`MWL unit is not alive: ${id}`);
|
|
643
881
|
unit.alive = false;
|
|
644
882
|
}
|
|
883
|
+
/**
|
|
884
|
+
* Live units a command's filter selects: its `[filter]` child when it has one, otherwise its
|
|
885
|
+
* own attributes, read by the same `unitMatchesFilter` an event filter and `[kill]` use. An
|
|
886
|
+
* empty filter matches every live unit, the rule `[kill]` already documents.
|
|
887
|
+
*/
|
|
888
|
+
matchingUnits(node, exclude = []) {
|
|
889
|
+
const filter = node.children.find((child) => child.tag === 'filter');
|
|
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);
|
|
893
|
+
return Object.entries(this.world.units).filter(([id, unit]) => unit.alive && unitMatchesFilter(unit, id, attributes));
|
|
894
|
+
}
|
|
895
|
+
/** The unit snapshots `[store_unit]` wrote into a world variable, or a named content error. */
|
|
896
|
+
storedUnits(variable) {
|
|
897
|
+
const stored = this.variableAt(variable);
|
|
898
|
+
if (!Array.isArray(stored))
|
|
899
|
+
throw new Error(`MWL variable ${variable} is not a stored unit list; store one first`);
|
|
900
|
+
return stored.filter((entry) => !!entry && typeof entry === 'object' && !Array.isArray(entry));
|
|
901
|
+
}
|
|
902
|
+
/** Writes a stored snapshot back into the world, applying any placement overrides. */
|
|
903
|
+
restoreUnit(entry, placement) {
|
|
904
|
+
const id = typeof entry.id === 'string' ? entry.id : undefined;
|
|
905
|
+
if (!id)
|
|
906
|
+
throw new Error('a stored unit snapshot is missing its id');
|
|
907
|
+
const unit = {
|
|
908
|
+
hp: typeof entry.hp === 'number' ? entry.hp : 1,
|
|
909
|
+
x: placement.x ?? (typeof entry.x === 'number' ? entry.x : 0),
|
|
910
|
+
y: placement.y ?? (typeof entry.y === 'number' ? entry.y : 0),
|
|
911
|
+
alive: entry.alive !== false,
|
|
912
|
+
};
|
|
913
|
+
if (typeof entry.type === 'string')
|
|
914
|
+
unit.type = entry.type;
|
|
915
|
+
const side = placement.side ?? (typeof entry.side === 'string' ? entry.side : undefined);
|
|
916
|
+
if (side !== undefined)
|
|
917
|
+
unit.side = side;
|
|
918
|
+
if (typeof entry.moves === 'number')
|
|
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;
|
|
928
|
+
this.world.units[id] = unit;
|
|
929
|
+
}
|
|
930
|
+
/** Changes one cell of the primary map, keeping the parsed grid and its size in step. */
|
|
931
|
+
setTerrain(x, y, terrain) {
|
|
932
|
+
const map = this.world.map;
|
|
933
|
+
if (!map)
|
|
934
|
+
throw new Error('[set_terrain] needs a loaded map');
|
|
935
|
+
if (x < 0 || y < 0 || x >= map.width || y >= map.height)
|
|
936
|
+
throw new Error(`[set_terrain] is outside the map: ${x},${y}`);
|
|
937
|
+
const codes = [...map.codes];
|
|
938
|
+
codes[y * map.width + x] = terrain;
|
|
939
|
+
this.world.map = { ...map, codes };
|
|
940
|
+
}
|
|
645
941
|
addGold(side, delta) {
|
|
646
942
|
this.world.gold[side] = (this.world.gold[side] ?? 0) + delta;
|
|
647
943
|
}
|
|
@@ -650,6 +946,22 @@ export class MwlRuntime {
|
|
|
650
946
|
this.advanceSchedule();
|
|
651
947
|
this.resetMoves();
|
|
652
948
|
this.run('turn');
|
|
949
|
+
this.checkTimeOver();
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Wesnoth's "time over", as a framework native: once the scenario's `turn_limit` has passed,
|
|
953
|
+
* `time_over` becomes a variable content can test and an event trigger it can answer. The
|
|
954
|
+
* framework reports it rather than ending anything, because a turn limit is usually a defeat and
|
|
955
|
+
* occasionally the whole point of the scenario ("hold out until then"), so what it means is
|
|
956
|
+
* content's business - `[endlevel]` is right there for it. Fired once, and derived from the turn
|
|
957
|
+
* counter rather than kept in step with it.
|
|
958
|
+
*/
|
|
959
|
+
checkTimeOver() {
|
|
960
|
+
const limit = integerAttribute(this.game.roots[0], 'turn_limit');
|
|
961
|
+
if (limit === undefined || this.world.turn <= limit || this.world.variables.time_over === 'yes')
|
|
962
|
+
return;
|
|
963
|
+
this.world.variables.time_over = 'yes';
|
|
964
|
+
this.run('time_over');
|
|
653
965
|
}
|
|
654
966
|
advanceSchedule() {
|
|
655
967
|
if (!this.schedule.length)
|
|
@@ -680,7 +992,20 @@ export class MwlRuntime {
|
|
|
680
992
|
conditionMatches(node) {
|
|
681
993
|
return node.children
|
|
682
994
|
.filter((child) => child.tag === 'condition')
|
|
683
|
-
.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
|
+
});
|
|
684
1009
|
}
|
|
685
1010
|
nodeConditionMatches(node) {
|
|
686
1011
|
if (node.attributes.test !== undefined)
|
|
@@ -702,7 +1027,7 @@ export class MwlRuntime {
|
|
|
702
1027
|
const name = child.attributes.name;
|
|
703
1028
|
if (name === undefined)
|
|
704
1029
|
return false;
|
|
705
|
-
return variableMatches(this.
|
|
1030
|
+
return variableMatches(this.variableAt(name), child.attributes, resolveVariable(this.world.variables));
|
|
706
1031
|
}
|
|
707
1032
|
if (child.tag === 'predicate') {
|
|
708
1033
|
const name = child.attributes.name;
|
|
@@ -721,12 +1046,17 @@ export class MwlRuntime {
|
|
|
721
1046
|
return true;
|
|
722
1047
|
});
|
|
723
1048
|
}
|
|
724
|
-
|
|
1049
|
+
/**
|
|
1050
|
+
* Whether one objective's condition holds. `defaultSide` is the side whose own
|
|
1051
|
+
* `[victory]`/`[defeat]` node this is, so a condition that names no side (a side losing
|
|
1052
|
+
* when its own units are gone) reads as that side rather than as nobody.
|
|
1053
|
+
*/
|
|
1054
|
+
conditionMet(node, defaultSide) {
|
|
725
1055
|
const condition = node.attributes.condition ?? '';
|
|
726
|
-
const side =
|
|
1056
|
+
const side = node.attributes.side ?? defaultSide;
|
|
727
1057
|
switch (condition) {
|
|
728
1058
|
case 'units_dead': {
|
|
729
|
-
const target =
|
|
1059
|
+
const target = node.attributes.side_filter ?? side;
|
|
730
1060
|
if (target === undefined)
|
|
731
1061
|
return false;
|
|
732
1062
|
return !Object.values(this.world.units).some((unit) => unit.alive && unit.side === target);
|
|
@@ -737,14 +1067,13 @@ export class MwlRuntime {
|
|
|
737
1067
|
}
|
|
738
1068
|
case 'gold_at_least': {
|
|
739
1069
|
const gold = optionalInteger(node, 'gold');
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
(this.world.gold[node.attributes.side] ?? 0) >= gold);
|
|
1070
|
+
const goldSide = node.attributes.side ?? defaultSide;
|
|
1071
|
+
return gold !== undefined && goldSide !== undefined && (this.world.gold[goldSide] ?? 0) >= gold;
|
|
743
1072
|
}
|
|
744
1073
|
case 'unit_at': {
|
|
745
1074
|
const x = optionalInteger(node, 'x');
|
|
746
1075
|
const y = optionalInteger(node, 'y');
|
|
747
|
-
const targetSide =
|
|
1076
|
+
const targetSide = node.attributes.side_filter ?? side;
|
|
748
1077
|
return Object.values(this.world.units).some((unit) => unit.alive &&
|
|
749
1078
|
(targetSide === undefined || unit.side === targetSide) &&
|
|
750
1079
|
(node.attributes.type === undefined || unit.type === node.attributes.type) &&
|
|
@@ -765,15 +1094,60 @@ export class MwlRuntime {
|
|
|
765
1094
|
return false;
|
|
766
1095
|
}
|
|
767
1096
|
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Evaluates the scenario-wide `[objectives]` conditions first, exactly as before, then each
|
|
1099
|
+
* side's own `[victory]`/`[defeat]` children. The scenario ends on the first side whose own
|
|
1100
|
+
* condition fires, recording that side in `world.sideStatus`; a side's victory outranks
|
|
1101
|
+
* another side's defeat when both fire in the same evaluation, which is the same precedence
|
|
1102
|
+
* the scenario-wide pair has always had. A side condition with no `side`/`side_filter` reads
|
|
1103
|
+
* as that side's own, so "this side loses when it has no units left" is written without
|
|
1104
|
+
* repeating the id.
|
|
1105
|
+
*/
|
|
768
1106
|
checkObjectives() {
|
|
769
1107
|
if (this.world.status !== 'playing')
|
|
770
1108
|
return;
|
|
771
|
-
|
|
1109
|
+
// a `[victory]`/`[defeat]` under a `[side]` is that side's own, never the scenario's
|
|
1110
|
+
const sideConditions = new Set();
|
|
1111
|
+
const sides = this.nodes('side');
|
|
1112
|
+
for (const side of sides) {
|
|
1113
|
+
for (const child of side.children) {
|
|
1114
|
+
if (child.tag === 'victory' || child.tag === 'defeat')
|
|
1115
|
+
sideConditions.add(child);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (this.nodes('victory').some((node) => !sideConditions.has(node) && this.conditionMet(node))) {
|
|
772
1119
|
this.world.status = 'won';
|
|
773
1120
|
return;
|
|
774
1121
|
}
|
|
775
|
-
if (this.nodes('defeat').some((node) => this.conditionMet(node)))
|
|
1122
|
+
if (this.nodes('defeat').some((node) => !sideConditions.has(node) && this.conditionMet(node))) {
|
|
776
1123
|
this.world.status = 'lost';
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
let sideWon = false;
|
|
1127
|
+
let sideLost = false;
|
|
1128
|
+
for (const side of sides) {
|
|
1129
|
+
const id = side.attributes.id;
|
|
1130
|
+
if (!id || (this.world.sideStatus?.[id] ?? 'playing') !== 'playing')
|
|
1131
|
+
continue;
|
|
1132
|
+
if (side.children.some((child) => child.tag === 'victory' && this.conditionMet(child, id))) {
|
|
1133
|
+
this.markSideResult(id, 'won');
|
|
1134
|
+
sideWon = true;
|
|
1135
|
+
}
|
|
1136
|
+
else if (side.children.some((child) => child.tag === 'defeat' && this.conditionMet(child, id))) {
|
|
1137
|
+
this.markSideResult(id, 'lost');
|
|
1138
|
+
sideLost = true;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (sideWon)
|
|
1142
|
+
this.world.status = 'won';
|
|
1143
|
+
else if (sideLost)
|
|
1144
|
+
this.world.status = 'lost';
|
|
1145
|
+
}
|
|
1146
|
+
/** Records one side's own result; the scenario status is set by whichever side fired first. */
|
|
1147
|
+
markSideResult(side, result) {
|
|
1148
|
+
if (side === undefined || side === '')
|
|
1149
|
+
return;
|
|
1150
|
+
(this.world.sideStatus ??= {})[side] = result;
|
|
777
1151
|
}
|
|
778
1152
|
worldView() {
|
|
779
1153
|
return {
|
|
@@ -791,16 +1165,18 @@ export class MwlRuntime {
|
|
|
791
1165
|
},
|
|
792
1166
|
spawn: (type, side, x, y) => this.spawnUnit(type, side, x, y),
|
|
793
1167
|
kill: (unit) => this.killUnit(unit),
|
|
794
|
-
gold: (side, delta) => this.addGold(
|
|
1168
|
+
gold: (side, delta) => this.addGold(side, delta),
|
|
795
1169
|
setVariable: (name, value) => {
|
|
796
|
-
this.world.variables
|
|
1170
|
+
setVariableAtPath(this.world.variables, name, value);
|
|
797
1171
|
},
|
|
798
1172
|
message: (speaker, text) => this.onMessage?.({ text, ...(speaker ? { speaker } : {}) }),
|
|
799
1173
|
endTurn: () => this.endTurn(),
|
|
800
|
-
win: (
|
|
1174
|
+
win: (side) => {
|
|
1175
|
+
this.markSideResult(side, 'won');
|
|
801
1176
|
this.world.status = 'won';
|
|
802
1177
|
},
|
|
803
|
-
lose: (
|
|
1178
|
+
lose: (side) => {
|
|
1179
|
+
this.markSideResult(side, 'lost');
|
|
804
1180
|
this.world.status = 'lost';
|
|
805
1181
|
},
|
|
806
1182
|
};
|
|
@@ -824,12 +1200,24 @@ export class MwlRuntime {
|
|
|
824
1200
|
return result;
|
|
825
1201
|
}
|
|
826
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
|
+
*/
|
|
827
1215
|
export function execute(world, command) {
|
|
828
1216
|
if (world.status !== 'playing' && command.name !== 'win' && command.name !== 'lose')
|
|
829
1217
|
return;
|
|
830
1218
|
switch (command.name) {
|
|
831
1219
|
case 'set_variable':
|
|
832
|
-
world.variables
|
|
1220
|
+
setVariableAtPath(world.variables, command.target, command.value);
|
|
833
1221
|
break;
|
|
834
1222
|
case 'modify_gold':
|
|
835
1223
|
world.gold[command.target] = (world.gold[command.target] ?? 0) + command.amount;
|
|
@@ -844,25 +1232,139 @@ export function execute(world, command) {
|
|
|
844
1232
|
world.units[command.target] = { hp: command.hp, x: command.x, y: command.y, alive: true };
|
|
845
1233
|
break;
|
|
846
1234
|
case 'kill':
|
|
847
|
-
|
|
1235
|
+
if (command.target !== undefined)
|
|
1236
|
+
requireUnit(world, command.target).alive = false;
|
|
1237
|
+
else
|
|
1238
|
+
killMatching(world, command.filter ?? {});
|
|
848
1239
|
break;
|
|
849
1240
|
case 'end_turn':
|
|
850
1241
|
world.turn++;
|
|
851
1242
|
break;
|
|
852
1243
|
case 'win':
|
|
1244
|
+
if (command.side !== undefined && command.side !== '')
|
|
1245
|
+
(world.sideStatus ??= {})[command.side] = 'won';
|
|
853
1246
|
world.status = 'won';
|
|
854
1247
|
break;
|
|
855
1248
|
case 'lose':
|
|
1249
|
+
if (command.side !== undefined && command.side !== '')
|
|
1250
|
+
(world.sideStatus ??= {})[command.side] = 'lost';
|
|
856
1251
|
world.status = 'lost';
|
|
857
1252
|
break;
|
|
1253
|
+
case 'endlevel':
|
|
1254
|
+
world.status = command.result === 'defeat' ? 'lost' : 'won';
|
|
1255
|
+
world.carryover = endLevelCarryover(world, command.side ?? null, command);
|
|
1256
|
+
break;
|
|
858
1257
|
}
|
|
859
1258
|
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Which side an `[endlevel]` carries: the one it names, or the human-controlled side when it names
|
|
1261
|
+
* none. `null` when there is no such side, which is not an error - a scenario can end with nobody
|
|
1262
|
+
* to carry.
|
|
1263
|
+
*/
|
|
1264
|
+
function endLevelSide(world, named) {
|
|
1265
|
+
const id = named ?? Object.entries(world.sides).find(([, side]) => side.controller === 'human')?.[0];
|
|
1266
|
+
if (!id || !(id in world.sides))
|
|
1267
|
+
return null;
|
|
1268
|
+
return { id };
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* The sides a scenario's content says see together, as groups ready for `FactionFog.share`.
|
|
1272
|
+
*
|
|
1273
|
+
* Wesnoth groups sides into teams with `[side] team_name`, and `share_vision` says whether a side
|
|
1274
|
+
* shares what it sees with its team. `none` does not; `all` and `shroud` are treated alike here
|
|
1275
|
+
* because `FactionFog` shares sight and memory as one thing rather than keeping them apart, and a
|
|
1276
|
+
* fog that separated them would be the place to tell the two apart. A side with no `team_name`
|
|
1277
|
+
* shares with nobody, and a team of one is not returned, since sharing with nobody is what it
|
|
1278
|
+
* already does.
|
|
1279
|
+
*
|
|
1280
|
+
* @example
|
|
1281
|
+
* ```ts
|
|
1282
|
+
* import { sideVisionGroups, type MwlWorld } from '@datamoc/mw_games/mwl';
|
|
1283
|
+
*
|
|
1284
|
+
* declare const world: MwlWorld;
|
|
1285
|
+
*
|
|
1286
|
+
* // sides 1 and 2 both wrote team_name=north, and side 3 asked for share_vision=none
|
|
1287
|
+
* console.log(sideVisionGroups(world)); // [['1', '2']]
|
|
1288
|
+
* ```
|
|
1289
|
+
*/
|
|
1290
|
+
export function sideVisionGroups(world) {
|
|
1291
|
+
const teams = new Map();
|
|
1292
|
+
for (const [id, side] of Object.entries(world.sides)) {
|
|
1293
|
+
if (!side.teamName || side.shareVision === 'none')
|
|
1294
|
+
continue;
|
|
1295
|
+
const group = teams.get(side.teamName);
|
|
1296
|
+
if (group)
|
|
1297
|
+
group.push(id);
|
|
1298
|
+
else
|
|
1299
|
+
teams.set(side.teamName, [id]);
|
|
1300
|
+
}
|
|
1301
|
+
return [...teams.values()].filter((group) => group.length > 1);
|
|
1302
|
+
}
|
|
860
1303
|
function requireUnit(world, id) {
|
|
861
1304
|
const unit = world.units[id];
|
|
862
1305
|
if (!unit || !unit.alive)
|
|
863
1306
|
throw new Error(`MWL unit is not alive: ${id}`);
|
|
864
1307
|
return unit;
|
|
865
1308
|
}
|
|
1309
|
+
/** Kills every alive unit a `[kill]` filter matches, and says how many it killed. */
|
|
1310
|
+
function killMatching(world, attributes) {
|
|
1311
|
+
let killed = 0;
|
|
1312
|
+
for (const [id, unit] of Object.entries(world.units)) {
|
|
1313
|
+
if (!unit.alive || !unitMatchesFilter(unit, id, attributes))
|
|
1314
|
+
continue;
|
|
1315
|
+
unit.alive = false;
|
|
1316
|
+
killed++;
|
|
1317
|
+
}
|
|
1318
|
+
return killed;
|
|
1319
|
+
}
|
|
1320
|
+
/** One unit as `[store_unit]` writes it: only the fields it has, so a variable stays small. */
|
|
1321
|
+
function unitSnapshot(unit) {
|
|
1322
|
+
const snapshot = { hp: unit.hp, x: unit.x, y: unit.y, alive: unit.alive };
|
|
1323
|
+
if (unit.type !== undefined)
|
|
1324
|
+
snapshot.type = unit.type;
|
|
1325
|
+
if (unit.side !== undefined)
|
|
1326
|
+
snapshot.side = unit.side;
|
|
1327
|
+
if (unit.moves !== undefined)
|
|
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;
|
|
1337
|
+
return snapshot;
|
|
1338
|
+
}
|
|
1339
|
+
/** Applies `[modify_unit]`'s chosen attributes to one unit, ignoring anything it did not name. */
|
|
1340
|
+
function applyUnitChanges(unit, changes) {
|
|
1341
|
+
const hp = finiteNumber(changes.hp);
|
|
1342
|
+
if (hp !== undefined)
|
|
1343
|
+
unit.hp = hp;
|
|
1344
|
+
const moves = finiteNumber(changes.moves);
|
|
1345
|
+
if (moves !== undefined)
|
|
1346
|
+
unit.moves = moves;
|
|
1347
|
+
if (changes.type !== undefined)
|
|
1348
|
+
unit.type = changes.type;
|
|
1349
|
+
if (changes.side !== undefined)
|
|
1350
|
+
unit.side = changes.side;
|
|
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;
|
|
1361
|
+
}
|
|
1362
|
+
function finiteNumber(value) {
|
|
1363
|
+
if (value === undefined || value === '')
|
|
1364
|
+
return undefined;
|
|
1365
|
+
const parsed = Number(value);
|
|
1366
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1367
|
+
}
|
|
866
1368
|
function optionalInteger(node, attribute) {
|
|
867
1369
|
const value = node.attributes[attribute];
|
|
868
1370
|
if (value === undefined || value === '')
|
|
@@ -876,12 +1378,19 @@ function optionalInteger(node, attribute) {
|
|
|
876
1378
|
* lists split on commas, comparisons coerce both sides with `Number` and
|
|
877
1379
|
* fail on anything non-numeric.
|
|
878
1380
|
*/
|
|
879
|
-
function variableMatches(value, attributes,
|
|
880
|
-
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
|
+
};
|
|
881
1390
|
if (attributes.equals !== undefined)
|
|
882
|
-
return sameValue(value,
|
|
1391
|
+
return sameValue(value, expected(attributes.equals));
|
|
883
1392
|
if (attributes.not_equals !== undefined)
|
|
884
|
-
return !sameValue(value,
|
|
1393
|
+
return !sameValue(value, expected(attributes.not_equals));
|
|
885
1394
|
if (attributes.in !== undefined) {
|
|
886
1395
|
const options = attributes.in.split(',').map((entry) => entry.trim());
|
|
887
1396
|
return options.some((option) => sameValue(value, option));
|
|
@@ -912,22 +1421,38 @@ function variableMatches(value, attributes, variables = {}) {
|
|
|
912
1421
|
}
|
|
913
1422
|
/**
|
|
914
1423
|
* One alive unit against `filter`/`have_unit` attributes: side, type (with
|
|
915
|
-
* comma-list `not_type` exclusion),
|
|
916
|
-
*
|
|
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.
|
|
917
1427
|
*/
|
|
918
1428
|
function unitMatchesFilter(unit, id, attributes) {
|
|
919
1429
|
const excluded = (attributes.not_type ?? '')
|
|
920
1430
|
.split(',')
|
|
921
1431
|
.map((entry) => entry.trim())
|
|
922
1432
|
.filter(Boolean);
|
|
1433
|
+
const wantedCanRecruit = booleanValue(attributes.can_recruit);
|
|
1434
|
+
const wantedLeader = booleanValue(attributes.leader);
|
|
923
1435
|
const key = attributes.unit ?? attributes.id;
|
|
924
1436
|
return ((key === undefined || id === key) &&
|
|
925
|
-
(attributes.side === undefined || unit.side ===
|
|
1437
|
+
(attributes.side === undefined || unit.side === attributes.side) &&
|
|
926
1438
|
(attributes.type === undefined || unit.type === attributes.type) &&
|
|
927
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) &&
|
|
928
1444
|
(attributes.x === undefined || unit.x === Number(attributes.x)) &&
|
|
929
1445
|
(attributes.y === undefined || unit.y === Number(attributes.y)));
|
|
930
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
|
+
}
|
|
931
1456
|
function sameValue(value, expected) {
|
|
932
1457
|
if (value === undefined || expected === undefined)
|
|
933
1458
|
return value === expected;
|
|
@@ -941,6 +1466,73 @@ function sameValue(value, expected) {
|
|
|
941
1466
|
function primitiveVariables(variables) {
|
|
942
1467
|
return Object.fromEntries(Object.entries(variables).filter((entry) => typeof entry[1] === 'string' || typeof entry[1] === 'number' || typeof entry[1] === 'boolean'));
|
|
943
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
|
+
}
|
|
944
1536
|
function coordinateMatches(specification, coordinate) {
|
|
945
1537
|
return specification.split(',').some((part) => {
|
|
946
1538
|
const value = part.trim();
|